mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b8a545589 | ||
|
|
5ab47596ff | ||
|
|
a32702cf38 | ||
|
|
8b743af217 | ||
|
|
0e152a0e33 | ||
|
|
3b77192ad0 | ||
|
|
defe0f1a89 | ||
|
|
85d70f01f6 | ||
|
|
6930c0f0b6 | ||
|
|
d83cf93f07 | ||
|
|
8783ac58f1 | ||
|
|
e15eab7da6 | ||
|
|
19a9e13788 | ||
|
|
b0a7a1fcb8 | ||
|
|
a841bdd1cc | ||
|
|
d46adffe6c | ||
|
|
b0b5777363 | ||
|
|
37b4cfd024 | ||
|
|
ff9343d7cc | ||
|
|
8ff34f9a43 | ||
|
|
e3f8bfc645 | ||
|
|
b4f2709b6d | ||
|
|
e5c11d38d6 | ||
|
|
a71f768331 | ||
|
|
0298e0a401 | ||
|
|
ca1532cf22 | ||
|
|
360839782c | ||
|
|
ee53fe4666 | ||
|
|
3cd805f0bf | ||
|
|
c7ddb8aa14 | ||
|
|
d5527982b6 | ||
|
|
ec1c5e9c11 | ||
|
|
06cdcb93f0 |
@@ -28,6 +28,18 @@ runs:
|
||||
echo "Waiting for Azurite (Azure Storage emulator) to be ready"
|
||||
timeout 30 bash -c 'until curl --silent http://localhost:10000/devstoreaccount1; do sleep 1; done'
|
||||
echo "Azurite (Azure Storage emulator) is ready"
|
||||
- name: Start Redis
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$(docker ps -aq -f name=redis)" ]; then
|
||||
echo "Stopping and removing existing Redis"
|
||||
docker rm -f redis
|
||||
fi
|
||||
echo "Starting Redis"
|
||||
docker run -d --name redis -p 6379:6379 redis:latest
|
||||
echo "Waiting for Redis to be ready"
|
||||
timeout 30 bash -c 'until docker exec redis redis-cli ping | grep -q PONG; do sleep 1; done'
|
||||
echo "Redis is ready"
|
||||
- name: Install Azure Functions Core Tools
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Test with pytest
|
||||
timeout-minutes: 10
|
||||
run: uv run poe azure-ai-tests -n logical --dist loadfile --dist worksteal --timeout 300 --retries 3 --retry-delay 10
|
||||
run: uv run --directory packages/azure-ai poe integration-tests -n logical --dist loadfile --dist worksteal --timeout 300 --retries 3 --retry-delay 10
|
||||
working-directory: ./python
|
||||
- name: Test Azure AI samples
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Declarative Agents
|
||||
|
||||
This folder contains sample agent definitions than be ran using the declarative agent support, for python see the [declarative agent python sample folder](../python/samples/getting_started/declarative/).
|
||||
This folder contains sample agent definitions that can be run using the declarative agent support, for python see the [declarative agent python sample folder](../python/samples/getting_started/declarative/).
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Aspire.* -->
|
||||
<PackageVersion Include="Anthropic" Version="11.0.0" />
|
||||
<PackageVersion Include="Anthropic" Version="12.0.0" />
|
||||
<PackageVersion Include="Anthropic.Foundry" Version="0.1.0" />
|
||||
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
|
||||
<PackageVersion Include="Aspire.Hosting.AppHost" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="Aspire.Hosting.Azure.CognitiveServices" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0-beta.440" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
|
||||
<!-- Azure.* -->
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
|
||||
<PackageVersion Include="Azure.AI.Projects.OpenAI" Version="1.0.0-beta.5" />
|
||||
@@ -100,7 +100,7 @@
|
||||
<!-- MCP -->
|
||||
<PackageVersion Include="ModelContextProtocol" Version="0.4.0-preview.3" />
|
||||
<!-- Inference SDKs -->
|
||||
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.4.11" />
|
||||
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.5" />
|
||||
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
|
||||
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
|
||||
<PackageVersion Include="OpenAI" Version="2.8.0" />
|
||||
@@ -112,19 +112,21 @@
|
||||
<PackageVersion Include="Microsoft.Bot.ObjectModel.PowerFx" Version="1.2025.1106.1" />
|
||||
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.5.0-build.20251008-1002" />
|
||||
<!-- Durable Task -->
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.16.2" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.16.2-preview.1" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.16.2" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.16.2-preview.1" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.18.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.18.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.18.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.18.0" />
|
||||
<!-- Azure Functions -->
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="2.50.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.9.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.11.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.1" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Mcp" Version="1.0.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
|
||||
<!-- Redis -->
|
||||
<PackageVersion Include="StackExchange.Redis" Version="2.10.1" />
|
||||
<!-- Test -->
|
||||
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Condition="'$(TargetFramework)' == 'net8.0'" Version="8.0.22" />
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<Project Path="samples/AzureFunctions/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj" />
|
||||
<Project Path="samples/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj" />
|
||||
<Project Path="samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj" />
|
||||
<Project Path="samples/AzureFunctions/08_ReliableStreaming/08_ReliableStreaming.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/">
|
||||
<File Path="samples/GettingStarted/README.md" />
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.0.0</VersionPrefix>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251204.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251204.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.251204.1</GitTag>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251219.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251219.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.251219.1</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- The Functions build tools don't like namespaces that start with a number -->
|
||||
<AssemblyName>ReliableStreaming</AssemblyName>
|
||||
<RootNamespace>ReliableStreaming</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Azure Functions packages -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Redis for reliable streaming -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,320 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ReliableStreaming;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP trigger functions for reliable streaming of durable agent responses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This class exposes two endpoints:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <term>Create</term>
|
||||
/// <description>Starts an agent run and streams responses. The response format depends on the
|
||||
/// <c>Accept</c> header: <c>text/plain</c> returns raw text (ideal for terminals), while
|
||||
/// <c>text/event-stream</c> or any other value returns Server-Sent Events (SSE).</description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term>Stream</term>
|
||||
/// <description>Resumes a stream from a cursor position, enabling reliable message delivery</description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class FunctionTriggers
|
||||
{
|
||||
private readonly RedisStreamResponseHandler _streamHandler;
|
||||
private readonly ILogger<FunctionTriggers> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FunctionTriggers"/> class.
|
||||
/// </summary>
|
||||
/// <param name="streamHandler">The Redis stream handler for reading/writing agent responses.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public FunctionTriggers(RedisStreamResponseHandler streamHandler, ILogger<FunctionTriggers> logger)
|
||||
{
|
||||
this._streamHandler = streamHandler;
|
||||
this._logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new agent session, starts an agent run with the provided prompt,
|
||||
/// and streams the response back to the client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The response format depends on the <c>Accept</c> header:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>text/plain</c>: Returns raw text output, ideal for terminal display with curl</item>
|
||||
/// <item><c>text/event-stream</c> or other: Returns Server-Sent Events (SSE) with cursor support</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The response includes an <c>x-conversation-id</c> header containing the conversation ID.
|
||||
/// For SSE responses, clients can use this conversation ID to resume the stream if disconnected
|
||||
/// by calling the <see cref="StreamAsync"/> endpoint with the conversation ID and the last received cursor.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each SSE event contains the following fields:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>id</c>: The Redis stream entry ID (use as cursor for resumption)</item>
|
||||
/// <item><c>event</c>: Either "message" for content or "done" for stream completion</item>
|
||||
/// <item><c>data</c>: The text content of the response chunk</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="request">The HTTP request containing the prompt in the body.</param>
|
||||
/// <param name="durableClient">The Durable Task client for signaling agents.</param>
|
||||
/// <param name="context">The function invocation context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A streaming response in the format specified by the Accept header.</returns>
|
||||
[Function(nameof(CreateAsync))]
|
||||
public async Task<IActionResult> CreateAsync(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "agent/create")] HttpRequest request,
|
||||
[DurableClient] DurableTaskClient durableClient,
|
||||
FunctionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Read the prompt from the request body
|
||||
string prompt = await new StreamReader(request.Body).ReadToEndAsync(cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(prompt))
|
||||
{
|
||||
return new BadRequestObjectResult("Request body must contain a prompt.");
|
||||
}
|
||||
|
||||
AIAgent agentProxy = durableClient.AsDurableAgentProxy(context, "TravelPlanner");
|
||||
|
||||
// Create a new agent thread
|
||||
AgentThread thread = agentProxy.GetNewThread();
|
||||
AgentThreadMetadata metadata = thread.GetService<AgentThreadMetadata>()
|
||||
?? throw new InvalidOperationException("Failed to get AgentThreadMetadata from new thread.");
|
||||
|
||||
this._logger.LogInformation("Creating new agent session: {ConversationId}", metadata.ConversationId);
|
||||
|
||||
// Run the agent in the background (fire-and-forget)
|
||||
DurableAgentRunOptions options = new() { IsFireAndForget = true };
|
||||
await agentProxy.RunAsync(prompt, thread, options, cancellationToken);
|
||||
|
||||
this._logger.LogInformation("Agent run started for session: {ConversationId}", metadata.ConversationId);
|
||||
|
||||
// Check Accept header to determine response format
|
||||
// text/plain = raw text output (ideal for terminals)
|
||||
// text/event-stream or other = SSE format (supports resumption)
|
||||
string? acceptHeader = request.Headers.Accept.FirstOrDefault();
|
||||
bool useSseFormat = acceptHeader?.Contains("text/plain", StringComparison.OrdinalIgnoreCase) != true;
|
||||
|
||||
return await this.StreamToClientAsync(
|
||||
conversationId: metadata.ConversationId!, cursor: null, useSseFormat, request.HttpContext, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes streaming from a specific cursor position for an existing session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Use this endpoint to resume a stream after disconnection. Pass the conversation ID
|
||||
/// (from the <c>x-conversation-id</c> response header) and the last received cursor
|
||||
/// (Redis stream entry ID) to continue from where you left off.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If no cursor is provided, streaming starts from the beginning of the stream.
|
||||
/// This allows clients to replay the entire response if needed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The response format depends on the <c>Accept</c> header:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>text/plain</c>: Returns raw text output, ideal for terminal display with curl</item>
|
||||
/// <item><c>text/event-stream</c> or other: Returns Server-Sent Events (SSE) with cursor support</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="request">The HTTP request. Use the <c>cursor</c> query parameter to specify the cursor position.</param>
|
||||
/// <param name="conversationId">The conversation ID to stream from.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A streaming response in the format specified by the Accept header.</returns>
|
||||
[Function(nameof(StreamAsync))]
|
||||
public async Task<IActionResult> StreamAsync(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "agent/stream/{conversationId}")] HttpRequest request,
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(conversationId))
|
||||
{
|
||||
return new BadRequestObjectResult("Conversation ID is required.");
|
||||
}
|
||||
|
||||
// Get the cursor from query string (optional)
|
||||
string? cursor = request.Query["cursor"].FirstOrDefault();
|
||||
|
||||
this._logger.LogInformation(
|
||||
"Resuming stream for conversation {ConversationId} from cursor: {Cursor}",
|
||||
conversationId,
|
||||
cursor ?? "(beginning)");
|
||||
|
||||
// Check Accept header to determine response format
|
||||
// text/plain = raw text output (ideal for terminals)
|
||||
// text/event-stream or other = SSE format (supports cursor-based resumption)
|
||||
string? acceptHeader = request.Headers.Accept.FirstOrDefault();
|
||||
bool useSseFormat = acceptHeader?.Contains("text/plain", StringComparison.OrdinalIgnoreCase) != true;
|
||||
|
||||
return await this.StreamToClientAsync(conversationId, cursor, useSseFormat, request.HttpContext, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams chunks from the Redis stream to the HTTP response.
|
||||
/// </summary>
|
||||
/// <param name="conversationId">The conversation ID to stream from.</param>
|
||||
/// <param name="cursor">Optional cursor to resume from. If null, streams from the beginning.</param>
|
||||
/// <param name="useSseFormat">True to use SSE format, false for plain text.</param>
|
||||
/// <param name="httpContext">The HTTP context for writing the response.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>An empty result after streaming completes.</returns>
|
||||
private async Task<IActionResult> StreamToClientAsync(
|
||||
string conversationId,
|
||||
string? cursor,
|
||||
bool useSseFormat,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Set response headers based on format
|
||||
httpContext.Response.Headers.ContentType = useSseFormat
|
||||
? "text/event-stream"
|
||||
: "text/plain; charset=utf-8";
|
||||
httpContext.Response.Headers.CacheControl = "no-cache";
|
||||
httpContext.Response.Headers.Connection = "keep-alive";
|
||||
httpContext.Response.Headers["x-conversation-id"] = conversationId;
|
||||
|
||||
// Disable response buffering if supported
|
||||
httpContext.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering();
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (StreamChunk chunk in this._streamHandler.ReadStreamAsync(
|
||||
conversationId,
|
||||
cursor,
|
||||
cancellationToken))
|
||||
{
|
||||
if (chunk.Error != null)
|
||||
{
|
||||
this._logger.LogWarning("Stream error for conversation {ConversationId}: {Error}", conversationId, chunk.Error);
|
||||
await WriteErrorAsync(httpContext.Response, chunk.Error, useSseFormat, cancellationToken);
|
||||
break;
|
||||
}
|
||||
|
||||
if (chunk.IsDone)
|
||||
{
|
||||
await WriteEndOfStreamAsync(httpContext.Response, chunk.EntryId, useSseFormat, cancellationToken);
|
||||
break;
|
||||
}
|
||||
|
||||
if (chunk.Text != null)
|
||||
{
|
||||
await WriteChunkAsync(httpContext.Response, chunk, useSseFormat, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
this._logger.LogInformation("Client disconnected from stream {ConversationId}", conversationId);
|
||||
}
|
||||
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a text chunk to the response.
|
||||
/// </summary>
|
||||
private static async Task WriteChunkAsync(
|
||||
HttpResponse response,
|
||||
StreamChunk chunk,
|
||||
bool useSseFormat,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (useSseFormat)
|
||||
{
|
||||
await WriteSSEEventAsync(response, "message", chunk.Text!, chunk.EntryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
await response.WriteAsync(chunk.Text!, cancellationToken);
|
||||
}
|
||||
|
||||
await response.Body.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes an end-of-stream marker to the response.
|
||||
/// </summary>
|
||||
private static async Task WriteEndOfStreamAsync(
|
||||
HttpResponse response,
|
||||
string entryId,
|
||||
bool useSseFormat,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (useSseFormat)
|
||||
{
|
||||
await WriteSSEEventAsync(response, "done", "[DONE]", entryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
await response.WriteAsync("\n", cancellationToken);
|
||||
}
|
||||
|
||||
await response.Body.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes an error message to the response.
|
||||
/// </summary>
|
||||
private static async Task WriteErrorAsync(
|
||||
HttpResponse response,
|
||||
string error,
|
||||
bool useSseFormat,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (useSseFormat)
|
||||
{
|
||||
await WriteSSEEventAsync(response, "error", error, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
await response.WriteAsync($"\n[Error: {error}]\n", cancellationToken);
|
||||
}
|
||||
|
||||
await response.Body.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a Server-Sent Event to the response stream.
|
||||
/// </summary>
|
||||
private static async Task WriteSSEEventAsync(
|
||||
HttpResponse response,
|
||||
string eventType,
|
||||
string data,
|
||||
string? id)
|
||||
{
|
||||
StringBuilder sb = new();
|
||||
|
||||
// Include the ID if provided (used as cursor for resumption)
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
sb.AppendLine($"id: {id}");
|
||||
}
|
||||
|
||||
sb.AppendLine($"event: {eventType}");
|
||||
sb.AppendLine($"data: {data}");
|
||||
sb.AppendLine(); // Empty line marks end of event
|
||||
|
||||
await response.WriteAsync(sb.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to implement reliable streaming for durable agents using Redis Streams.
|
||||
// It exposes two HTTP endpoints:
|
||||
// 1. Create - Starts an agent run and streams responses back via Server-Sent Events (SSE)
|
||||
// 2. Stream - Resumes a stream from a specific cursor position, enabling reliable message delivery
|
||||
//
|
||||
// This pattern is inspired by OpenAI's background mode for the Responses API, which allows clients
|
||||
// to disconnect and reconnect to ongoing agent responses without losing messages.
|
||||
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using OpenAI.Chat;
|
||||
using ReliableStreaming;
|
||||
using StackExchange.Redis;
|
||||
|
||||
// Get the Azure OpenAI endpoint and deployment name from environment variables.
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
|
||||
|
||||
// Get Redis connection string from environment variable.
|
||||
string redisConnectionString = Environment.GetEnvironmentVariable("REDIS_CONNECTION_STRING")
|
||||
?? "localhost:6379";
|
||||
|
||||
// Get the Redis stream TTL from environment variable (default: 10 minutes).
|
||||
int redisStreamTtlMinutes = int.TryParse(
|
||||
Environment.GetEnvironmentVariable("REDIS_STREAM_TTL_MINUTES"),
|
||||
out int ttlMinutes) ? ttlMinutes : 10;
|
||||
|
||||
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
|
||||
string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
|
||||
AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey)
|
||||
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
|
||||
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
|
||||
|
||||
// Travel Planner agent instructions - designed to produce longer responses for demonstrating streaming.
|
||||
const string TravelPlannerName = "TravelPlanner";
|
||||
const string TravelPlannerInstructions =
|
||||
"""
|
||||
You are an expert travel planner who creates detailed, personalized travel itineraries.
|
||||
When asked to plan a trip, you should:
|
||||
1. Create a comprehensive day-by-day itinerary
|
||||
2. Include specific recommendations for activities, restaurants, and attractions
|
||||
3. Provide practical tips for each destination
|
||||
4. Consider weather and local events when making recommendations
|
||||
5. Include estimated times and logistics between activities
|
||||
|
||||
Always use the available tools to get current weather forecasts and local events
|
||||
for the destination to make your recommendations more relevant and timely.
|
||||
|
||||
Format your response with clear headings for each day and include emoji icons
|
||||
to make the itinerary easy to scan and visually appealing.
|
||||
""";
|
||||
|
||||
// Configure the function app to host the AI agent.
|
||||
FunctionsApplicationBuilder builder = FunctionsApplication
|
||||
.CreateBuilder(args)
|
||||
.ConfigureFunctionsWebApplication()
|
||||
.ConfigureDurableAgents(options =>
|
||||
{
|
||||
// Define the Travel Planner agent with tools for weather and events
|
||||
options.AddAIAgentFactory(TravelPlannerName, sp =>
|
||||
{
|
||||
return client.GetChatClient(deploymentName).CreateAIAgent(
|
||||
instructions: TravelPlannerInstructions,
|
||||
name: TravelPlannerName,
|
||||
services: sp,
|
||||
tools: [
|
||||
AIFunctionFactory.Create(TravelTools.GetWeatherForecast),
|
||||
AIFunctionFactory.Create(TravelTools.GetLocalEvents),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Register Redis connection as a singleton
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
|
||||
ConnectionMultiplexer.Connect(redisConnectionString));
|
||||
|
||||
// Register the Redis stream response handler - this captures agent responses
|
||||
// and publishes them to Redis Streams for reliable delivery.
|
||||
// Registered as both the concrete type (for FunctionTriggers) and the interface (for the agent framework).
|
||||
builder.Services.AddSingleton(sp =>
|
||||
new RedisStreamResponseHandler(
|
||||
sp.GetRequiredService<IConnectionMultiplexer>(),
|
||||
TimeSpan.FromMinutes(redisStreamTtlMinutes)));
|
||||
builder.Services.AddSingleton<IAgentResponseHandler>(sp =>
|
||||
sp.GetRequiredService<RedisStreamResponseHandler>());
|
||||
|
||||
using IHost app = builder.Build();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,264 @@
|
||||
# Reliable Streaming with Redis
|
||||
|
||||
This sample demonstrates how to implement reliable streaming for durable agents using Redis Streams as a message broker. It enables clients to disconnect and reconnect to ongoing agent responses without losing messages, inspired by [OpenAI's background mode](https://platform.openai.com/docs/guides/background) for the Responses API.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- **Reliable message delivery**: Agent responses are persisted to Redis Streams, allowing clients to resume from any point
|
||||
- **Content negotiation**: Use `Accept: text/plain` for raw terminal output, or `Accept: text/event-stream` for SSE format
|
||||
- **Server-Sent Events (SSE)**: Standard streaming format that works with `curl`, browsers, and most HTTP clients
|
||||
- **Cursor-based resumption**: Each SSE event includes an `id` field that can be used to resume the stream
|
||||
- **Fire-and-forget agent invocation**: The agent runs in the background while the client streams from Redis via an HTTP trigger function
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
|
||||
|
||||
### Additional Requirements: Redis
|
||||
|
||||
This sample requires a Redis instance. Start a local Redis instance using Docker:
|
||||
|
||||
```bash
|
||||
docker run -d --name redis -p 6379:6379 redis:latest
|
||||
```
|
||||
|
||||
To verify Redis is running:
|
||||
|
||||
```bash
|
||||
docker ps | grep redis
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
Start the Azure Functions host:
|
||||
|
||||
```bash
|
||||
func start
|
||||
```
|
||||
|
||||
### 1. Test Streaming with curl
|
||||
|
||||
Open a new terminal and start a travel planning request. Use the `-i` flag to see response headers (including the conversation ID) and `Accept: text/plain` for raw text output:
|
||||
|
||||
**Bash (Linux/macOS/WSL):**
|
||||
|
||||
```bash
|
||||
curl -i -N -X POST http://localhost:7071/api/agent/create \
|
||||
-H "Content-Type: text/plain" \
|
||||
-H "Accept: text/plain" \
|
||||
-d "Plan a 7-day trip to Tokyo, Japan for next month. Include daily activities, restaurant recommendations, and tips for getting around."
|
||||
```
|
||||
|
||||
**PowerShell:**
|
||||
|
||||
```powershell
|
||||
curl -i -N -X POST http://localhost:7071/api/agent/create `
|
||||
-H "Content-Type: text/plain" `
|
||||
-H "Accept: text/plain" `
|
||||
-d "Plan a 7-day trip to Tokyo, Japan for next month. Include daily activities, restaurant recommendations, and tips for getting around."
|
||||
```
|
||||
|
||||
You'll first see the response headers, including:
|
||||
|
||||
```text
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
x-conversation-id: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890
|
||||
...
|
||||
```
|
||||
|
||||
Then the agent's response will stream to your terminal in chunks, similar to a ChatGPT-style experience (though not character-by-character).
|
||||
|
||||
> **Note:** The `-N` flag in curl disables output buffering, which is essential for seeing the stream in real-time. The `-i` flag includes the HTTP headers in the output.
|
||||
|
||||
### 2. Demonstrate Stream Interruption and Resumption
|
||||
|
||||
This is the key feature of reliable streaming! Follow these steps to see it in action:
|
||||
|
||||
#### Step 1: Start a stream and note the conversation ID
|
||||
|
||||
Run the curl command from step 1. Watch for the `x-conversation-id` header in the response - **copy this value**, you'll need it to resume.
|
||||
|
||||
```text
|
||||
x-conversation-id: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890
|
||||
```
|
||||
|
||||
#### Step 2: Interrupt the stream
|
||||
|
||||
While the agent is still generating text, press **`Ctrl+C`** to interrupt the stream. The agent continues running in the background - your messages are being saved to Redis!
|
||||
|
||||
#### Step 3: Resume the stream
|
||||
|
||||
Use the conversation ID you copied to resume streaming from where you left off. Include the `Accept: text/plain` header to get raw text output:
|
||||
|
||||
**Bash (Linux/macOS/WSL):**
|
||||
|
||||
```bash
|
||||
# Replace with your actual conversation ID from the x-conversation-id header
|
||||
CONVERSATION_ID="@dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890"
|
||||
|
||||
curl -N -H "Accept: text/plain" "http://localhost:7071/api/agent/stream/${CONVERSATION_ID}"
|
||||
```
|
||||
|
||||
**PowerShell:**
|
||||
|
||||
```powershell
|
||||
# Replace with your actual conversation ID from the x-conversation-id header
|
||||
$conversationId = "@dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890"
|
||||
|
||||
curl -N -H "Accept: text/plain" "http://localhost:7071/api/agent/stream/$conversationId"
|
||||
```
|
||||
|
||||
You'll see the **entire response replayed from the beginning**, including the parts you already received before interrupting.
|
||||
|
||||
#### Step 4 (Advanced): Resume from a specific cursor
|
||||
|
||||
If you're using SSE format, each event includes an `id` field that you can use as a cursor to resume from a specific point:
|
||||
|
||||
```bash
|
||||
# Resume from a specific cursor position
|
||||
curl -N "http://localhost:7071/api/agent/stream/${CONVERSATION_ID}?cursor=1734567890123-0"
|
||||
```
|
||||
|
||||
### 3. Alternative: SSE Format for Programmatic Clients
|
||||
|
||||
If you need the full Server-Sent Events format with cursors for resumable streaming, use `Accept: text/event-stream` (or omit the Accept header):
|
||||
|
||||
```bash
|
||||
curl -i -N -X POST http://localhost:7071/api/agent/create \
|
||||
-H "Content-Type: text/plain" \
|
||||
-H "Accept: text/event-stream" \
|
||||
-d "Plan a 7-day trip to Tokyo, Japan."
|
||||
```
|
||||
|
||||
This returns SSE-formatted events with `id`, `event`, and `data` fields:
|
||||
|
||||
```text
|
||||
id: 1734567890123-0
|
||||
event: message
|
||||
data: # 7-Day Tokyo Adventure
|
||||
|
||||
id: 1734567890124-0
|
||||
event: message
|
||||
data: ## Day 1: Arrival and Exploration
|
||||
|
||||
id: 1734567890999-0
|
||||
event: done
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
The `id` field is the Redis stream entry ID - use it as the `cursor` parameter to resume from that exact point.
|
||||
|
||||
### Understanding the Response Headers
|
||||
|
||||
| Header | Description |
|
||||
|--------|-------------|
|
||||
| `x-conversation-id` | The conversation ID (session key). Use this to resume the stream. |
|
||||
| `Content-Type` | Either `text/plain` or `text/event-stream` depending on your `Accept` header. |
|
||||
| `Cache-Control` | Set to `no-cache` to prevent caching of the stream. |
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```text
|
||||
┌─────────────┐ POST /agent/create ┌─────────────────────┐
|
||||
│ Client │ (Accept: text/plain or SSE)│ Azure Functions │
|
||||
│ (curl) │ ──────────────────────────► │ (FunctionTriggers) │
|
||||
└─────────────┘ └──────────┬──────────┘
|
||||
▲ │
|
||||
│ Text or SSE stream Signal Entity
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌─────────────────────┐
|
||||
│ │ AgentEntity │
|
||||
│ │ (Durable Entity) │
|
||||
│ └──────────┬──────────┘
|
||||
│ │
|
||||
│ IAgentResponseHandler
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌─────────────────────┐
|
||||
│ │ RedisStreamResponse │
|
||||
│ │ Handler │
|
||||
│ └──────────┬──────────┘
|
||||
│ │
|
||||
│ XADD (write)
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌─────────────────────┐
|
||||
└─────────── XREAD (poll) ────────── │ Redis Streams │
|
||||
│ (Durable Log) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Client sends prompt**: The `Create` endpoint receives the prompt and generates a new agent thread.
|
||||
|
||||
2. **Agent invoked**: The durable entity (`AgentEntity`) is signaled to run the travel planner agent. This is fire-and-forget from the HTTP request's perspective.
|
||||
|
||||
3. **Responses captured**: As the agent generates responses, `RedisStreamResponseHandler` (implementing `IAgentResponseHandler`) extracts the text from each `AgentRunResponseUpdate` and publishes it to a Redis Stream keyed by session ID.
|
||||
|
||||
4. **Client polls Redis**: The HTTP response streams events by polling the Redis Stream. For SSE format, each event includes the Redis entry ID as the `id` field.
|
||||
|
||||
5. **Resumption**: If the client disconnects, it can call the `Stream` endpoint with the conversation ID (from the `x-conversation-id` header) and optionally the last received cursor to resume from that point.
|
||||
|
||||
## Message Delivery Guarantees
|
||||
|
||||
This sample provides **at-least-once delivery** with the following characteristics:
|
||||
|
||||
- **Durability**: Messages are persisted to Redis Streams with configurable TTL (default: 10 minutes).
|
||||
- **Ordering**: Messages are delivered in order within a session.
|
||||
- **Resumption**: Clients can resume from any point using cursor-based pagination.
|
||||
- **Replay**: Clients can replay the entire stream by omitting the cursor.
|
||||
|
||||
### Important Considerations
|
||||
|
||||
- **No exactly-once delivery**: If a client disconnects exactly when receiving a message, it may receive that message again upon resumption. Clients should handle duplicate messages idempotently.
|
||||
- **TTL expiration**: Streams expire after the configured TTL. Clients cannot resume streams that have expired.
|
||||
- **Redis guarantees**: Redis streams are backed by Redis persistence mechanisms (RDB/AOF). Ensure your Redis instance is configured for durability as needed.
|
||||
|
||||
## When to Use These Patterns
|
||||
|
||||
The patterns demonstrated in this sample are ideal for:
|
||||
|
||||
- **Long-running agent tasks**: When agent responses take minutes to complete (e.g., deep research, complex planning)
|
||||
- **Unreliable network connections**: Mobile apps, unstable WiFi, or connections that may drop
|
||||
- **Resumable experiences**: Users should be able to close and reopen an app without losing context
|
||||
- **Background processing**: When you want to fire off a task and check on it later
|
||||
|
||||
These patterns may be overkill for:
|
||||
|
||||
- **Simple, fast responses**: If responses complete in a few seconds, standard streaming is simpler
|
||||
- **Stateless interactions**: If there's no need to resume or replay conversations
|
||||
- **Very high throughput**: Redis adds latency; for maximum throughput, direct streaming may be better
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
|---------------------|-------------|---------|
|
||||
| `REDIS_CONNECTION_STRING` | Redis connection string | `localhost:6379` |
|
||||
| `REDIS_STREAM_TTL_MINUTES` | How long streams are retained after last write | `10` |
|
||||
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint URL | (required) |
|
||||
| `AZURE_OPENAI_DEPLOYMENT` | Azure OpenAI deployment name | (required) |
|
||||
| `AZURE_OPENAI_KEY` | API key (optional, uses Azure CLI auth if not set) | (optional) |
|
||||
|
||||
## Cleanup
|
||||
|
||||
To stop and remove the Redis Docker containers:
|
||||
|
||||
```bash
|
||||
docker stop redis
|
||||
docker rm redis
|
||||
```
|
||||
|
||||
## Disclaimer
|
||||
|
||||
> ⚠️ **This sample is for illustration purposes only and is not intended to be production-ready.**
|
||||
>
|
||||
> A production implementation should consider:
|
||||
>
|
||||
> - Redis cluster configuration for high availability
|
||||
> - Authentication and authorization for the streaming endpoints
|
||||
> - Rate limiting and abuse prevention
|
||||
> - Monitoring and alerting for stream health
|
||||
> - Graceful handling of Redis failures
|
||||
@@ -0,0 +1,213 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace ReliableStreaming;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a chunk of data read from a Redis stream.
|
||||
/// </summary>
|
||||
/// <param name="EntryId">The Redis stream entry ID (can be used as a cursor for resumption).</param>
|
||||
/// <param name="Text">The text content of the chunk, or null if this is a completion/error marker.</param>
|
||||
/// <param name="IsDone">True if this chunk marks the end of the stream.</param>
|
||||
/// <param name="Error">An error message if something went wrong, or null otherwise.</param>
|
||||
public readonly record struct StreamChunk(string EntryId, string? Text, bool IsDone, string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// An implementation of <see cref="IAgentResponseHandler"/> that publishes agent response updates
|
||||
/// to Redis Streams for reliable delivery. This enables clients to disconnect and reconnect
|
||||
/// to ongoing agent responses without losing messages.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Redis Streams provide a durable, append-only log that supports consumer groups and message
|
||||
/// acknowledgment. This implementation uses auto-generated IDs (which are timestamp-based)
|
||||
/// as sequence numbers, allowing clients to resume from any point in the stream.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each agent session gets its own Redis Stream, keyed by session ID. The stream entries
|
||||
/// contain text chunks extracted from <see cref="AgentRunResponseUpdate"/> objects.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RedisStreamResponseHandler : IAgentResponseHandler
|
||||
{
|
||||
private const int MaxEmptyReads = 300; // 5 minutes at 1 second intervals
|
||||
private const int PollIntervalMs = 1000;
|
||||
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly TimeSpan _streamTtl;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedisStreamResponseHandler" /> class.
|
||||
/// </summary>
|
||||
/// <param name="redis">The Redis connection multiplexer.</param>
|
||||
/// <param name="streamTtl">The time-to-live for stream entries. Streams will expire after this duration of inactivity.</param>
|
||||
public RedisStreamResponseHandler(IConnectionMultiplexer redis, TimeSpan streamTtl)
|
||||
{
|
||||
this._redis = redis;
|
||||
this._streamTtl = streamTtl;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask OnStreamingResponseUpdateAsync(
|
||||
IAsyncEnumerable<AgentRunResponseUpdate> messageStream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Get the current session ID from the DurableAgentContext
|
||||
// This is set by the AgentEntity before invoking the response handler
|
||||
DurableAgentContext? context = DurableAgentContext.Current;
|
||||
if (context is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"DurableAgentContext.Current is not set. This handler must be used within a durable agent context.");
|
||||
}
|
||||
|
||||
// Get conversation ID from the current thread context, which is only available in the context of
|
||||
// a durable agent execution.
|
||||
string conversationId = context.CurrentThread.GetService<AgentThreadMetadata>()?.ConversationId
|
||||
?? throw new InvalidOperationException("Unable to determine conversation ID from the current thread.");
|
||||
string streamKey = GetStreamKey(conversationId);
|
||||
|
||||
IDatabase db = this._redis.GetDatabase();
|
||||
int sequenceNumber = 0;
|
||||
|
||||
await foreach (AgentRunResponseUpdate update in messageStream.WithCancellation(cancellationToken))
|
||||
{
|
||||
// Extract just the text content - this avoids serialization round-trip issues
|
||||
string text = update.Text;
|
||||
|
||||
// Only publish non-empty text chunks
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
// Create the stream entry with the text and metadata
|
||||
NameValueEntry[] entries =
|
||||
[
|
||||
new NameValueEntry("text", text),
|
||||
new NameValueEntry("sequence", sequenceNumber++),
|
||||
new NameValueEntry("timestamp", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()),
|
||||
];
|
||||
|
||||
// Add to the Redis Stream with auto-generated ID (timestamp-based)
|
||||
await db.StreamAddAsync(streamKey, entries);
|
||||
|
||||
// Refresh the TTL on each write to keep the stream alive during active streaming
|
||||
await db.KeyExpireAsync(streamKey, this._streamTtl);
|
||||
}
|
||||
}
|
||||
|
||||
// Add a sentinel entry to mark the end of the stream
|
||||
NameValueEntry[] endEntries =
|
||||
[
|
||||
new NameValueEntry("text", ""),
|
||||
new NameValueEntry("sequence", sequenceNumber),
|
||||
new NameValueEntry("timestamp", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()),
|
||||
new NameValueEntry("done", "true"),
|
||||
];
|
||||
await db.StreamAddAsync(streamKey, endEntries);
|
||||
|
||||
// Set final TTL - the stream will be cleaned up after this duration
|
||||
await db.KeyExpireAsync(streamKey, this._streamTtl);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask OnAgentResponseAsync(AgentRunResponse message, CancellationToken cancellationToken)
|
||||
{
|
||||
// This handler is optimized for streaming responses.
|
||||
// For non-streaming responses, we don't need to store in Redis since
|
||||
// the response is returned directly to the caller.
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads chunks from a Redis stream for the given session, yielding them as they become available.
|
||||
/// </summary>
|
||||
/// <param name="conversationId">The conversation ID to read from.</param>
|
||||
/// <param name="cursor">Optional cursor to resume from. If null, reads from the beginning.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>An async enumerable of stream chunks.</returns>
|
||||
public async IAsyncEnumerable<StreamChunk> ReadStreamAsync(
|
||||
string conversationId,
|
||||
string? cursor,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
string streamKey = GetStreamKey(conversationId);
|
||||
|
||||
IDatabase db = this._redis.GetDatabase();
|
||||
string startId = string.IsNullOrEmpty(cursor) ? "0-0" : cursor;
|
||||
|
||||
int emptyReadCount = 0;
|
||||
bool hasSeenData = false;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
StreamEntry[]? entries = null;
|
||||
string? errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
entries = await db.StreamReadAsync(streamKey, startId, count: 100);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
|
||||
if (errorMessage != null)
|
||||
{
|
||||
yield return new StreamChunk(startId, null, false, errorMessage);
|
||||
yield break;
|
||||
}
|
||||
|
||||
// entries is guaranteed to be non-null if errorMessage is null
|
||||
if (entries!.Length == 0)
|
||||
{
|
||||
if (!hasSeenData)
|
||||
{
|
||||
emptyReadCount++;
|
||||
if (emptyReadCount >= MaxEmptyReads)
|
||||
{
|
||||
yield return new StreamChunk(
|
||||
startId,
|
||||
null,
|
||||
false,
|
||||
$"Stream not found or timed out after {MaxEmptyReads * PollIntervalMs / 1000} seconds");
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(PollIntervalMs, cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
hasSeenData = true;
|
||||
|
||||
foreach (StreamEntry entry in entries)
|
||||
{
|
||||
startId = entry.Id.ToString();
|
||||
string? text = entry["text"];
|
||||
string? done = entry["done"];
|
||||
|
||||
if (done == "true")
|
||||
{
|
||||
yield return new StreamChunk(startId, null, true, null);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
yield return new StreamChunk(startId, text, false, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Redis Stream key for a given conversation ID.
|
||||
/// </summary>
|
||||
/// <param name="conversationId">The conversation ID.</param>
|
||||
/// <returns>The Redis Stream key.</returns>
|
||||
internal static string GetStreamKey(string conversationId) => $"agent-stream:{conversationId}";
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace ReliableStreaming;
|
||||
|
||||
/// <summary>
|
||||
/// Mock travel tools that return hardcoded data for demonstration purposes.
|
||||
/// In a real application, these would call actual weather and events APIs.
|
||||
/// </summary>
|
||||
internal static class TravelTools
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a weather forecast for a destination on a specific date.
|
||||
/// Returns mock weather data for demonstration purposes.
|
||||
/// </summary>
|
||||
/// <param name="destination">The destination city or location.</param>
|
||||
/// <param name="date">The date for the forecast (e.g., "2025-01-15" or "next Monday").</param>
|
||||
/// <returns>A weather forecast summary.</returns>
|
||||
[Description("Gets the weather forecast for a destination on a specific date. Use this to provide weather-aware recommendations in the itinerary.")]
|
||||
public static string GetWeatherForecast(string destination, string date)
|
||||
{
|
||||
// Mock weather data based on destination for realistic responses
|
||||
Dictionary<string, (string condition, int highF, int lowF)> weatherByRegion = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Tokyo"] = ("Partly cloudy with a chance of light rain", 58, 45),
|
||||
["Paris"] = ("Overcast with occasional drizzle", 52, 41),
|
||||
["New York"] = ("Clear and cold", 42, 28),
|
||||
["London"] = ("Foggy morning, clearing in afternoon", 48, 38),
|
||||
["Sydney"] = ("Sunny and warm", 82, 68),
|
||||
["Rome"] = ("Sunny with light breeze", 62, 48),
|
||||
["Barcelona"] = ("Partly sunny", 59, 47),
|
||||
["Amsterdam"] = ("Cloudy with light rain", 46, 38),
|
||||
["Dubai"] = ("Sunny and hot", 85, 72),
|
||||
["Singapore"] = ("Tropical thunderstorms in afternoon", 88, 77),
|
||||
["Bangkok"] = ("Hot and humid, afternoon showers", 91, 78),
|
||||
["Los Angeles"] = ("Sunny and pleasant", 72, 55),
|
||||
["San Francisco"] = ("Morning fog, afternoon sun", 62, 52),
|
||||
["Seattle"] = ("Rainy with breaks", 48, 40),
|
||||
["Miami"] = ("Warm and sunny", 78, 65),
|
||||
["Honolulu"] = ("Tropical paradise weather", 82, 72),
|
||||
};
|
||||
|
||||
// Find a matching destination or use a default
|
||||
(string condition, int highF, int lowF) forecast = ("Partly cloudy", 65, 50);
|
||||
foreach (KeyValuePair<string, (string, int, int)> entry in weatherByRegion)
|
||||
{
|
||||
if (destination.Contains(entry.Key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
forecast = entry.Value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $"""
|
||||
Weather forecast for {destination} on {date}:
|
||||
Conditions: {forecast.condition}
|
||||
High: {forecast.highF}°F ({(forecast.highF - 32) * 5 / 9}°C)
|
||||
Low: {forecast.lowF}°F ({(forecast.lowF - 32) * 5 / 9}°C)
|
||||
|
||||
Recommendation: {GetWeatherRecommendation(forecast.condition)}
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets local events happening at a destination around a specific date.
|
||||
/// Returns mock event data for demonstration purposes.
|
||||
/// </summary>
|
||||
/// <param name="destination">The destination city or location.</param>
|
||||
/// <param name="date">The date to search for events (e.g., "2025-01-15" or "next week").</param>
|
||||
/// <returns>A list of local events and activities.</returns>
|
||||
[Description("Gets local events and activities happening at a destination around a specific date. Use this to suggest timely activities and experiences.")]
|
||||
public static string GetLocalEvents(string destination, string date)
|
||||
{
|
||||
// Mock events data based on destination
|
||||
Dictionary<string, string[]> eventsByCity = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Tokyo"] = [
|
||||
"🎭 Kabuki Theater Performance at Kabukiza Theatre - Traditional Japanese drama",
|
||||
"🌸 Winter Illuminations at Yoyogi Park - Spectacular light displays",
|
||||
"🍜 Ramen Festival at Tokyo Station - Sample ramen from across Japan",
|
||||
"🎮 Gaming Expo at Tokyo Big Sight - Latest video games and technology",
|
||||
],
|
||||
["Paris"] = [
|
||||
"🎨 Impressionist Exhibition at Musée d'Orsay - Extended evening hours",
|
||||
"🍷 Wine Tasting Tour in Le Marais - Local sommelier guided",
|
||||
"🎵 Jazz Night at Le Caveau de la Huchette - Historic jazz club",
|
||||
"🥐 French Pastry Workshop - Learn from master pâtissiers",
|
||||
],
|
||||
["New York"] = [
|
||||
"🎭 Broadway Show: Hamilton - Limited engagement performances",
|
||||
"🏀 Knicks vs Lakers at Madison Square Garden",
|
||||
"🎨 Modern Art Exhibit at MoMA - New installations",
|
||||
"🍕 Pizza Walking Tour of Brooklyn - Artisan pizzerias",
|
||||
],
|
||||
["London"] = [
|
||||
"👑 Royal Collection Exhibition at Buckingham Palace",
|
||||
"🎭 West End Musical: The Phantom of the Opera",
|
||||
"🍺 Craft Beer Festival at Brick Lane",
|
||||
"🎪 Winter Wonderland at Hyde Park - Rides and markets",
|
||||
],
|
||||
["Sydney"] = [
|
||||
"🏄 Pro Surfing Competition at Bondi Beach",
|
||||
"🎵 Opera at Sydney Opera House - La Bohème",
|
||||
"🦘 Wildlife Night Safari at Taronga Zoo",
|
||||
"🍽️ Harbor Dinner Cruise with fireworks",
|
||||
],
|
||||
["Rome"] = [
|
||||
"🏛️ After-Hours Vatican Tour - Skip the crowds",
|
||||
"🍝 Pasta Making Class in Trastevere",
|
||||
"🎵 Classical Concert at Borghese Gallery",
|
||||
"🍷 Wine Tasting in Roman Cellars",
|
||||
],
|
||||
};
|
||||
|
||||
// Find events for the destination or use generic events
|
||||
string[] events = [
|
||||
"🎭 Local theater performance",
|
||||
"🍽️ Food and wine festival",
|
||||
"🎨 Art gallery opening",
|
||||
"🎵 Live music at local venues",
|
||||
];
|
||||
|
||||
foreach (KeyValuePair<string, string[]> entry in eventsByCity)
|
||||
{
|
||||
if (destination.Contains(entry.Key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
events = entry.Value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
string eventList = string.Join("\n• ", events);
|
||||
return $"""
|
||||
Local events in {destination} around {date}:
|
||||
|
||||
• {eventList}
|
||||
|
||||
💡 Tip: Book popular events in advance as they may sell out quickly!
|
||||
""";
|
||||
}
|
||||
|
||||
private static string GetWeatherRecommendation(string condition)
|
||||
{
|
||||
// Use case-insensitive comparison instead of ToLowerInvariant() to satisfy CA1308
|
||||
return condition switch
|
||||
{
|
||||
string c when c.Contains("rain", StringComparison.OrdinalIgnoreCase) || c.Contains("drizzle", StringComparison.OrdinalIgnoreCase) =>
|
||||
"Bring an umbrella and waterproof jacket. Consider indoor activities for backup.",
|
||||
string c when c.Contains("fog", StringComparison.OrdinalIgnoreCase) =>
|
||||
"Morning visibility may be limited. Plan outdoor sightseeing for afternoon.",
|
||||
string c when c.Contains("cold", StringComparison.OrdinalIgnoreCase) =>
|
||||
"Layer up with warm clothing. Hot drinks and cozy cafés recommended.",
|
||||
string c when c.Contains("hot", StringComparison.OrdinalIgnoreCase) || c.Contains("warm", StringComparison.OrdinalIgnoreCase) =>
|
||||
"Stay hydrated and use sunscreen. Plan strenuous activities for cooler morning hours.",
|
||||
string c when c.Contains("thunder", StringComparison.OrdinalIgnoreCase) || c.Contains("storm", StringComparison.OrdinalIgnoreCase) =>
|
||||
"Keep an eye on weather updates. Have indoor alternatives ready.",
|
||||
_ => "Pleasant conditions expected. Great day for outdoor exploration!"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"logging": {
|
||||
"logLevel": {
|
||||
"Microsoft.Agents.AI.DurableTask": "Information",
|
||||
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
|
||||
"DurableTask": "Information",
|
||||
"Microsoft.DurableTask": "Information",
|
||||
"ReliableStreaming": "Information"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "default",
|
||||
"storageProvider": {
|
||||
"type": "AzureManaged",
|
||||
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
||||
"AZURE_OPENAI_DEPLOYMENT": "<AZURE_OPENAI_DEPLOYMENT>",
|
||||
"REDIS_CONNECTION_STRING": "localhost:6379",
|
||||
"REDIS_STREAM_TTL_MINUTES": "10"
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ This directory contains samples for Azure Functions.
|
||||
- **[05_AgentOrchestration_HITL](05_AgentOrchestration_HITL)**: A sample that demonstrates how to implement a human-in-the-loop workflow using durable orchestration, including external event handling for human approval.
|
||||
- **[06_LongRunningTools](06_LongRunningTools)**: A sample that demonstrates how agents can start and interact with durable orchestrations from tool calls to enable long-running tool scenarios.
|
||||
- **[07_AgentAsMcpTool](07_AgentAsMcpTool)**: A sample that demonstrates how to configure durable AI agents to be accessible as Model Context Protocol (MCP) tools.
|
||||
- **[08_ReliableStreaming](08_ReliableStreaming)**: A sample that demonstrates how to implement reliable streaming for durable agents using Redis Streams, enabling clients to disconnect and reconnect without losing messages.
|
||||
|
||||
## Running the Samples
|
||||
|
||||
|
||||
+4
-4
@@ -45,7 +45,7 @@ namespace SampleApp
|
||||
}
|
||||
|
||||
// Clone the input messages and turn them into response messages with upper case text.
|
||||
List<ChatMessage> responseMessages = CloneAndToUpperCase(messages, this.DisplayName).ToList();
|
||||
List<ChatMessage> responseMessages = CloneAndToUpperCase(messages, this.Name).ToList();
|
||||
|
||||
// Notify the thread of the input and output messages.
|
||||
await typedThread.MessageStore.AddMessagesAsync(messages.Concat(responseMessages), cancellationToken);
|
||||
@@ -69,7 +69,7 @@ namespace SampleApp
|
||||
}
|
||||
|
||||
// Clone the input messages and turn them into response messages with upper case text.
|
||||
List<ChatMessage> responseMessages = CloneAndToUpperCase(messages, this.DisplayName).ToList();
|
||||
List<ChatMessage> responseMessages = CloneAndToUpperCase(messages, this.Name).ToList();
|
||||
|
||||
// Notify the thread of the input and output messages.
|
||||
await typedThread.MessageStore.AddMessagesAsync(messages.Concat(responseMessages), cancellationToken);
|
||||
@@ -79,7 +79,7 @@ namespace SampleApp
|
||||
yield return new AgentRunResponseUpdate
|
||||
{
|
||||
AgentId = this.Id,
|
||||
AuthorName = this.DisplayName,
|
||||
AuthorName = message.AuthorName,
|
||||
Role = ChatRole.Assistant,
|
||||
Contents = message.Contents,
|
||||
ResponseId = Guid.NewGuid().ToString("N"),
|
||||
@@ -88,7 +88,7 @@ namespace SampleApp
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ChatMessage> CloneAndToUpperCase(IEnumerable<ChatMessage> messages, string agentName) => messages.Select(x =>
|
||||
private static IEnumerable<ChatMessage> CloneAndToUpperCase(IEnumerable<ChatMessage> messages, string? agentName) => messages.Select(x =>
|
||||
{
|
||||
// Clone the message and update its author to be the agent.
|
||||
var messageClone = x.Clone();
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.4" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251125.1" />
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.4" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-preview.251125.1" />
|
||||
|
||||
@@ -30,7 +30,6 @@ internal sealed class A2AAgent : AIAgent
|
||||
private readonly string? _id;
|
||||
private readonly string? _name;
|
||||
private readonly string? _description;
|
||||
private readonly string? _displayName;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -40,9 +39,8 @@ internal sealed class A2AAgent : AIAgent
|
||||
/// <param name="id">The unique identifier for the agent.</param>
|
||||
/// <param name="name">The the name of the agent.</param>
|
||||
/// <param name="description">The description of the agent.</param>
|
||||
/// <param name="displayName">The display name of the agent.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
|
||||
public A2AAgent(A2AClient a2aClient, string? id = null, string? name = null, string? description = null, string? displayName = null, ILoggerFactory? loggerFactory = null)
|
||||
public A2AAgent(A2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_ = Throw.IfNull(a2aClient);
|
||||
|
||||
@@ -50,7 +48,6 @@ internal sealed class A2AAgent : AIAgent
|
||||
this._id = id;
|
||||
this._name = name;
|
||||
this._description = description;
|
||||
this._displayName = displayName;
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<A2AAgent>();
|
||||
}
|
||||
|
||||
@@ -203,9 +200,6 @@ internal sealed class A2AAgent : AIAgent
|
||||
/// <inheritdoc/>
|
||||
public override string? Name => this._name ?? base.Name;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string DisplayName => this._displayName ?? base.DisplayName;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? Description => this._description ?? base.Description;
|
||||
|
||||
|
||||
@@ -33,9 +33,8 @@ public static class A2AClientExtensions
|
||||
/// <param name="id">The unique identifier for the agent.</param>
|
||||
/// <param name="name">The the name of the agent.</param>
|
||||
/// <param name="description">The description of the agent.</param>
|
||||
/// <param name="displayName">The display name of the agent.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
|
||||
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
|
||||
public static AIAgent GetAIAgent(this A2AClient client, string? id = null, string? name = null, string? description = null, string? displayName = null, ILoggerFactory? loggerFactory = null) =>
|
||||
new A2AAgent(client, id, name, description, displayName, loggerFactory);
|
||||
public static AIAgent GetAIAgent(this A2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) =>
|
||||
new A2AAgent(client, id, name, description, loggerFactory);
|
||||
}
|
||||
|
||||
@@ -60,18 +60,6 @@ public abstract class AIAgent
|
||||
/// </remarks>
|
||||
public virtual string? Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a display-friendly name for the agent.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The agent's <see cref="Name"/> if available, otherwise the <see cref="Id"/>.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// This property provides a guaranteed non-null string suitable for display in user interfaces,
|
||||
/// logs, or other contexts where a readable identifier is needed.
|
||||
/// </remarks>
|
||||
public virtual string DisplayName => this.Name ?? this.Id;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a description of the agent's purpose, capabilities, or behavior.
|
||||
/// </summary>
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// Derived classes can override specific methods to add custom behavior while maintaining compatibility with the agent interface.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class DelegatingAIAgent : AIAgent
|
||||
public abstract class DelegatingAIAgent : AIAgent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DelegatingAIAgent"/> class with the specified inner agent.
|
||||
|
||||
@@ -231,7 +231,7 @@ internal static class EntitiesApiExtensions
|
||||
return new EntityInfo(
|
||||
Id: entityId,
|
||||
Type: "agent",
|
||||
Name: agent.DisplayName,
|
||||
Name: agent.Name ?? agent.Id,
|
||||
Description: agent.Description,
|
||||
Framework: "agent_framework",
|
||||
Tools: tools,
|
||||
|
||||
@@ -32,7 +32,7 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
||||
}
|
||||
|
||||
HttpRequestData? httpRequestData = null;
|
||||
TaskEntityDispatcher? dispatcher = null;
|
||||
string? encodedEntityRequest = null;
|
||||
DurableTaskClient? durableTaskClient = null;
|
||||
ToolInvocationContext? mcpToolInvocationContext = null;
|
||||
|
||||
@@ -43,8 +43,8 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
||||
case HttpRequestData request:
|
||||
httpRequestData = request;
|
||||
break;
|
||||
case TaskEntityDispatcher entityDispatcher:
|
||||
dispatcher = entityDispatcher;
|
||||
case string entityRequest:
|
||||
encodedEntityRequest = entityRequest;
|
||||
break;
|
||||
case DurableTaskClient client:
|
||||
durableTaskClient = client;
|
||||
@@ -78,14 +78,14 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
||||
|
||||
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunAgentEntityFunctionEntryPoint)
|
||||
{
|
||||
if (dispatcher is null)
|
||||
if (encodedEntityRequest is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Task entity dispatcher binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
await BuiltInFunctions.InvokeAgentAsync(
|
||||
dispatcher,
|
||||
context.GetInvocationResult().Value = await BuiltInFunctions.InvokeAgentAsync(
|
||||
durableTaskClient,
|
||||
encodedEntityRequest,
|
||||
context);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Extensions.Mcp;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Worker.Grpc;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -22,14 +23,14 @@ internal static class BuiltInFunctions
|
||||
internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}";
|
||||
|
||||
// Exposed as an entity trigger via AgentFunctionsProvider
|
||||
public static async Task InvokeAgentAsync(
|
||||
[EntityTrigger] TaskEntityDispatcher dispatcher,
|
||||
public static Task<string> InvokeAgentAsync(
|
||||
[DurableClient] DurableTaskClient client,
|
||||
string encodedEntityRequest,
|
||||
FunctionContext functionContext)
|
||||
{
|
||||
// This should never be null except if the function trigger is misconfigured.
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(encodedEntityRequest);
|
||||
ArgumentNullException.ThrowIfNull(functionContext);
|
||||
|
||||
// Create a combined service provider that includes both the existing services
|
||||
@@ -38,7 +39,8 @@ internal static class BuiltInFunctions
|
||||
|
||||
// This method is the entry point for the agent entity.
|
||||
// It will be invoked by the Azure Functions runtime when the entity is called.
|
||||
await dispatcher.DispatchAsync(new AgentEntity(combinedServiceProvider, functionContext.CancellationToken));
|
||||
AgentEntity entity = new(combinedServiceProvider, functionContext.CancellationToken);
|
||||
return GrpcEntityRunner.LoadAndRunAsync(encodedEntityRequest, entity, combinedServiceProvider);
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseData> RunAgentHttpAsync(
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Release History
|
||||
|
||||
## <version>
|
||||
|
||||
- Addressed incompatibility issue with `Microsoft.Azure.Functions.Worker.Extensions.DurableTask` >= 1.11.0 ([#2759](https://github.com/microsoft/agent-framework/pull/2759))
|
||||
|
||||
## v1.0.0-preview.251125.1
|
||||
|
||||
- Added support for .NET 10 ([#2128](https://github.com/microsoft/agent-framework/pull/2128))
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
|
||||
Language = "dotnet-isolated",
|
||||
RawBindings =
|
||||
[
|
||||
"""{"name":"dispatcher","type":"entityTrigger","direction":"In"}""",
|
||||
"""{"name":"encodedEntityRequest","type":"entityTrigger","direction":"In"}""",
|
||||
"""{"name":"client","type":"durableClient","direction":"In"}"""
|
||||
],
|
||||
EntryPoint = BuiltInFunctions.RunAgentEntityFunctionEntryPoint,
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExt
|
||||
|
||||
path ??= $"/{agent.Name}/v1/chat/completions";
|
||||
var group = endpoints.MapGroup(path);
|
||||
var endpointAgentName = agent.DisplayName;
|
||||
var endpointAgentName = agent.Name ?? agent.Id;
|
||||
|
||||
group.MapPost("/", async ([FromBody] CreateChatCompletion request, CancellationToken cancellationToken)
|
||||
=> await AIAgentChatCompletionsProcessor.CreateChatCompletionAsync(agent, request, cancellationToken).ConfigureAwait(false))
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExt
|
||||
var handlers = new ResponsesHttpHandler(responsesService);
|
||||
|
||||
var group = endpoints.MapGroup(responsesPath);
|
||||
var endpointAgentName = agent.DisplayName;
|
||||
var endpointAgentName = agent.Name ?? agent.Id;
|
||||
|
||||
// Create response endpoint
|
||||
group.MapPost("/", handlers.CreateResponseAsync)
|
||||
|
||||
@@ -125,14 +125,14 @@ public sealed class HandoffsWorkflowBuilder
|
||||
{
|
||||
Throw.ArgumentException(
|
||||
nameof(to),
|
||||
$"The provided target agent '{to.DisplayName}' has no description, name, or instructions, and no handoff description has been provided. " +
|
||||
$"The provided target agent '{to.Name ?? to.Id}' has no description, name, or instructions, and no handoff description has been provided. " +
|
||||
"At least one of these is required to register a handoff so that the appropriate target agent can be chosen.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!handoffs.Add(new(to, handoffReason)))
|
||||
{
|
||||
Throw.InvalidOperationException($"A handoff from agent '{from.DisplayName}' to agent '{to.DisplayName}' has already been registered.");
|
||||
Throw.InvalidOperationException($"A handoff from agent '{from.Name ?? from.Id}' to agent '{to.Name ?? to.Id}' has already been registered.");
|
||||
}
|
||||
|
||||
return this;
|
||||
|
||||
@@ -20,7 +20,7 @@ internal sealed class AgentRunStreamingExecutor(AIAgent agent, bool includeInput
|
||||
|
||||
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<ChatMessage>? roleChanged = messages.ChangeAssistantToUserForOtherParticipants(agent.DisplayName);
|
||||
List<ChatMessage>? roleChanged = messages.ChangeAssistantToUserForOtherParticipants(agent.Name ?? agent.Id);
|
||||
|
||||
List<AgentRunResponseUpdate> updates = [];
|
||||
await foreach (var update in agent.RunStreamingAsync(messages, cancellationToken: cancellationToken).ConfigureAwait(false))
|
||||
|
||||
@@ -67,7 +67,7 @@ internal sealed class HandoffAgentExecutor(
|
||||
List<AgentRunResponseUpdate> updates = [];
|
||||
List<ChatMessage> allMessages = handoffState.Messages;
|
||||
|
||||
List<ChatMessage>? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.DisplayName);
|
||||
List<ChatMessage>? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
|
||||
|
||||
await foreach (var update in this._agent.RunStreamingAsync(allMessages,
|
||||
options: this._agentOptions,
|
||||
@@ -85,7 +85,7 @@ internal sealed class HandoffAgentExecutor(
|
||||
new AgentRunResponseUpdate
|
||||
{
|
||||
AgentId = this._agent.Id,
|
||||
AuthorName = this._agent.DisplayName,
|
||||
AuthorName = this._agent.Name ?? this._agent.Id,
|
||||
Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")],
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
|
||||
@@ -114,7 +114,9 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
|
||||
// Override information set by OpenTelemetryChatClient to make it specific to invoke_agent.
|
||||
|
||||
activity.DisplayName = $"{OpenTelemetryConsts.GenAI.InvokeAgent} {this.DisplayName}";
|
||||
activity.DisplayName = string.IsNullOrWhiteSpace(this.Name)
|
||||
? $"{OpenTelemetryConsts.GenAI.InvokeAgent} {this.Id}"
|
||||
: $"{OpenTelemetryConsts.GenAI.InvokeAgent} {this.Name}({this.Id})";
|
||||
activity.SetTag(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.InvokeAgent);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(this._providerName))
|
||||
|
||||
@@ -42,16 +42,14 @@ public sealed class A2AAgentTests : IDisposable
|
||||
const string TestId = "test-id";
|
||||
const string TestName = "test-name";
|
||||
const string TestDescription = "test-description";
|
||||
const string TestDisplayName = "test-display-name";
|
||||
|
||||
// Act
|
||||
var agent = new A2AAgent(this._a2aClient, TestId, TestName, TestDescription, TestDisplayName);
|
||||
var agent = new A2AAgent(this._a2aClient, TestId, TestName, TestDescription);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TestId, agent.Id);
|
||||
Assert.Equal(TestName, agent.Name);
|
||||
Assert.Equal(TestDescription, agent.Description);
|
||||
Assert.Equal(TestDisplayName, agent.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -70,7 +68,6 @@ public sealed class A2AAgentTests : IDisposable
|
||||
Assert.NotEmpty(agent.Id);
|
||||
Assert.Null(agent.Name);
|
||||
Assert.Null(agent.Description);
|
||||
Assert.Equal(agent.Id, agent.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+1
-3
@@ -19,10 +19,9 @@ public sealed class A2AClientExtensionsTests
|
||||
const string TestId = "test-agent-id";
|
||||
const string TestName = "Test Agent";
|
||||
const string TestDescription = "This is a test agent description";
|
||||
const string TestDisplayName = "Test Display Name";
|
||||
|
||||
// Act
|
||||
var agent = a2aClient.GetAIAgent(TestId, TestName, TestDescription, TestDisplayName);
|
||||
var agent = a2aClient.GetAIAgent(TestId, TestName, TestDescription);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -30,6 +29,5 @@ public sealed class A2AClientExtensionsTests
|
||||
Assert.Equal(TestId, agent.Id);
|
||||
Assert.Equal(TestName, agent.Name);
|
||||
Assert.Equal(TestDescription, agent.Description);
|
||||
Assert.Equal(TestDisplayName, agent.DisplayName);
|
||||
}
|
||||
}
|
||||
|
||||
+190
@@ -19,6 +19,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
private const string AzureFunctionsPort = "7071";
|
||||
private const string AzuritePort = "10000";
|
||||
private const string DtsPort = "8080";
|
||||
private const string RedisPort = "6379";
|
||||
|
||||
private static readonly string s_dotnetTargetFramework = GetTargetFramework();
|
||||
private static readonly HttpClient s_sharedHttpClient = new();
|
||||
@@ -392,6 +393,136 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReliableStreamingSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "08_ReliableStreaming");
|
||||
await this.RunSampleTestAsync(samplePath, async (logs) =>
|
||||
{
|
||||
Uri createUri = new($"http://localhost:{AzureFunctionsPort}/api/agent/create");
|
||||
this._outputHelper.WriteLine($"Starting reliable streaming agent via POST request to {createUri}...");
|
||||
|
||||
// Test the agent endpoint with a simple prompt
|
||||
const string RequestBody = "Plan a 3-day trip to Seattle. Include daily activities.";
|
||||
using HttpContent content = new StringContent(RequestBody, Encoding.UTF8, "text/plain");
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, createUri)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
request.Headers.Add("Accept", "text/plain");
|
||||
|
||||
using HttpResponseMessage response = await s_sharedHttpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
// The response should be successful
|
||||
Assert.True(response.IsSuccessStatusCode, $"Agent request failed with status: {response.StatusCode}");
|
||||
Assert.Equal("text/plain", response.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
// The response headers should include the conversation ID
|
||||
string? conversationId = response.Headers.GetValues("x-conversation-id")?.FirstOrDefault();
|
||||
Assert.NotNull(conversationId);
|
||||
Assert.NotEmpty(conversationId);
|
||||
this._outputHelper.WriteLine($"Agent conversation ID: {conversationId}");
|
||||
|
||||
// Read the streamed response
|
||||
using Stream responseStream = await response.Content.ReadAsStreamAsync();
|
||||
using StreamReader reader = new(responseStream);
|
||||
StringBuilder responseText = new();
|
||||
char[] buffer = new char[1024];
|
||||
int bytesRead;
|
||||
|
||||
// Read for a reasonable amount of time to get some content
|
||||
using CancellationTokenSource readTimeout = new(TimeSpan.FromSeconds(30));
|
||||
try
|
||||
{
|
||||
while (!readTimeout.Token.IsCancellationRequested)
|
||||
{
|
||||
bytesRead = await reader.ReadAsync(buffer, 0, buffer.Length);
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
// Check if we've received enough content
|
||||
if (responseText.Length > 50)
|
||||
{
|
||||
break;
|
||||
}
|
||||
await Task.Delay(100, readTimeout.Token);
|
||||
continue;
|
||||
}
|
||||
|
||||
responseText.Append(buffer, 0, bytesRead);
|
||||
if (responseText.Length > 200)
|
||||
{
|
||||
// We've received enough content to validate
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Timeout is acceptable if we got some content
|
||||
}
|
||||
|
||||
string responseContent = responseText.ToString();
|
||||
Assert.True(responseContent.Length > 0, "Expected to receive some streamed content");
|
||||
this._outputHelper.WriteLine($"Received {responseContent.Length} characters of streamed content");
|
||||
|
||||
// Test resumption by calling the stream endpoint
|
||||
Uri streamUri = new($"http://localhost:{AzureFunctionsPort}/api/agent/stream/{conversationId}");
|
||||
this._outputHelper.WriteLine($"Testing stream resumption via GET request to {streamUri}...");
|
||||
|
||||
using HttpRequestMessage streamRequest = new(HttpMethod.Get, streamUri);
|
||||
streamRequest.Headers.Add("Accept", "text/plain");
|
||||
|
||||
using HttpResponseMessage streamResponse = await s_sharedHttpClient.SendAsync(
|
||||
streamRequest,
|
||||
HttpCompletionOption.ResponseHeadersRead);
|
||||
Assert.True(streamResponse.IsSuccessStatusCode, $"Stream request failed with status: {streamResponse.StatusCode}");
|
||||
Assert.Equal("text/plain", streamResponse.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
// Verify the conversation ID header is present
|
||||
string? resumedConversationId = streamResponse.Headers.GetValues("x-conversation-id")?.FirstOrDefault();
|
||||
Assert.Equal(conversationId, resumedConversationId);
|
||||
|
||||
// Read some content from the resumed stream
|
||||
using Stream resumedStream = await streamResponse.Content.ReadAsStreamAsync();
|
||||
using StreamReader resumedReader = new(resumedStream);
|
||||
StringBuilder resumedText = new();
|
||||
|
||||
using CancellationTokenSource resumedReadTimeout = new(TimeSpan.FromSeconds(10));
|
||||
try
|
||||
{
|
||||
while (!resumedReadTimeout.Token.IsCancellationRequested)
|
||||
{
|
||||
bytesRead = await resumedReader.ReadAsync(buffer, 0, buffer.Length);
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
if (resumedText.Length > 50)
|
||||
{
|
||||
break;
|
||||
}
|
||||
await Task.Delay(100, resumedReadTimeout.Token);
|
||||
continue;
|
||||
}
|
||||
|
||||
resumedText.Append(buffer, 0, bytesRead);
|
||||
if (resumedText.Length > 100)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Timeout is acceptable if we got some content
|
||||
}
|
||||
|
||||
string resumedContent = resumedText.ToString();
|
||||
Assert.True(resumedContent.Length > 0, "Expected to receive some content from resumed stream");
|
||||
this._outputHelper.WriteLine($"Received {resumedContent.Length} characters from resumed stream");
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<string> InvokeMcpToolAsync(McpClient mcpClient, string toolName, string query)
|
||||
{
|
||||
this._outputHelper.WriteLine($"Invoking MCP tool '{toolName}'...");
|
||||
@@ -482,6 +613,21 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
message: "DTS emulator is running",
|
||||
timeout: TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
// Start Redis if it's not already running
|
||||
if (!await this.IsRedisRunningAsync())
|
||||
{
|
||||
await this.StartDockerContainerAsync(
|
||||
containerName: "redis",
|
||||
image: "redis:latest",
|
||||
ports: ["-p", "6379:6379"]);
|
||||
|
||||
// Wait for Redis
|
||||
await this.WaitForConditionAsync(
|
||||
condition: this.IsRedisRunningAsync,
|
||||
message: "Redis is running",
|
||||
timeout: TimeSpan.FromSeconds(30));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> IsAzuriteRunningAsync()
|
||||
@@ -562,6 +708,49 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> IsRedisRunningAsync()
|
||||
{
|
||||
this._outputHelper.WriteLine($"Checking if Redis is running at localhost:{RedisPort}...");
|
||||
|
||||
try
|
||||
{
|
||||
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = "exec redis redis-cli ping",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using Process process = new() { StartInfo = startInfo };
|
||||
if (!process.Start())
|
||||
{
|
||||
this._outputHelper.WriteLine("Failed to start docker exec command");
|
||||
return false;
|
||||
}
|
||||
|
||||
string output = await process.StandardOutput.ReadToEndAsync(timeoutCts.Token);
|
||||
await process.WaitForExitAsync(timeoutCts.Token);
|
||||
|
||||
if (process.ExitCode == 0 && output.Contains("PONG", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
this._outputHelper.WriteLine("Redis is running");
|
||||
return true;
|
||||
}
|
||||
|
||||
this._outputHelper.WriteLine($"Redis is not running. Exit code: {process.ExitCode}, Output: {output}");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._outputHelper.WriteLine($"Redis is not running: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartDockerContainerAsync(string containerName, string image, string[] ports)
|
||||
{
|
||||
// Stop existing container if it exists
|
||||
@@ -646,6 +835,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
startInfo.EnvironmentVariables["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"] =
|
||||
$"Endpoint=http://localhost:{DtsPort};TaskHub=default;Authentication=None";
|
||||
startInfo.EnvironmentVariables["AzureWebJobsStorage"] = "UseDevelopmentStorage=true";
|
||||
startInfo.EnvironmentVariables["REDIS_CONNECTION_STRING"] = $"localhost:{RedisPort}";
|
||||
|
||||
Process process = new() { StartInfo = startInfo };
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ public class LoggingAgentTests
|
||||
Assert.Equal("TestAgent", agent.Name);
|
||||
Assert.Equal("This is a test agent.", agent.Description);
|
||||
Assert.Equal(innerAgent.Id, agent.Id);
|
||||
Assert.Equal(innerAgent.DisplayName, agent.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -45,7 +45,6 @@ public class OpenTelemetryAgentTests
|
||||
Assert.Equal("TestAgent", agent.Name);
|
||||
Assert.Equal("This is a test agent.", agent.Description);
|
||||
Assert.Equal(innerAgent.Id, agent.Id);
|
||||
Assert.Equal(innerAgent.DisplayName, agent.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -170,7 +169,7 @@ public class OpenTelemetryAgentTests
|
||||
Assert.Equal("localhost", activity.GetTagItem("server.address"));
|
||||
Assert.Equal(12345, (int)activity.GetTagItem("server.port")!);
|
||||
|
||||
Assert.Equal("invoke_agent TestAgent", activity.DisplayName);
|
||||
Assert.Equal($"invoke_agent {agent.Name}({agent.Id})", activity.DisplayName);
|
||||
Assert.Equal("invoke_agent", activity.GetTagItem("gen_ai.operation.name"));
|
||||
Assert.Equal("TestAgentProviderFromAIAgentMetadata", activity.GetTagItem("gen_ai.provider.name"));
|
||||
Assert.Equal(innerAgent.Name, activity.GetTagItem("gen_ai.agent.name"));
|
||||
@@ -431,7 +430,15 @@ public class OpenTelemetryAgentTests
|
||||
Assert.Equal("localhost", activity.GetTagItem("server.address"));
|
||||
Assert.Equal(12345, (int)activity.GetTagItem("server.port")!);
|
||||
|
||||
Assert.Equal($"invoke_agent {innerAgent.DisplayName}", activity.DisplayName);
|
||||
if (string.IsNullOrWhiteSpace(innerAgent.Name))
|
||||
{
|
||||
Assert.Equal($"invoke_agent {innerAgent.Id}", activity.DisplayName);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal($"invoke_agent {innerAgent.Name}({innerAgent.Id})", activity.DisplayName);
|
||||
}
|
||||
|
||||
Assert.Equal("invoke_agent", activity.GetTagItem("gen_ai.operation.name"));
|
||||
Assert.Equal("TestAgentProviderFromAIAgentMetadata", activity.GetTagItem("gen_ai.provider.name"));
|
||||
Assert.Equal(innerAgent.Name, activity.GetTagItem("gen_ai.agent.name"));
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ internal sealed class HandoffTestEchoAgent(string id, string name, string prefix
|
||||
{
|
||||
return [new(ChatRole.Assistant, [new FunctionCallContent(Guid.NewGuid().ToString("N"), handoff.Name)])
|
||||
{
|
||||
AuthorName = this.DisplayName,
|
||||
AuthorName = this.Name ?? this.Id,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
}];
|
||||
|
||||
@@ -47,7 +47,7 @@ internal class TestEchoAgent(string? id = null, string? name = null, string? pre
|
||||
select
|
||||
UpdateThread(new ChatMessage(ChatRole.Assistant, $"{prefix}{message.Text}")
|
||||
{
|
||||
AuthorName = this.DisplayName,
|
||||
AuthorName = this.Name ?? this.Id,
|
||||
CreatedAt = DateTimeOffset.Now,
|
||||
MessageId = Guid.NewGuid().ToString("N")
|
||||
}, thread as InMemoryAgentThread);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
---
|
||||
applyTo: '**/agent-framework/python/**'
|
||||
---
|
||||
- Use `uv run` as the main entrypoint for running Python commands with all packages available.
|
||||
- Use `uv run poe <task>` for development tasks like formatting (`fmt`), linting (`lint`), type checking (`pyright`, `mypy`), and testing (`test`).
|
||||
- Use `uv run --directory packages/<package> poe <task>` to run tasks for a specific package.
|
||||
- Read [DEV_SETUP.md](../../DEV_SETUP.md) for detailed development environment setup and available poe tasks.
|
||||
- Read [CODING_STANDARD.md](../../CODING_STANDARD.md) for the project's coding standards and best practices.
|
||||
- When verifying logic with unit tests, run only the related tests, not the entire test suite.
|
||||
- For new tests and samples, review existing ones to understand the coding style and reuse it.
|
||||
- When generating new functions, always specify the function return type and parameter types.
|
||||
|
||||
+38
-2
@@ -7,9 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0b251223] - 2025-12-23
|
||||
|
||||
### Added
|
||||
|
||||
- **agent-framework-bedrock**: Introducing support for Bedrock-hosted models (Anthropic, Cohere, etc.) ([#2610](https://github.com/microsoft/agent-framework/pull/2610))
|
||||
- **agent-framework-core**: Added `response.created` and `response.in_progress` event process to `OpenAIBaseResponseClient` ([#2975](https://github.com/microsoft/agent-framework/pull/2975))
|
||||
- **agent-framework-foundry-local**: Introducing Foundry Local Chat Clients ([#2915](https://github.com/microsoft/agent-framework/pull/2915))
|
||||
- **samples**: Added GitHub MCP sample with PAT ([#2967](https://github.com/microsoft/agent-framework/pull/2967))
|
||||
|
||||
### Changed
|
||||
|
||||
- **agent-framework-azurefunctions**: Durable Agents: platforms should use consistent entity method names (#2234)
|
||||
- **agent-framework-core**: Preserve reasoning blocks with OpenRouter ([#2950](https://github.com/microsoft/agent-framework/pull/2950))
|
||||
|
||||
## [1.0.0b251218] - 2025-12-18
|
||||
|
||||
### Added
|
||||
|
||||
- **agent-framework-core**: Azure AI Agent with Bing Grounding Citations sample ([#2892](https://github.com/microsoft/agent-framework/pull/2892))
|
||||
- **agent-framework-core**: Workflow option to visualize internal executors ([#2917](https://github.com/microsoft/agent-framework/pull/2917))
|
||||
- **agent-framework-core**: Workflow cancellation sample ([#2732](https://github.com/microsoft/agent-framework/pull/2732))
|
||||
- **agent-framework-core**: Azure Managed Redis support with credential provider ([#2887](https://github.com/microsoft/agent-framework/pull/2887))
|
||||
- **agent-framework-core**: Additional arguments for Azure AI agent configuration ([#2922](https://github.com/microsoft/agent-framework/pull/2922))
|
||||
|
||||
### Changed
|
||||
|
||||
- **agent-framework-ollama**: Updated Ollama package version ([#2920](https://github.com/microsoft/agent-framework/pull/2920))
|
||||
- **agent-framework-ollama**: Move Ollama samples to samples getting started directory ([#2921](https://github.com/microsoft/agent-framework/pull/2921))
|
||||
- **agent-framework-core**: Cleanup and refactoring of chat clients ([#2937](https://github.com/microsoft/agent-framework/pull/2937))
|
||||
- **agent-framework-core**: Align Run ID and Thread ID casing with AG-UI TypeScript SDK ([#2948](https://github.com/microsoft/agent-framework/pull/2948))
|
||||
|
||||
### Fixed
|
||||
|
||||
- **agent-framework-core**: Fix Pydantic error when using Literal types for tool parameters ([#2893](https://github.com/microsoft/agent-framework/pull/2893))
|
||||
- **agent-framework-core**: Correct MCP image type conversion in `_mcp.py` ([#2901](https://github.com/microsoft/agent-framework/pull/2901))
|
||||
- **agent-framework-core**: Fix BadRequestError when using Pydantic models in response formatting ([#1843](https://github.com/microsoft/agent-framework/pull/1843))
|
||||
- **agent-framework-core**: Propagate workflow kwargs to sub-workflows via WorkflowExecutor ([#2923](https://github.com/microsoft/agent-framework/pull/2923))
|
||||
- **agent-framework-core**: Fix WorkflowAgent event handling and kwargs forwarding ([#2946](https://github.com/microsoft/agent-framework/pull/2946))
|
||||
|
||||
## [1.0.0b251216] - 2025-12-16
|
||||
|
||||
@@ -392,7 +426,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251216...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251223...HEAD
|
||||
[1.0.0b251223]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251218...python-1.0.0b251223
|
||||
[1.0.0b251218]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251216...python-1.0.0b251218
|
||||
[1.0.0b251216]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251211...python-1.0.0b251216
|
||||
[1.0.0b251211]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251209...python-1.0.0b251211
|
||||
[1.0.0b251209]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251204...python-1.0.0b251209
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
# Coding Standards
|
||||
|
||||
This document describes the coding standards and conventions for the Agent Framework project.
|
||||
|
||||
## Code Style and Formatting
|
||||
|
||||
We use [ruff](https://github.com/astral-sh/ruff) for both linting and formatting with the following configuration:
|
||||
|
||||
- **Line length**: 120 characters
|
||||
- **Target Python version**: 3.10+
|
||||
- **Google-style docstrings**: All public functions, classes, and modules should have docstrings following Google conventions
|
||||
|
||||
## Function Parameter Guidelines
|
||||
|
||||
To make the code easier to use and maintain:
|
||||
|
||||
- **Positional parameters**: Only use for up to 3 fully expected parameters
|
||||
- **Keyword parameters**: Use for all other parameters, especially when there are multiple required parameters without obvious ordering
|
||||
- **Avoid additional imports**: Do not require the user to import additional modules to use the function, so provide string based overrides when applicable, for instance:
|
||||
```python
|
||||
def create_agent(name: str, tool_mode: ChatToolMode) -> Agent:
|
||||
# Implementation here
|
||||
```
|
||||
Should be:
|
||||
```python
|
||||
def create_agent(name: str, tool_mode: Literal['auto', 'required', 'none'] | ChatToolMode) -> Agent:
|
||||
# Implementation here
|
||||
if isinstance(tool_mode, str):
|
||||
tool_mode = ChatToolMode(tool_mode)
|
||||
```
|
||||
- **Document kwargs**: Always document how `kwargs` are used, either by referencing external documentation or explaining their purpose
|
||||
- **Separate kwargs**: When combining kwargs for multiple purposes, use specific parameters like `client_kwargs: dict[str, Any]` instead of mixing everything in `**kwargs`
|
||||
|
||||
## Method Naming Inside Connectors
|
||||
|
||||
When naming methods inside connectors, we have a loose preference for using the following conventions:
|
||||
- Use `_prepare_<object>_for_<purpose>` as a prefix for methods that prepare data for sending to the external service.
|
||||
- Use `_parse_<object>_from_<source>` as a prefix for methods that process data received from the external service.
|
||||
|
||||
This is not a strict rule, but a guideline to help maintain consistency across the codebase.
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
### Asynchronous Programming
|
||||
|
||||
It's important to note that most of this library is written with asynchronous in mind. The
|
||||
developer should always assume everything is asynchronous. One can use the function signature
|
||||
with either `async def` or `def` to understand if something is asynchronous or not.
|
||||
|
||||
### Attributes vs Inheritance
|
||||
|
||||
Prefer attributes over inheritance when parameters are mostly the same:
|
||||
|
||||
```python
|
||||
# ✅ Preferred - using attributes
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
user_msg = ChatMessage(role="user", content="Hello, world!")
|
||||
asst_msg = ChatMessage(role="assistant", content="Hello, world!")
|
||||
|
||||
# ❌ Not preferred - unnecessary inheritance
|
||||
from agent_framework import UserMessage, AssistantMessage
|
||||
|
||||
user_msg = UserMessage(content="Hello, world!")
|
||||
asst_msg = AssistantMessage(content="Hello, world!")
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
Use the centralized logging system:
|
||||
|
||||
```python
|
||||
from agent_framework import get_logger
|
||||
|
||||
# For main package
|
||||
logger = get_logger()
|
||||
|
||||
# For subpackages
|
||||
logger = get_logger('agent_framework.azure')
|
||||
```
|
||||
|
||||
**Do not use** direct logging module imports:
|
||||
```python
|
||||
# ❌ Avoid this
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
```
|
||||
|
||||
### Import Structure
|
||||
|
||||
The package follows a flat import structure:
|
||||
|
||||
- **Core**: Import directly from `agent_framework`
|
||||
```python
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
```
|
||||
|
||||
- **Components**: Import from `agent_framework.<component>`
|
||||
```python
|
||||
from agent_framework.observability import enable_instrumentation, configure_otel_providers
|
||||
```
|
||||
|
||||
- **Connectors**: Import from `agent_framework.<vendor/platform>`
|
||||
```python
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
```
|
||||
|
||||
## Package Structure
|
||||
|
||||
The project uses a monorepo structure with separate packages for each connector/extension:
|
||||
|
||||
```plaintext
|
||||
python/
|
||||
├── pyproject.toml # Root package (agent-framework) depends on agent-framework-core[all]
|
||||
├── samples/ # Sample code and examples
|
||||
├── packages/
|
||||
│ ├── core/ # agent-framework-core - Core abstractions and implementations
|
||||
│ │ ├── pyproject.toml # Defines [all] extra that includes all connector packages
|
||||
│ │ ├── tests/ # Tests for core package
|
||||
│ │ └── agent_framework/
|
||||
│ │ ├── __init__.py # Public API exports
|
||||
│ │ ├── _agents.py # Agent implementations
|
||||
│ │ ├── _clients.py # Chat client protocols and base classes
|
||||
│ │ ├── _tools.py # Tool definitions
|
||||
│ │ ├── _types.py # Type definitions
|
||||
│ │ ├── _logging.py # Logging utilities
|
||||
│ │ │
|
||||
│ │ │ # Provider folders - lazy load from connector packages
|
||||
│ │ ├── openai/ # OpenAI clients (built into core)
|
||||
│ │ ├── azure/ # Lazy loads from azure-ai, azure-ai-search, azurefunctions
|
||||
│ │ ├── anthropic/ # Lazy loads from agent-framework-anthropic
|
||||
│ │ ├── ollama/ # Lazy loads from agent-framework-ollama
|
||||
│ │ ├── a2a/ # Lazy loads from agent-framework-a2a
|
||||
│ │ ├── ag_ui/ # Lazy loads from agent-framework-ag-ui
|
||||
│ │ ├── chatkit/ # Lazy loads from agent-framework-chatkit
|
||||
│ │ ├── declarative/ # Lazy loads from agent-framework-declarative
|
||||
│ │ ├── devui/ # Lazy loads from agent-framework-devui
|
||||
│ │ ├── mem0/ # Lazy loads from agent-framework-mem0
|
||||
│ │ └── redis/ # Lazy loads from agent-framework-redis
|
||||
│ │
|
||||
│ ├── azure-ai/ # agent-framework-azure-ai
|
||||
│ │ ├── pyproject.toml
|
||||
│ │ ├── tests/
|
||||
│ │ └── agent_framework_azure_ai/
|
||||
│ │ ├── __init__.py # Public exports
|
||||
│ │ ├── _chat_client.py # AzureAIClient implementation
|
||||
│ │ ├── _client.py # AzureAIAgentClient implementation
|
||||
│ │ ├── _shared.py # AzureAISettings and shared utilities
|
||||
│ │ └── py.typed # PEP 561 marker
|
||||
│ ├── anthropic/ # agent-framework-anthropic
|
||||
│ ├── bedrock/ # agent-framework-bedrock
|
||||
│ ├── ollama/ # agent-framework-ollama
|
||||
│ └── ... # Other connector packages
|
||||
```
|
||||
|
||||
### Lazy Loading Pattern
|
||||
|
||||
Provider folders in the core package use `__getattr__` to lazy load classes from their respective connector packages. This allows users to import from a consistent location while only loading dependencies when needed:
|
||||
|
||||
```python
|
||||
# In agent_framework/azure/__init__.py
|
||||
_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"AzureAIAgentClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"),
|
||||
# ...
|
||||
}
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
import_path, package_name = _IMPORTS[name]
|
||||
try:
|
||||
return getattr(importlib.import_module(import_path), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The package {package_name} is required to use `{name}`. "
|
||||
f"Install it with: pip install {package_name}"
|
||||
) from exc
|
||||
```
|
||||
|
||||
### Adding a New Connector Package
|
||||
|
||||
**Important:** Do not create a new package unless there is an issue that has been reviewed and approved by the core team.
|
||||
|
||||
#### Initial Release (Preview Phase)
|
||||
|
||||
For the first release of a new connector package:
|
||||
|
||||
1. Create a new directory under `packages/` (e.g., `packages/my-connector/`)
|
||||
2. Add the package to `tool.uv.sources` in the root `pyproject.toml`
|
||||
3. Include samples inside the package itself (e.g., `packages/my-connector/samples/`)
|
||||
4. **Do NOT** add the package to the `[all]` extra in `packages/core/pyproject.toml`
|
||||
5. **Do NOT** create lazy loading in core yet
|
||||
|
||||
#### Promotion to Stable
|
||||
|
||||
After the package has been released and gained a measure of confidence:
|
||||
|
||||
1. Move samples from the package to the root `samples/` folder
|
||||
2. Add the package to the `[all]` extra in `packages/core/pyproject.toml`
|
||||
3. Create a provider folder in `agent_framework/` with lazy loading `__init__.py`
|
||||
|
||||
### Installation Options
|
||||
|
||||
Connectors are distributed as separate packages and are not imported by default in the core package. Users install the specific connectors they need:
|
||||
|
||||
```bash
|
||||
# Install core only
|
||||
pip install agent-framework-core
|
||||
|
||||
# Install core with all connectors
|
||||
pip install agent-framework-core[all]
|
||||
# or (equivalently):
|
||||
pip install agent-framework
|
||||
|
||||
# Install specific connector
|
||||
pip install agent-framework-azure-ai
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Each file should have a single first line containing: # Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
We follow the [Google Docstring](https://github.com/google/styleguide/blob/gh-pages/pyguide.md#383-functions-and-methods) style guide for functions and methods.
|
||||
They are currently not checked for private functions (functions starting with '_').
|
||||
|
||||
They should contain:
|
||||
|
||||
- Single line explaining what the function does, ending with a period.
|
||||
- If necessary to further explain the logic a newline follows the first line and then the explanation is given.
|
||||
- The following three sections are optional, and if used should be separated by a single empty line.
|
||||
- Arguments are then specified after a header called `Args:`, with each argument being specified in the following format:
|
||||
- `arg_name`: Explanation of the argument.
|
||||
- if a longer explanation is needed for a argument, it should be placed on the next line, indented by 4 spaces.
|
||||
- Type and default values do not have to be specified, they will be pulled from the definition.
|
||||
- Returns are specified after a header called `Returns:` or `Yields:`, with the return type and explanation of the return value.
|
||||
- Keyword arguments are specified after a header called `Keyword Args:`, with each argument being specified in the same format as `Args:`.
|
||||
- A header for exceptions can be added, called `Raises:`, but should only be used for:
|
||||
- Agent Framework specific exceptions (e.g., `ServiceInitializationError`)
|
||||
- Base exceptions that might be unexpected in the context
|
||||
- Obvious exceptions like `ValueError` or `TypeError` do not need to be documented
|
||||
- Format: `ExceptionType`: Explanation of the exception.
|
||||
- If a longer explanation is needed, it should be placed on the next line, indented by 4 spaces.
|
||||
- Code examples can be added using the `Examples:` header followed by `.. code-block:: python` directive.
|
||||
|
||||
Putting them all together, gives you at minimum this:
|
||||
|
||||
```python
|
||||
def equal(arg1: str, arg2: str) -> bool:
|
||||
"""Compares two strings and returns True if they are the same."""
|
||||
...
|
||||
```
|
||||
|
||||
Or a complete version of this:
|
||||
|
||||
```python
|
||||
def equal(arg1: str, arg2: str) -> bool:
|
||||
"""Compares two strings and returns True if they are the same.
|
||||
|
||||
Here is extra explanation of the logic involved.
|
||||
|
||||
Args:
|
||||
arg1: The first string to compare.
|
||||
arg2: The second string to compare.
|
||||
|
||||
Returns:
|
||||
True if the strings are the same, False otherwise.
|
||||
"""
|
||||
```
|
||||
|
||||
A more complete example with keyword arguments and code samples:
|
||||
|
||||
```python
|
||||
def create_client(
|
||||
model_id: str | None = None,
|
||||
*,
|
||||
timeout: float | None = None,
|
||||
env_file_path: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Client:
|
||||
"""Create a new client with the specified configuration.
|
||||
|
||||
Args:
|
||||
model_id: The model ID to use. If not provided,
|
||||
it will be loaded from settings.
|
||||
|
||||
Keyword Args:
|
||||
timeout: Optional timeout for requests.
|
||||
env_file_path: If provided, settings are read from this file.
|
||||
kwargs: Additional keyword arguments passed to the underlying client.
|
||||
|
||||
Returns:
|
||||
A configured client instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If the model_id is invalid.
|
||||
|
||||
Examples:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Create a client with default settings:
|
||||
client = create_client(model_id="gpt-4o")
|
||||
|
||||
# Or load from environment:
|
||||
client = create_client(env_file_path=".env")
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
Use Google-style docstrings for all public APIs:
|
||||
|
||||
```python
|
||||
def create_agent(name: str, chat_client: ChatClientProtocol) -> Agent:
|
||||
"""Create a new agent with the specified configuration.
|
||||
|
||||
Args:
|
||||
name: The name of the agent.
|
||||
chat_client: The chat client to use for communication.
|
||||
|
||||
Returns:
|
||||
True if the strings are the same, False otherwise.
|
||||
|
||||
Raises:
|
||||
ValueError: If one of the strings is empty.
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
If in doubt, use the link above to read much more considerations of what to do and when, or use common sense.
|
||||
|
||||
## Performance considerations
|
||||
|
||||
### Cache Expensive Computations
|
||||
|
||||
Think about caching where appropriate. Cache the results of expensive operations that are called repeatedly with the same inputs:
|
||||
|
||||
```python
|
||||
# ✅ Preferred - cache expensive computations
|
||||
class AIFunction:
|
||||
def __init__(self, ...):
|
||||
self._cached_parameters: dict[str, Any] | None = None
|
||||
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
"""Return the JSON schema for the function's parameters.
|
||||
|
||||
The result is cached after the first call for performance.
|
||||
"""
|
||||
if self._cached_parameters is None:
|
||||
self._cached_parameters = self.input_model.model_json_schema()
|
||||
return self._cached_parameters
|
||||
|
||||
# ❌ Avoid - recalculating every time
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return self.input_model.model_json_schema()
|
||||
```
|
||||
|
||||
### Prefer Attribute Access Over isinstance()
|
||||
|
||||
When checking types in hot paths, prefer checking a `type` attribute (fast string comparison) over `isinstance()` (slower due to method resolution order traversal):
|
||||
|
||||
```python
|
||||
# ✅ Preferred - use match/case with type attribute (faster)
|
||||
match content.type:
|
||||
case "function_call":
|
||||
# handle function call
|
||||
case "usage":
|
||||
# handle usage
|
||||
case _:
|
||||
# handle other types
|
||||
|
||||
# ❌ Avoid in hot paths - isinstance() is slower
|
||||
if isinstance(content, FunctionCallContent):
|
||||
# handle function call
|
||||
elif isinstance(content, UsageContent):
|
||||
# handle usage
|
||||
```
|
||||
|
||||
For inline conditionals:
|
||||
|
||||
```python
|
||||
# ✅ Preferred - type attribute comparison
|
||||
result = value if content.type == "function_call" else other
|
||||
|
||||
# ❌ Avoid - isinstance() in hot paths
|
||||
result = value if isinstance(content, FunctionCallContent) else other
|
||||
```
|
||||
|
||||
### Avoid Redundant Serialization
|
||||
|
||||
When the same data needs to be used in multiple places, compute it once and reuse it:
|
||||
|
||||
```python
|
||||
# ✅ Preferred - reuse computed representation
|
||||
otel_message = _to_otel_message(message)
|
||||
otel_messages.append(otel_message)
|
||||
logger.info(otel_message, extra={...})
|
||||
|
||||
# ❌ Avoid - computing the same thing twice
|
||||
otel_messages.append(_to_otel_message(message)) # this already serializes
|
||||
message_data = message.to_dict(exclude_none=True) # and this does so again!
|
||||
logger.info(message_data, extra={...})
|
||||
```
|
||||
+45
-367
@@ -4,6 +4,8 @@ This document describes how to setup your environment with Python and uv,
|
||||
if you're working on new features or a bug fix for Agent Framework, or simply
|
||||
want to run the tests included.
|
||||
|
||||
For coding standards and conventions, see [CODING_STANDARD.md](CODING_STANDARD.md).
|
||||
|
||||
## System setup
|
||||
|
||||
We are using a tool called [poethepoet](https://github.com/nat-n/poethepoet) for task management and [uv](https://github.com/astral-sh/uv) for dependency management. At the [end of this document](#available-poe-tasks), you will find the available Poe tasks.
|
||||
@@ -117,43 +119,6 @@ from agent_framework.openai import OpenAIChatClient
|
||||
chat_client = OpenAIChatClient(env_file_path="openai.env")
|
||||
```
|
||||
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### Code Style and Formatting
|
||||
|
||||
We use [ruff](https://github.com/astral-sh/ruff) for both linting and formatting with the following configuration:
|
||||
|
||||
- **Line length**: 120 characters
|
||||
- **Target Python version**: 3.10+
|
||||
- **Google-style docstrings**: All public functions, classes, and modules should have docstrings following Google conventions
|
||||
|
||||
### Function Parameter Guidelines
|
||||
|
||||
To make the code easier to use and maintain:
|
||||
|
||||
- **Positional parameters**: Only use for up to 3 fully expected parameters
|
||||
- **Keyword parameters**: Use for all other parameters, especially when there are multiple required parameters without obvious ordering
|
||||
- **Avoid additional imports**: Do not require the user to import additional modules to use the function, so provide string based overrides when applicable, for instance:
|
||||
```python
|
||||
def create_agent(name: str, tool_mode: ChatToolMode) -> Agent:
|
||||
# Implementation here
|
||||
```
|
||||
Should be:
|
||||
```python
|
||||
def create_agent(name: str, tool_mode: Literal['auto', 'required', 'none'] | ChatToolMode) -> Agent:
|
||||
# Implementation here
|
||||
if isinstance(tool_mode, str):
|
||||
tool_mode = ChatToolMode(tool_mode)
|
||||
```
|
||||
- **Document kwargs**: Always document how `kwargs` are used, either by referencing external documentation or explaining their purpose
|
||||
- **Separate kwargs**: When combining kwargs for multiple purposes, use specific parameters like `client_kwargs: dict[str, Any]` instead of mixing everything in `**kwargs`
|
||||
|
||||
Example:
|
||||
```python
|
||||
chat_completion = OpenAIChatClient(env_file_path="openai.env")
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
All the tests are located in the `tests` folder of each package. There are tests that are marked with a `@skip_if_..._integration_tests_disabled` decorator, these are integration tests that require an external service to be running, like OpenAI or Azure OpenAI.
|
||||
@@ -171,264 +136,6 @@ uv run poe --directory packages/core test
|
||||
|
||||
These commands also output the coverage report.
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
### Asynchronous programming
|
||||
|
||||
It's important to note that most of this library is written with asynchronous in mind. The
|
||||
developer should always assume everything is asynchronous. One can use the function signature
|
||||
with either `async def` or `def` to understand if something is asynchronous or not.
|
||||
|
||||
### Documentation
|
||||
|
||||
Each file should have a single first line containing: # Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
We follow the [Google Docstring](https://github.com/google/styleguide/blob/gh-pages/pyguide.md#383-functions-and-methods) style guide for functions and methods.
|
||||
They are currently not checked for private functions (functions starting with '_').
|
||||
|
||||
They should contain:
|
||||
|
||||
- Single line explaining what the function does, ending with a period.
|
||||
- If necessary to further explain the logic a newline follows the first line and then the explanation is given.
|
||||
- The following three sections are optional, and if used should be separated by a single empty line.
|
||||
- Arguments are then specified after a header called `Args:`, with each argument being specified in the following format:
|
||||
- `arg_name`: Explanation of the argument.
|
||||
- if a longer explanation is needed for a argument, it should be placed on the next line, indented by 4 spaces.
|
||||
- Type and default values do not have to be specified, they will be pulled from the definition.
|
||||
- Returns are specified after a header called `Returns:` or `Yields:`, with the return type and explanation of the return value.
|
||||
- Finally, a header for exceptions can be added, called `Raises:`, with each exception being specified in the following format:
|
||||
- `ExceptionType`: Explanation of the exception.
|
||||
- if a longer explanation is needed for a exception, it should be placed on the next line, indented by 4 spaces.
|
||||
|
||||
Putting them all together, gives you at minimum this:
|
||||
|
||||
```python
|
||||
def equal(arg1: str, arg2: str) -> bool:
|
||||
"""Compares two strings and returns True if they are the same."""
|
||||
...
|
||||
```
|
||||
|
||||
Or a complete version of this:
|
||||
|
||||
```python
|
||||
def equal(arg1: str, arg2: str) -> bool:
|
||||
"""Compares two strings and returns True if they are the same.
|
||||
|
||||
Here is extra explanation of the logic involved.
|
||||
|
||||
Args:
|
||||
arg1: The first string to compare.
|
||||
arg2: The second string to compare.
|
||||
|
||||
Returns:
|
||||
True if the strings are the same, False otherwise.
|
||||
"""
|
||||
```
|
||||
|
||||
### Attributes vs Inheritance
|
||||
|
||||
Prefer attributes over inheritance when parameters are mostly the same:
|
||||
|
||||
```python
|
||||
# ✅ Preferred - using attributes
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
user_msg = ChatMessage(role="user", content="Hello, world!")
|
||||
asst_msg = ChatMessage(role="assistant", content="Hello, world!")
|
||||
|
||||
# ❌ Not preferred - unnecessary inheritance
|
||||
from agent_framework import UserMessage, AssistantMessage
|
||||
|
||||
user_msg = UserMessage(content="Hello, world!")
|
||||
asst_msg = AssistantMessage(content="Hello, world!")
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
Use the centralized logging system:
|
||||
|
||||
```python
|
||||
from agent_framework import get_logger
|
||||
|
||||
# For main package
|
||||
logger = get_logger()
|
||||
|
||||
# For subpackages
|
||||
logger = get_logger('agent_framework.azure')
|
||||
```
|
||||
|
||||
**Do not use** direct logging module imports:
|
||||
```python
|
||||
# ❌ Avoid this
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
```
|
||||
|
||||
### Import Structure
|
||||
|
||||
The package follows a flat import structure:
|
||||
|
||||
- **Core**: Import directly from `agent_framework`
|
||||
```python
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
```
|
||||
|
||||
- **Components**: Import from `agent_framework.<component>`
|
||||
```python
|
||||
from agent_framework.vector_data import VectorStoreModel
|
||||
from agent_framework.guardrails import ContentFilter
|
||||
```
|
||||
|
||||
- **Connectors**: Import from `agent_framework.<vendor/platform>`
|
||||
```python
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests with coverage
|
||||
uv run poe test
|
||||
|
||||
# Run specific test file
|
||||
uv run pytest tests/test_agents.py
|
||||
|
||||
# Run with verbose output
|
||||
uv run pytest -v
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- Target: Minimum 80% test coverage for all packages
|
||||
- Coverage reports are generated automatically during test runs
|
||||
- Tests should be in corresponding `test_*.py` files in the `tests/` directory
|
||||
|
||||
## Documentation
|
||||
|
||||
### Building Documentation
|
||||
|
||||
```bash
|
||||
# Build documentation
|
||||
uv run poe docs-build
|
||||
|
||||
# Serve documentation locally with auto-reload
|
||||
uv run poe docs-serve
|
||||
|
||||
# Check documentation for warnings
|
||||
uv run poe docs-check
|
||||
```
|
||||
|
||||
### Docstring Style
|
||||
|
||||
Use Google-style docstrings for all public APIs:
|
||||
|
||||
```python
|
||||
def create_agent(name: str, chat_client: ChatClientProtocol) -> Agent:
|
||||
"""Create a new agent with the specified configuration.
|
||||
|
||||
Args:
|
||||
name: The name of the agent.
|
||||
chat_client: The chat client to use for communication.
|
||||
|
||||
Returns:
|
||||
True if the strings are the same, False otherwise.
|
||||
|
||||
Raises:
|
||||
ValueError: If one of the strings is empty.
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
If in doubt, use the link above to read much more considerations of what to do and when, or use common sense.
|
||||
|
||||
## Coding standards
|
||||
|
||||
```plaintext
|
||||
agent_framework/
|
||||
├── __init__.py # Tier 0: Core components
|
||||
├── _agents.py # Agent implementations
|
||||
├── _tools.py # Tool definitions
|
||||
├── _models.py # Type definitions
|
||||
├── _logging.py # Logging utilities
|
||||
├── context_providers.py # Tier 1: Context providers
|
||||
├── guardrails.py # Tier 1: Guardrails and filters
|
||||
├── vector_data.py # Tier 1: Vector stores
|
||||
├── workflows.py # Tier 1: Multi-agent orchestration
|
||||
└── azure/ # Tier 2: Azure connectors (lazy loaded)
|
||||
└── __init__.py # Imports from agent-framework-azure
|
||||
```
|
||||
|
||||
### Pydantic and Serialization
|
||||
|
||||
This section describes how one can enable serialization for their class using Pydantic.
|
||||
For more info you can refer to the [Pydantic Documentation](https://docs.pydantic.dev/latest/).
|
||||
|
||||
#### Upgrading existing classes to use Pydantic
|
||||
|
||||
Let's take the following example:
|
||||
|
||||
```python
|
||||
class A:
|
||||
def __init__(self, a: int, b: float, c: List[float], d: dict[str, tuple[float, str]] = {}):
|
||||
self.a = a
|
||||
self.b = b
|
||||
self.c = c
|
||||
self.d = d
|
||||
```
|
||||
|
||||
You would convert this to a Pydantic class by sub-classing from the `AFBaseModel` class.
|
||||
|
||||
```python
|
||||
from pydantic import Field
|
||||
from ._pydantic import AFBaseModel
|
||||
|
||||
class A(AFBaseModel):
|
||||
# The notation for the fields is similar to dataclasses.
|
||||
a: int
|
||||
b: float
|
||||
c: list[float]
|
||||
# Only, instead of using dataclasses.field, you would use pydantic.Field
|
||||
d: dict[str, tuple[float, str]] = Field(default_factory=dict)
|
||||
```
|
||||
|
||||
#### Classes with data that need to be serialized, and some of them are Generic types
|
||||
|
||||
Let's take the following example:
|
||||
|
||||
```python
|
||||
from typing import TypeVar
|
||||
|
||||
T1 = TypeVar("T1")
|
||||
T2 = TypeVar("T2", bound=<some class>)
|
||||
|
||||
class A:
|
||||
def __init__(a: int, b: T1, c: T2):
|
||||
self.a = a
|
||||
self.b = b
|
||||
self.c = c
|
||||
```
|
||||
|
||||
You can use the `AFBaseModel` to convert these to pydantic serializable classes.
|
||||
|
||||
```python
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from ._pydantic import AFBaseModel
|
||||
|
||||
T1 = TypeVar("T1")
|
||||
T2 = TypeVar("T2", bound=<some class>)
|
||||
|
||||
class A(AFBaseModel, Generic[T1, T2]):
|
||||
# T1 and T2 must be specified in the Generic argument otherwise, pydantic will
|
||||
# NOT be able to serialize this class
|
||||
a: int
|
||||
b: T1
|
||||
c: T2
|
||||
```
|
||||
|
||||
## Code quality checks
|
||||
|
||||
To run the same checks that run during a commit and the GitHub Action `Python Code Quality`, you can use this command, from the [python](../python) folder:
|
||||
@@ -489,7 +196,7 @@ and then you can run the following tasks:
|
||||
uv sync --all-extras --dev
|
||||
```
|
||||
|
||||
After this initial setup, you can use the following tasks to manage your development environment, it is adviced to use the following setup command since that also installs the pre-commit hooks.
|
||||
After this initial setup, you can use the following tasks to manage your development environment. It is advised to use the following setup command since that also installs the pre-commit hooks.
|
||||
|
||||
#### `setup`
|
||||
Set up the development environment with a virtual environment, install dependencies and pre-commit hooks:
|
||||
@@ -547,64 +254,6 @@ Run MyPy type checking:
|
||||
uv run poe mypy
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
#### `test`
|
||||
Run unit tests with coverage:
|
||||
```bash
|
||||
uv run poe test
|
||||
```
|
||||
|
||||
### Documentation
|
||||
|
||||
#### `docs-install`
|
||||
Install including the documentation tools:
|
||||
```bash
|
||||
uv run poe docs-install
|
||||
```
|
||||
|
||||
#### `docs-clean`
|
||||
Remove the docs build directory:
|
||||
```bash
|
||||
uv run poe docs-clean
|
||||
```
|
||||
|
||||
#### `docs-build`
|
||||
Build the documentation:
|
||||
```bash
|
||||
uv run poe docs-build
|
||||
```
|
||||
|
||||
#### `docs-full`
|
||||
Build the packages, clean and build the documentation:
|
||||
```bash
|
||||
uv run poe docs-full
|
||||
```
|
||||
|
||||
#### `docs-rebuild`
|
||||
Clean and build the documentation:
|
||||
```bash
|
||||
uv run poe docs-rebuild
|
||||
```
|
||||
|
||||
#### `docs-full-install`
|
||||
Install the docs dependencies, build the packages, clean and build the documentation:
|
||||
```bash
|
||||
uv run poe docs-full-install
|
||||
```
|
||||
|
||||
#### `docs-debug`
|
||||
Build the documentation with debug information:
|
||||
```bash
|
||||
uv run poe docs-debug
|
||||
```
|
||||
|
||||
#### `docs-rebuild-debug`
|
||||
Clean and build the documentation with debug information:
|
||||
```bash
|
||||
uv run poe docs-rebuild-debug
|
||||
```
|
||||
|
||||
### Code Validation
|
||||
|
||||
#### `markdown-code-lint`
|
||||
@@ -613,37 +262,66 @@ Lint markdown code blocks:
|
||||
uv run poe markdown-code-lint
|
||||
```
|
||||
|
||||
#### `samples-code-check`
|
||||
Run type checking on samples:
|
||||
```bash
|
||||
uv run poe samples-code-check
|
||||
```
|
||||
|
||||
### Comprehensive Checks
|
||||
|
||||
#### `check`
|
||||
Run all quality checks (format, lint, pyright, mypy, test, markdown lint, samples check):
|
||||
Run all quality checks (format, lint, pyright, mypy, test, markdown lint):
|
||||
```bash
|
||||
uv run poe check
|
||||
```
|
||||
|
||||
#### `pre-commit-check`
|
||||
Run pre-commit specific checks (all of the above, excluding `mypy`):
|
||||
### Testing
|
||||
|
||||
#### `test`
|
||||
Run unit tests with coverage by invoking the `test` task in each package sequentially:
|
||||
```bash
|
||||
uv run poe pre-commit-check
|
||||
uv run poe test
|
||||
```
|
||||
|
||||
### Building
|
||||
To run tests for a specific package only, use the `--directory` flag:
|
||||
```bash
|
||||
# Run tests for the core package
|
||||
uv run --directory packages/core poe test
|
||||
|
||||
# Run tests for the azure-ai package
|
||||
uv run --directory packages/azure-ai poe test
|
||||
```
|
||||
|
||||
#### `all-tests`
|
||||
Run all tests in a single pytest invocation across all packages in parallel (excluding lab and devui). This is faster than `test` as it uses pytest's parallel execution:
|
||||
```bash
|
||||
uv run poe all-tests
|
||||
```
|
||||
|
||||
#### `all-tests-cov`
|
||||
Same as `all-tests` but with coverage reporting enabled:
|
||||
```bash
|
||||
uv run poe all-tests-cov
|
||||
```
|
||||
|
||||
### Building and Publishing
|
||||
|
||||
#### `build`
|
||||
Build the package:
|
||||
Build all packages:
|
||||
```bash
|
||||
uv run poe build
|
||||
```
|
||||
|
||||
#### `clean-dist`
|
||||
Clean the dist directories:
|
||||
```bash
|
||||
uv run poe clean-dist
|
||||
```
|
||||
|
||||
#### `publish`
|
||||
Publish packages to PyPI:
|
||||
```bash
|
||||
uv run poe publish
|
||||
```
|
||||
|
||||
## Pre-commit Hooks
|
||||
|
||||
You can also run all checks using pre-commit directly:
|
||||
Pre-commit hooks run automatically on commit and execute a subset of the checks on changed files only. You can also run all checks using pre-commit directly:
|
||||
|
||||
```bash
|
||||
uv run pre-commit run -a
|
||||
|
||||
@@ -237,14 +237,14 @@ class A2AAgent(BaseAgent):
|
||||
An agent response item.
|
||||
"""
|
||||
messages = self._normalize_messages(messages)
|
||||
a2a_message = self._chat_message_to_a2a_message(messages[-1])
|
||||
a2a_message = self._prepare_message_for_a2a(messages[-1])
|
||||
|
||||
response_stream = self.client.send_message(a2a_message)
|
||||
|
||||
async for item in response_stream:
|
||||
if isinstance(item, Message):
|
||||
# Process A2A Message
|
||||
contents = self._a2a_parts_to_contents(item.parts)
|
||||
contents = self._parse_contents_from_a2a(item.parts)
|
||||
yield AgentRunResponseUpdate(
|
||||
contents=contents,
|
||||
role=Role.ASSISTANT if item.role == A2ARole.agent else Role.USER,
|
||||
@@ -255,7 +255,7 @@ class A2AAgent(BaseAgent):
|
||||
task, _update_event = item
|
||||
if isinstance(task, Task) and task.status.state in TERMINAL_TASK_STATES:
|
||||
# Convert Task artifacts to ChatMessages and yield as separate updates
|
||||
task_messages = self._task_to_chat_messages(task)
|
||||
task_messages = self._parse_messages_from_task(task)
|
||||
if task_messages:
|
||||
for message in task_messages:
|
||||
# Use the artifact's ID from raw_representation as message_id for unique identification
|
||||
@@ -280,8 +280,8 @@ class A2AAgent(BaseAgent):
|
||||
msg = f"Only Message and Task responses are supported from A2A agents. Received: {type(item)}"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
def _chat_message_to_a2a_message(self, message: ChatMessage) -> A2AMessage:
|
||||
"""Convert a ChatMessage to an A2A Message.
|
||||
def _prepare_message_for_a2a(self, message: ChatMessage) -> A2AMessage:
|
||||
"""Prepare a ChatMessage for the A2A protocol.
|
||||
|
||||
Transforms Agent Framework ChatMessage objects into A2A protocol Messages by:
|
||||
- Converting all message contents to appropriate A2A Part types
|
||||
@@ -361,8 +361,8 @@ class A2AAgent(BaseAgent):
|
||||
metadata=cast(dict[str, Any], message.additional_properties),
|
||||
)
|
||||
|
||||
def _a2a_parts_to_contents(self, parts: Sequence[A2APart]) -> list[Contents]:
|
||||
"""Convert A2A Parts to Agent Framework Contents.
|
||||
def _parse_contents_from_a2a(self, parts: Sequence[A2APart]) -> list[Contents]:
|
||||
"""Parse A2A Parts into Agent Framework Contents.
|
||||
|
||||
Transforms A2A protocol Parts into framework-native Content objects,
|
||||
handling text, file (URI/bytes), and data parts with metadata preservation.
|
||||
@@ -410,17 +410,17 @@ class A2AAgent(BaseAgent):
|
||||
raise ValueError(f"Unknown Part kind: {inner_part.kind}")
|
||||
return contents
|
||||
|
||||
def _task_to_chat_messages(self, task: Task) -> list[ChatMessage]:
|
||||
"""Convert A2A Task artifacts to ChatMessages with ASSISTANT role."""
|
||||
def _parse_messages_from_task(self, task: Task) -> list[ChatMessage]:
|
||||
"""Parse A2A Task artifacts into ChatMessages with ASSISTANT role."""
|
||||
messages: list[ChatMessage] = []
|
||||
|
||||
if task.artifacts is not None:
|
||||
for artifact in task.artifacts:
|
||||
messages.append(self._artifact_to_chat_message(artifact))
|
||||
messages.append(self._parse_message_from_artifact(artifact))
|
||||
elif task.history is not None and len(task.history) > 0:
|
||||
# Include the last history item as the agent response
|
||||
history_item = task.history[-1]
|
||||
contents = self._a2a_parts_to_contents(history_item.parts)
|
||||
contents = self._parse_contents_from_a2a(history_item.parts)
|
||||
messages.append(
|
||||
ChatMessage(
|
||||
role=Role.ASSISTANT if history_item.role == A2ARole.agent else Role.USER,
|
||||
@@ -431,9 +431,9 @@ class A2AAgent(BaseAgent):
|
||||
|
||||
return messages
|
||||
|
||||
def _artifact_to_chat_message(self, artifact: Artifact) -> ChatMessage:
|
||||
"""Convert A2A Artifact to ChatMessage using part contents."""
|
||||
contents = self._a2a_parts_to_contents(artifact.parts)
|
||||
def _parse_message_from_artifact(self, artifact: Artifact) -> ChatMessage:
|
||||
"""Parse A2A Artifact into ChatMessage using part contents."""
|
||||
contents = self._parse_contents_from_a2a(artifact.parts)
|
||||
return ChatMessage(
|
||||
role=Role.ASSISTANT,
|
||||
contents=contents,
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -197,18 +197,18 @@ async def test_run_with_unknown_response_type_raises_error(a2a_agent: A2AAgent,
|
||||
await a2a_agent.run("Test message")
|
||||
|
||||
|
||||
def test_task_to_chat_messages_empty_artifacts(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _task_to_chat_messages with task containing no artifacts."""
|
||||
def test_parse_messages_from_task_empty_artifacts(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _parse_messages_from_task with task containing no artifacts."""
|
||||
task = MagicMock()
|
||||
task.artifacts = None
|
||||
|
||||
result = a2a_agent._task_to_chat_messages(task)
|
||||
result = a2a_agent._parse_messages_from_task(task)
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_task_to_chat_messages_with_artifacts(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _task_to_chat_messages with task containing artifacts."""
|
||||
def test_parse_messages_from_task_with_artifacts(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _parse_messages_from_task with task containing artifacts."""
|
||||
task = MagicMock()
|
||||
|
||||
# Create mock artifacts
|
||||
@@ -232,7 +232,7 @@ def test_task_to_chat_messages_with_artifacts(a2a_agent: A2AAgent) -> None:
|
||||
|
||||
task.artifacts = [artifact1, artifact2]
|
||||
|
||||
result = a2a_agent._task_to_chat_messages(task)
|
||||
result = a2a_agent._parse_messages_from_task(task)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].text == "Content 1"
|
||||
@@ -240,8 +240,8 @@ def test_task_to_chat_messages_with_artifacts(a2a_agent: A2AAgent) -> None:
|
||||
assert all(msg.role == Role.ASSISTANT for msg in result)
|
||||
|
||||
|
||||
def test_artifact_to_chat_message(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _artifact_to_chat_message conversion."""
|
||||
def test_parse_message_from_artifact(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _parse_message_from_artifact conversion."""
|
||||
artifact = MagicMock()
|
||||
artifact.artifact_id = "test-artifact"
|
||||
|
||||
@@ -253,7 +253,7 @@ def test_artifact_to_chat_message(a2a_agent: A2AAgent) -> None:
|
||||
|
||||
artifact.parts = [text_part]
|
||||
|
||||
result = a2a_agent._artifact_to_chat_message(artifact)
|
||||
result = a2a_agent._parse_message_from_artifact(artifact)
|
||||
|
||||
assert isinstance(result, ChatMessage)
|
||||
assert result.role == Role.ASSISTANT
|
||||
@@ -276,7 +276,7 @@ def test_get_uri_data_invalid_uri() -> None:
|
||||
_get_uri_data("not-a-valid-data-uri")
|
||||
|
||||
|
||||
def test_a2a_parts_to_contents_conversion(a2a_agent: A2AAgent) -> None:
|
||||
def test_parse_contents_from_a2a_conversion(a2a_agent: A2AAgent) -> None:
|
||||
"""Test A2A parts to contents conversion."""
|
||||
|
||||
agent = A2AAgent(name="Test Agent", client=MockA2AClient(), _http_client=None)
|
||||
@@ -285,7 +285,7 @@ def test_a2a_parts_to_contents_conversion(a2a_agent: A2AAgent) -> None:
|
||||
parts = [Part(root=TextPart(text="First part")), Part(root=TextPart(text="Second part"))]
|
||||
|
||||
# Convert to contents
|
||||
contents = agent._a2a_parts_to_contents(parts)
|
||||
contents = agent._parse_contents_from_a2a(parts)
|
||||
|
||||
# Verify conversion
|
||||
assert len(contents) == 2
|
||||
@@ -295,30 +295,30 @@ def test_a2a_parts_to_contents_conversion(a2a_agent: A2AAgent) -> None:
|
||||
assert contents[1].text == "Second part"
|
||||
|
||||
|
||||
def test_chat_message_to_a2a_message_with_error_content(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _chat_message_to_a2a_message with ErrorContent."""
|
||||
def test_prepare_message_for_a2a_with_error_content(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _prepare_message_for_a2a with ErrorContent."""
|
||||
|
||||
# Create ChatMessage with ErrorContent
|
||||
error_content = ErrorContent(message="Test error message")
|
||||
message = ChatMessage(role=Role.USER, contents=[error_content])
|
||||
|
||||
# Convert to A2A message
|
||||
a2a_message = a2a_agent._chat_message_to_a2a_message(message)
|
||||
a2a_message = a2a_agent._prepare_message_for_a2a(message)
|
||||
|
||||
# Verify conversion
|
||||
assert len(a2a_message.parts) == 1
|
||||
assert a2a_message.parts[0].root.text == "Test error message"
|
||||
|
||||
|
||||
def test_chat_message_to_a2a_message_with_uri_content(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _chat_message_to_a2a_message with UriContent."""
|
||||
def test_prepare_message_for_a2a_with_uri_content(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _prepare_message_for_a2a with UriContent."""
|
||||
|
||||
# Create ChatMessage with UriContent
|
||||
uri_content = UriContent(uri="http://example.com/file.pdf", media_type="application/pdf")
|
||||
message = ChatMessage(role=Role.USER, contents=[uri_content])
|
||||
|
||||
# Convert to A2A message
|
||||
a2a_message = a2a_agent._chat_message_to_a2a_message(message)
|
||||
a2a_message = a2a_agent._prepare_message_for_a2a(message)
|
||||
|
||||
# Verify conversion
|
||||
assert len(a2a_message.parts) == 1
|
||||
@@ -326,15 +326,15 @@ def test_chat_message_to_a2a_message_with_uri_content(a2a_agent: A2AAgent) -> No
|
||||
assert a2a_message.parts[0].root.file.mime_type == "application/pdf"
|
||||
|
||||
|
||||
def test_chat_message_to_a2a_message_with_data_content(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _chat_message_to_a2a_message with DataContent."""
|
||||
def test_prepare_message_for_a2a_with_data_content(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _prepare_message_for_a2a with DataContent."""
|
||||
|
||||
# Create ChatMessage with DataContent (base64 data URI)
|
||||
data_content = DataContent(uri="data:text/plain;base64,SGVsbG8gV29ybGQ=", media_type="text/plain")
|
||||
message = ChatMessage(role=Role.USER, contents=[data_content])
|
||||
|
||||
# Convert to A2A message
|
||||
a2a_message = a2a_agent._chat_message_to_a2a_message(message)
|
||||
a2a_message = a2a_agent._prepare_message_for_a2a(message)
|
||||
|
||||
# Verify conversion
|
||||
assert len(a2a_message.parts) == 1
|
||||
@@ -342,14 +342,14 @@ def test_chat_message_to_a2a_message_with_data_content(a2a_agent: A2AAgent) -> N
|
||||
assert a2a_message.parts[0].root.file.mime_type == "text/plain"
|
||||
|
||||
|
||||
def test_chat_message_to_a2a_message_empty_contents_raises_error(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _chat_message_to_a2a_message with empty contents raises ValueError."""
|
||||
def test_prepare_message_for_a2a_empty_contents_raises_error(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _prepare_message_for_a2a with empty contents raises ValueError."""
|
||||
# Create ChatMessage with no contents
|
||||
message = ChatMessage(role=Role.USER, contents=[])
|
||||
|
||||
# Should raise ValueError for empty contents
|
||||
with raises(ValueError, match="ChatMessage.contents is empty"):
|
||||
a2a_agent._chat_message_to_a2a_message(message)
|
||||
a2a_agent._prepare_message_for_a2a(message)
|
||||
|
||||
|
||||
async def test_run_stream_with_message_response(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
@@ -405,7 +405,7 @@ async def test_context_manager_no_cleanup_when_no_http_client() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_chat_message_to_a2a_message_with_multiple_contents() -> None:
|
||||
def test_prepare_message_for_a2a_with_multiple_contents() -> None:
|
||||
"""Test conversion of ChatMessage with multiple contents."""
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), _http_client=None)
|
||||
@@ -421,7 +421,7 @@ def test_chat_message_to_a2a_message_with_multiple_contents() -> None:
|
||||
],
|
||||
)
|
||||
|
||||
result = agent._chat_message_to_a2a_message(message)
|
||||
result = agent._prepare_message_for_a2a(message)
|
||||
|
||||
# Should have converted all 4 contents to parts
|
||||
assert len(result.parts) == 4
|
||||
@@ -433,7 +433,7 @@ def test_chat_message_to_a2a_message_with_multiple_contents() -> None:
|
||||
assert result.parts[3].root.kind == "text" # JSON text remains as text (no parsing)
|
||||
|
||||
|
||||
def test_a2a_parts_to_contents_with_data_part() -> None:
|
||||
def test_parse_contents_from_a2a_with_data_part() -> None:
|
||||
"""Test conversion of A2A DataPart."""
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), _http_client=None)
|
||||
@@ -441,7 +441,7 @@ def test_a2a_parts_to_contents_with_data_part() -> None:
|
||||
# Create DataPart
|
||||
data_part = Part(root=DataPart(data={"key": "value", "number": 42}, metadata={"source": "test"}))
|
||||
|
||||
contents = agent._a2a_parts_to_contents([data_part])
|
||||
contents = agent._parse_contents_from_a2a([data_part])
|
||||
|
||||
assert len(contents) == 1
|
||||
|
||||
@@ -450,7 +450,7 @@ def test_a2a_parts_to_contents_with_data_part() -> None:
|
||||
assert contents[0].additional_properties == {"source": "test"}
|
||||
|
||||
|
||||
def test_a2a_parts_to_contents_unknown_part_kind() -> None:
|
||||
def test_parse_contents_from_a2a_unknown_part_kind() -> None:
|
||||
"""Test error handling for unknown A2A part kind."""
|
||||
agent = A2AAgent(client=MagicMock(), _http_client=None)
|
||||
|
||||
@@ -459,10 +459,10 @@ def test_a2a_parts_to_contents_unknown_part_kind() -> None:
|
||||
mock_part.root.kind = "unknown_kind"
|
||||
|
||||
with raises(ValueError, match="Unknown Part kind: unknown_kind"):
|
||||
agent._a2a_parts_to_contents([mock_part])
|
||||
agent._parse_contents_from_a2a([mock_part])
|
||||
|
||||
|
||||
def test_chat_message_to_a2a_message_with_hosted_file() -> None:
|
||||
def test_prepare_message_for_a2a_with_hosted_file() -> None:
|
||||
"""Test conversion of ChatMessage with HostedFileContent to A2A message."""
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), _http_client=None)
|
||||
@@ -473,7 +473,7 @@ def test_chat_message_to_a2a_message_with_hosted_file() -> None:
|
||||
contents=[HostedFileContent(file_id="hosted://storage/document.pdf")],
|
||||
)
|
||||
|
||||
result = agent._chat_message_to_a2a_message(message) # noqa: SLF001
|
||||
result = agent._prepare_message_for_a2a(message) # noqa: SLF001
|
||||
|
||||
# Verify the conversion
|
||||
assert len(result.parts) == 1
|
||||
@@ -488,7 +488,7 @@ def test_chat_message_to_a2a_message_with_hosted_file() -> None:
|
||||
assert part.root.file.mime_type is None # HostedFileContent doesn't specify media_type
|
||||
|
||||
|
||||
def test_a2a_parts_to_contents_with_hosted_file_uri() -> None:
|
||||
def test_parse_contents_from_a2a_with_hosted_file_uri() -> None:
|
||||
"""Test conversion of A2A FilePart with hosted file URI back to UriContent."""
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), _http_client=None)
|
||||
@@ -503,7 +503,7 @@ def test_a2a_parts_to_contents_with_hosted_file_uri() -> None:
|
||||
)
|
||||
)
|
||||
|
||||
contents = agent._a2a_parts_to_contents([file_part]) # noqa: SLF001
|
||||
contents = agent._parse_contents_from_a2a([file_part]) # noqa: SLF001
|
||||
|
||||
assert len(contents) == 1
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ class ExecutionContext:
|
||||
def run_id(self) -> str:
|
||||
"""Get or generate run ID."""
|
||||
if self._run_id is None:
|
||||
self._run_id = self.input_data.get("run_id") or str(uuid.uuid4())
|
||||
self._run_id = self.input_data.get("run_id") or self.input_data.get("runId") or str(uuid.uuid4())
|
||||
# This should never be None after the if block above, but satisfy type checkers
|
||||
if self._run_id is None: # pragma: no cover
|
||||
raise RuntimeError("Failed to initialize run_id")
|
||||
@@ -96,7 +96,7 @@ class ExecutionContext:
|
||||
def thread_id(self) -> str:
|
||||
"""Get or generate thread ID."""
|
||||
if self._thread_id is None:
|
||||
self._thread_id = self.input_data.get("thread_id") or str(uuid.uuid4())
|
||||
self._thread_id = self.input_data.get("thread_id") or self.input_data.get("threadId") or str(uuid.uuid4())
|
||||
# This should never be None after the if block above, but satisfy type checkers
|
||||
if self._thread_id is None: # pragma: no cover
|
||||
raise RuntimeError("Failed to initialize thread_id")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
|
||||
@@ -83,3 +83,71 @@ async def test_default_orchestrator_merges_client_tools() -> None:
|
||||
assert "server_tool" in tool_names
|
||||
assert "get_weather" in tool_names
|
||||
assert agent.chat_client.function_invocation_configuration.additional_tools
|
||||
|
||||
|
||||
async def test_default_orchestrator_with_camel_case_ids() -> None:
|
||||
"""Client tool is able to extract camelCase IDs."""
|
||||
|
||||
agent = DummyAgent()
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data = {
|
||||
"runId": "test-camelcase-runid",
|
||||
"threadId": "test-camelcase-threadid",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "Hello"}],
|
||||
}
|
||||
],
|
||||
"tools": [],
|
||||
}
|
||||
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# assert the last event has the expected run_id and thread_id
|
||||
last_event = events[-1]
|
||||
assert last_event.run_id == "test-camelcase-runid"
|
||||
assert last_event.thread_id == "test-camelcase-threadid"
|
||||
|
||||
|
||||
async def test_default_orchestrator_with_snake_case_ids() -> None:
|
||||
"""Client tool is able to extract snake_case IDs."""
|
||||
|
||||
agent = DummyAgent()
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data = {
|
||||
"run_id": "test-snakecase-runid",
|
||||
"thread_id": "test-snakecase-threadid",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "Hello"}],
|
||||
}
|
||||
],
|
||||
"tools": [],
|
||||
}
|
||||
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# assert the last event has the expected run_id and thread_id
|
||||
last_event = events[-1]
|
||||
assert last_event.run_id == "test-snakecase-runid"
|
||||
assert last_event.thread_id == "test-snakecase-threadid"
|
||||
|
||||
@@ -25,7 +25,6 @@ from agent_framework import (
|
||||
TextContent,
|
||||
TextReasoningContent,
|
||||
TextSpanRegion,
|
||||
ToolProtocol,
|
||||
UsageContent,
|
||||
UsageDetails,
|
||||
get_logger,
|
||||
@@ -214,9 +213,11 @@ class AnthropicClient(BaseChatClient):
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> ChatResponse:
|
||||
# Extract necessary state from messages and options
|
||||
run_options = self._create_run_options(messages, chat_options, **kwargs)
|
||||
# prepare
|
||||
run_options = self._prepare_options(messages, chat_options, **kwargs)
|
||||
# execute
|
||||
message = await self.anthropic_client.beta.messages.create(**run_options, stream=False)
|
||||
# process
|
||||
return self._process_message(message)
|
||||
|
||||
async def _inner_get_streaming_response(
|
||||
@@ -226,16 +227,17 @@ class AnthropicClient(BaseChatClient):
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[ChatResponseUpdate]:
|
||||
# Extract necessary state from messages and options
|
||||
run_options = self._create_run_options(messages, chat_options, **kwargs)
|
||||
# prepare
|
||||
run_options = self._prepare_options(messages, chat_options, **kwargs)
|
||||
# execute and process
|
||||
async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True):
|
||||
parsed_chunk = self._process_stream_event(chunk)
|
||||
if parsed_chunk:
|
||||
yield parsed_chunk
|
||||
|
||||
# region Create Run Options and Helpers
|
||||
# region Prep methods
|
||||
|
||||
def _create_run_options(
|
||||
def _prepare_options(
|
||||
self,
|
||||
messages: MutableSequence[ChatMessage],
|
||||
chat_options: ChatOptions,
|
||||
@@ -251,78 +253,91 @@ class AnthropicClient(BaseChatClient):
|
||||
Returns:
|
||||
A dictionary of run options for the Anthropic client.
|
||||
"""
|
||||
if chat_options.additional_properties and "additional_beta_flags" in chat_options.additional_properties:
|
||||
betas = chat_options.additional_properties.pop("additional_beta_flags")
|
||||
else:
|
||||
betas = []
|
||||
run_options: dict[str, Any] = {
|
||||
"model": chat_options.model_id or self.model_id,
|
||||
"messages": self._convert_messages_to_anthropic_format(messages),
|
||||
"max_tokens": chat_options.max_tokens or ANTHROPIC_DEFAULT_MAX_TOKENS,
|
||||
"extra_headers": {"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
|
||||
"betas": {*BETA_FLAGS, *self.additional_beta_flags, *betas},
|
||||
}
|
||||
run_options: dict[str, Any] = chat_options.to_dict(
|
||||
exclude={
|
||||
"type",
|
||||
"instructions", # handled via system message
|
||||
"tool_choice", # handled separately
|
||||
"allow_multiple_tool_calls", # handled via tool_choice
|
||||
"additional_properties", # handled separately
|
||||
}
|
||||
)
|
||||
|
||||
# Add any additional options from chat_options or kwargs
|
||||
if chat_options.temperature is not None:
|
||||
run_options["temperature"] = chat_options.temperature
|
||||
if chat_options.top_p is not None:
|
||||
run_options["top_p"] = chat_options.top_p
|
||||
if chat_options.stop is not None:
|
||||
run_options["stop_sequences"] = chat_options.stop
|
||||
# translations between ChatOptions and Anthropic API
|
||||
translations = {
|
||||
"model_id": "model",
|
||||
"stop": "stop_sequences",
|
||||
}
|
||||
for old_key, new_key in translations.items():
|
||||
if old_key in run_options and old_key != new_key:
|
||||
run_options[new_key] = run_options.pop(old_key)
|
||||
|
||||
# model id
|
||||
if not run_options.get("model"):
|
||||
if not self.model_id:
|
||||
raise ValueError("model_id must be a non-empty string")
|
||||
run_options["model"] = self.model_id
|
||||
|
||||
# max_tokens - Anthropic requires this, default if not provided
|
||||
if not run_options.get("max_tokens"):
|
||||
run_options["max_tokens"] = ANTHROPIC_DEFAULT_MAX_TOKENS
|
||||
|
||||
# messages
|
||||
run_options["messages"] = self._prepare_messages_for_anthropic(messages)
|
||||
|
||||
# system message - first system message is passed as instructions
|
||||
if messages and isinstance(messages[0], ChatMessage) and messages[0].role == Role.SYSTEM:
|
||||
# first system message is passed as instructions
|
||||
run_options["system"] = messages[0].text
|
||||
if chat_options.tool_choice is not None:
|
||||
match (
|
||||
chat_options.tool_choice if isinstance(chat_options.tool_choice, str) else chat_options.tool_choice.mode
|
||||
):
|
||||
case "auto":
|
||||
run_options["tool_choice"] = {"type": "auto"}
|
||||
if chat_options.allow_multiple_tool_calls is not None:
|
||||
run_options["tool_choice"][ # type:ignore[reportArgumentType]
|
||||
"disable_parallel_tool_use"
|
||||
] = not chat_options.allow_multiple_tool_calls
|
||||
case "required":
|
||||
if chat_options.tool_choice.required_function_name:
|
||||
run_options["tool_choice"] = {
|
||||
"type": "tool",
|
||||
"name": chat_options.tool_choice.required_function_name,
|
||||
}
|
||||
if chat_options.allow_multiple_tool_calls is not None:
|
||||
run_options["tool_choice"][ # type:ignore[reportArgumentType]
|
||||
"disable_parallel_tool_use"
|
||||
] = not chat_options.allow_multiple_tool_calls
|
||||
else:
|
||||
run_options["tool_choice"] = {"type": "any"}
|
||||
if chat_options.allow_multiple_tool_calls is not None:
|
||||
run_options["tool_choice"][ # type:ignore[reportArgumentType]
|
||||
"disable_parallel_tool_use"
|
||||
] = not chat_options.allow_multiple_tool_calls
|
||||
case "none":
|
||||
run_options["tool_choice"] = {"type": "none"}
|
||||
case _:
|
||||
logger.debug(f"Ignoring unsupported tool choice mode: {chat_options.tool_choice.mode} for now")
|
||||
if tools_and_mcp := self._convert_tools_to_anthropic_format(chat_options.tools):
|
||||
run_options.update(tools_and_mcp)
|
||||
if chat_options.additional_properties:
|
||||
run_options.update(chat_options.additional_properties)
|
||||
|
||||
# betas
|
||||
run_options["betas"] = self._prepare_betas(chat_options)
|
||||
|
||||
# extra headers
|
||||
run_options["extra_headers"] = {"User-Agent": AGENT_FRAMEWORK_USER_AGENT}
|
||||
|
||||
# tools, mcp servers and tool choice
|
||||
if tools_config := self._prepare_tools_for_anthropic(chat_options):
|
||||
run_options.update(tools_config)
|
||||
|
||||
# additional properties
|
||||
additional_options = {
|
||||
key: value
|
||||
for key, value in chat_options.additional_properties.items()
|
||||
if value is not None and key != "additional_beta_flags"
|
||||
}
|
||||
if additional_options:
|
||||
run_options.update(additional_options)
|
||||
run_options.update(kwargs)
|
||||
return run_options
|
||||
|
||||
def _convert_messages_to_anthropic_format(self, messages: MutableSequence[ChatMessage]) -> list[dict[str, Any]]:
|
||||
"""Convert a list of ChatMessages to the format expected by the Anthropic client.
|
||||
def _prepare_betas(self, chat_options: ChatOptions) -> set[str]:
|
||||
"""Prepare the beta flags for the Anthropic API request.
|
||||
|
||||
Args:
|
||||
chat_options: The chat options that may contain additional beta flags.
|
||||
|
||||
Returns:
|
||||
A set of beta flag strings to include in the request.
|
||||
"""
|
||||
return {
|
||||
*BETA_FLAGS,
|
||||
*self.additional_beta_flags,
|
||||
*chat_options.additional_properties.get("additional_beta_flags", []),
|
||||
}
|
||||
|
||||
def _prepare_messages_for_anthropic(self, messages: MutableSequence[ChatMessage]) -> list[dict[str, Any]]:
|
||||
"""Prepare a list of ChatMessages for the Anthropic client.
|
||||
|
||||
This skips the first message if it is a system message,
|
||||
as Anthropic expects system instructions as a separate parameter.
|
||||
"""
|
||||
# first system message is passed as instructions
|
||||
if messages and isinstance(messages[0], ChatMessage) and messages[0].role == Role.SYSTEM:
|
||||
return [self._convert_message_to_anthropic_format(msg) for msg in messages[1:]]
|
||||
return [self._convert_message_to_anthropic_format(msg) for msg in messages]
|
||||
return [self._prepare_message_for_anthropic(msg) for msg in messages[1:]]
|
||||
return [self._prepare_message_for_anthropic(msg) for msg in messages]
|
||||
|
||||
def _convert_message_to_anthropic_format(self, message: ChatMessage) -> dict[str, Any]:
|
||||
"""Convert a ChatMessage to the format expected by the Anthropic client.
|
||||
def _prepare_message_for_anthropic(self, message: ChatMessage) -> dict[str, Any]:
|
||||
"""Prepare a ChatMessage for the Anthropic client.
|
||||
|
||||
Args:
|
||||
message: The ChatMessage to convert.
|
||||
@@ -376,58 +391,96 @@ class AnthropicClient(BaseChatClient):
|
||||
"content": a_content,
|
||||
}
|
||||
|
||||
def _convert_tools_to_anthropic_format(
|
||||
self, tools: list[ToolProtocol | MutableMapping[str, Any]] | None
|
||||
) -> dict[str, Any] | None:
|
||||
if not tools:
|
||||
return None
|
||||
tool_list: list[MutableMapping[str, Any]] = []
|
||||
mcp_server_list: list[MutableMapping[str, Any]] = []
|
||||
for tool in tools:
|
||||
match tool:
|
||||
case MutableMapping():
|
||||
tool_list.append(tool)
|
||||
case AIFunction():
|
||||
tool_list.append({
|
||||
"type": "custom",
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"input_schema": tool.parameters(),
|
||||
})
|
||||
case HostedWebSearchTool():
|
||||
search_tool: dict[str, Any] = {
|
||||
"type": "web_search_20250305",
|
||||
"name": "web_search",
|
||||
}
|
||||
if tool.additional_properties:
|
||||
search_tool.update(tool.additional_properties)
|
||||
tool_list.append(search_tool)
|
||||
case HostedCodeInterpreterTool():
|
||||
code_tool: dict[str, Any] = {
|
||||
"type": "code_execution_20250825",
|
||||
"name": "code_execution",
|
||||
}
|
||||
tool_list.append(code_tool)
|
||||
case HostedMCPTool():
|
||||
server_def: dict[str, Any] = {
|
||||
"type": "url",
|
||||
"name": tool.name,
|
||||
"url": str(tool.url),
|
||||
}
|
||||
if tool.allowed_tools:
|
||||
server_def["tool_configuration"] = {"allowed_tools": list(tool.allowed_tools)}
|
||||
if tool.headers and (auth := tool.headers.get("authorization")):
|
||||
server_def["authorization_token"] = auth
|
||||
mcp_server_list.append(server_def)
|
||||
case _:
|
||||
logger.debug(f"Ignoring unsupported tool type: {type(tool)} for now")
|
||||
def _prepare_tools_for_anthropic(self, chat_options: ChatOptions) -> dict[str, Any] | None:
|
||||
"""Prepare tools and tool choice configuration for the Anthropic API request.
|
||||
|
||||
all_tools: dict[str, list[MutableMapping[str, Any]]] = {}
|
||||
if tool_list:
|
||||
all_tools["tools"] = tool_list
|
||||
if mcp_server_list:
|
||||
all_tools["mcp_servers"] = mcp_server_list
|
||||
return all_tools
|
||||
Args:
|
||||
chat_options: The chat options containing tools and tool choice settings.
|
||||
|
||||
Returns:
|
||||
A dictionary with tools, mcp_servers, and tool_choice configuration, or None if empty.
|
||||
"""
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
# Process tools
|
||||
if chat_options.tools:
|
||||
tool_list: list[MutableMapping[str, Any]] = []
|
||||
mcp_server_list: list[MutableMapping[str, Any]] = []
|
||||
for tool in chat_options.tools:
|
||||
match tool:
|
||||
case MutableMapping():
|
||||
tool_list.append(tool)
|
||||
case AIFunction():
|
||||
tool_list.append({
|
||||
"type": "custom",
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"input_schema": tool.parameters(),
|
||||
})
|
||||
case HostedWebSearchTool():
|
||||
search_tool: dict[str, Any] = {
|
||||
"type": "web_search_20250305",
|
||||
"name": "web_search",
|
||||
}
|
||||
if tool.additional_properties:
|
||||
search_tool.update(tool.additional_properties)
|
||||
tool_list.append(search_tool)
|
||||
case HostedCodeInterpreterTool():
|
||||
code_tool: dict[str, Any] = {
|
||||
"type": "code_execution_20250825",
|
||||
"name": "code_execution",
|
||||
}
|
||||
tool_list.append(code_tool)
|
||||
case HostedMCPTool():
|
||||
server_def: dict[str, Any] = {
|
||||
"type": "url",
|
||||
"name": tool.name,
|
||||
"url": str(tool.url),
|
||||
}
|
||||
if tool.allowed_tools:
|
||||
server_def["tool_configuration"] = {"allowed_tools": list(tool.allowed_tools)}
|
||||
if tool.headers and (auth := tool.headers.get("authorization")):
|
||||
server_def["authorization_token"] = auth
|
||||
mcp_server_list.append(server_def)
|
||||
case _:
|
||||
logger.debug(f"Ignoring unsupported tool type: {type(tool)} for now")
|
||||
|
||||
if tool_list:
|
||||
result["tools"] = tool_list
|
||||
if mcp_server_list:
|
||||
result["mcp_servers"] = mcp_server_list
|
||||
|
||||
# Process tool choice
|
||||
if chat_options.tool_choice is not None:
|
||||
tool_choice_mode = (
|
||||
chat_options.tool_choice if isinstance(chat_options.tool_choice, str) else chat_options.tool_choice.mode
|
||||
)
|
||||
match tool_choice_mode:
|
||||
case "auto":
|
||||
tool_choice: dict[str, Any] = {"type": "auto"}
|
||||
if chat_options.allow_multiple_tool_calls is not None:
|
||||
tool_choice["disable_parallel_tool_use"] = not chat_options.allow_multiple_tool_calls
|
||||
result["tool_choice"] = tool_choice
|
||||
case "required":
|
||||
if (
|
||||
not isinstance(chat_options.tool_choice, str)
|
||||
and chat_options.tool_choice.required_function_name
|
||||
):
|
||||
tool_choice = {
|
||||
"type": "tool",
|
||||
"name": chat_options.tool_choice.required_function_name,
|
||||
}
|
||||
else:
|
||||
tool_choice = {"type": "any"}
|
||||
if chat_options.allow_multiple_tool_calls is not None:
|
||||
tool_choice["disable_parallel_tool_use"] = not chat_options.allow_multiple_tool_calls
|
||||
result["tool_choice"] = tool_choice
|
||||
case "none":
|
||||
result["tool_choice"] = {"type": "none"}
|
||||
case _:
|
||||
logger.debug(f"Ignoring unsupported tool choice mode: {tool_choice_mode} for now")
|
||||
|
||||
return result or None
|
||||
|
||||
# region Response Processing Methods
|
||||
|
||||
@@ -445,11 +498,11 @@ class AnthropicClient(BaseChatClient):
|
||||
messages=[
|
||||
ChatMessage(
|
||||
role=Role.ASSISTANT,
|
||||
contents=self._parse_message_contents(message.content),
|
||||
contents=self._parse_contents_from_anthropic(message.content),
|
||||
raw_representation=message,
|
||||
)
|
||||
],
|
||||
usage_details=self._parse_message_usage(message.usage),
|
||||
usage_details=self._parse_usage_from_anthropic(message.usage),
|
||||
model_id=message.model,
|
||||
finish_reason=FINISH_REASON_MAP.get(message.stop_reason) if message.stop_reason else None,
|
||||
raw_response=message,
|
||||
@@ -467,12 +520,12 @@ class AnthropicClient(BaseChatClient):
|
||||
match event.type:
|
||||
case "message_start":
|
||||
usage_details: list[UsageContent] = []
|
||||
if event.message.usage and (details := self._parse_message_usage(event.message.usage)):
|
||||
if event.message.usage and (details := self._parse_usage_from_anthropic(event.message.usage)):
|
||||
usage_details.append(UsageContent(details=details))
|
||||
|
||||
return ChatResponseUpdate(
|
||||
response_id=event.message.id,
|
||||
contents=[*self._parse_message_contents(event.message.content), *usage_details],
|
||||
contents=[*self._parse_contents_from_anthropic(event.message.content), *usage_details],
|
||||
model_id=event.message.model,
|
||||
finish_reason=FINISH_REASON_MAP.get(event.message.stop_reason)
|
||||
if event.message.stop_reason
|
||||
@@ -480,7 +533,7 @@ class AnthropicClient(BaseChatClient):
|
||||
raw_response=event,
|
||||
)
|
||||
case "message_delta":
|
||||
usage = self._parse_message_usage(event.usage)
|
||||
usage = self._parse_usage_from_anthropic(event.usage)
|
||||
return ChatResponseUpdate(
|
||||
contents=[UsageContent(details=usage, raw_representation=event.usage)] if usage else [],
|
||||
raw_response=event,
|
||||
@@ -488,13 +541,13 @@ class AnthropicClient(BaseChatClient):
|
||||
case "message_stop":
|
||||
logger.debug("Received message_stop event; no content to process.")
|
||||
case "content_block_start":
|
||||
contents = self._parse_message_contents([event.content_block])
|
||||
contents = self._parse_contents_from_anthropic([event.content_block])
|
||||
return ChatResponseUpdate(
|
||||
contents=contents,
|
||||
raw_response=event,
|
||||
)
|
||||
case "content_block_delta":
|
||||
contents = self._parse_message_contents([event.delta])
|
||||
contents = self._parse_contents_from_anthropic([event.delta])
|
||||
return ChatResponseUpdate(
|
||||
contents=contents,
|
||||
raw_response=event,
|
||||
@@ -505,7 +558,7 @@ class AnthropicClient(BaseChatClient):
|
||||
logger.debug(f"Ignoring unsupported event type: {event.type}")
|
||||
return None
|
||||
|
||||
def _parse_message_usage(self, usage: BetaUsage | BetaMessageDeltaUsage | None) -> UsageDetails | None:
|
||||
def _parse_usage_from_anthropic(self, usage: BetaUsage | BetaMessageDeltaUsage | None) -> UsageDetails | None:
|
||||
"""Parse usage details from the Anthropic message usage."""
|
||||
if not usage:
|
||||
return None
|
||||
@@ -518,7 +571,7 @@ class AnthropicClient(BaseChatClient):
|
||||
usage_details.additional_counts["anthropic.cache_read_input_tokens"] = usage.cache_read_input_tokens
|
||||
return usage_details
|
||||
|
||||
def _parse_message_contents(
|
||||
def _parse_contents_from_anthropic(
|
||||
self, content: Sequence[BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock]
|
||||
) -> list[Contents]:
|
||||
"""Parse contents from the Anthropic message."""
|
||||
@@ -530,7 +583,7 @@ class AnthropicClient(BaseChatClient):
|
||||
TextContent(
|
||||
text=content_block.text,
|
||||
raw_representation=content_block,
|
||||
annotations=self._parse_citations(content_block),
|
||||
annotations=self._parse_citations_from_anthropic(content_block),
|
||||
)
|
||||
)
|
||||
case "tool_use" | "mcp_tool_use" | "server_tool_use":
|
||||
@@ -549,7 +602,7 @@ class AnthropicClient(BaseChatClient):
|
||||
FunctionResultContent(
|
||||
call_id=content_block.tool_use_id,
|
||||
name=name if name and call_id == content_block.tool_use_id else "mcp_tool",
|
||||
result=self._parse_message_contents(content_block.content)
|
||||
result=self._parse_contents_from_anthropic(content_block.content)
|
||||
if isinstance(content_block.content, list)
|
||||
else content_block.content,
|
||||
raw_representation=content_block,
|
||||
@@ -608,7 +661,7 @@ class AnthropicClient(BaseChatClient):
|
||||
logger.debug(f"Ignoring unsupported content type: {content_block.type} for now")
|
||||
return contents
|
||||
|
||||
def _parse_citations(
|
||||
def _parse_citations_from_anthropic(
|
||||
self, content_block: BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock
|
||||
) -> list[Annotations] | None:
|
||||
content_citations = getattr(content_block, "citations", None)
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -151,12 +151,12 @@ def test_anthropic_client_service_url(mock_anthropic_client: MagicMock) -> None:
|
||||
# Message Conversion Tests
|
||||
|
||||
|
||||
def test_convert_message_to_anthropic_format_text(mock_anthropic_client: MagicMock) -> None:
|
||||
def test_prepare_message_for_anthropic_text(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test converting text message to Anthropic format."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
message = ChatMessage(role=Role.USER, text="Hello, world!")
|
||||
|
||||
result = chat_client._convert_message_to_anthropic_format(message)
|
||||
result = chat_client._prepare_message_for_anthropic(message)
|
||||
|
||||
assert result["role"] == "user"
|
||||
assert len(result["content"]) == 1
|
||||
@@ -164,7 +164,7 @@ def test_convert_message_to_anthropic_format_text(mock_anthropic_client: MagicMo
|
||||
assert result["content"][0]["text"] == "Hello, world!"
|
||||
|
||||
|
||||
def test_convert_message_to_anthropic_format_function_call(mock_anthropic_client: MagicMock) -> None:
|
||||
def test_prepare_message_for_anthropic_function_call(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test converting function call message to Anthropic format."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
message = ChatMessage(
|
||||
@@ -178,7 +178,7 @@ def test_convert_message_to_anthropic_format_function_call(mock_anthropic_client
|
||||
],
|
||||
)
|
||||
|
||||
result = chat_client._convert_message_to_anthropic_format(message)
|
||||
result = chat_client._prepare_message_for_anthropic(message)
|
||||
|
||||
assert result["role"] == "assistant"
|
||||
assert len(result["content"]) == 1
|
||||
@@ -188,7 +188,7 @@ def test_convert_message_to_anthropic_format_function_call(mock_anthropic_client
|
||||
assert result["content"][0]["input"] == {"location": "San Francisco"}
|
||||
|
||||
|
||||
def test_convert_message_to_anthropic_format_function_result(mock_anthropic_client: MagicMock) -> None:
|
||||
def test_prepare_message_for_anthropic_function_result(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test converting function result message to Anthropic format."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
message = ChatMessage(
|
||||
@@ -202,7 +202,7 @@ def test_convert_message_to_anthropic_format_function_result(mock_anthropic_clie
|
||||
],
|
||||
)
|
||||
|
||||
result = chat_client._convert_message_to_anthropic_format(message)
|
||||
result = chat_client._prepare_message_for_anthropic(message)
|
||||
|
||||
assert result["role"] == "user"
|
||||
assert len(result["content"]) == 1
|
||||
@@ -214,7 +214,7 @@ def test_convert_message_to_anthropic_format_function_result(mock_anthropic_clie
|
||||
assert result["content"][0]["is_error"] is False
|
||||
|
||||
|
||||
def test_convert_message_to_anthropic_format_text_reasoning(mock_anthropic_client: MagicMock) -> None:
|
||||
def test_prepare_message_for_anthropic_text_reasoning(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test converting text reasoning message to Anthropic format."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
message = ChatMessage(
|
||||
@@ -222,7 +222,7 @@ def test_convert_message_to_anthropic_format_text_reasoning(mock_anthropic_clien
|
||||
contents=[TextReasoningContent(text="Let me think about this...")],
|
||||
)
|
||||
|
||||
result = chat_client._convert_message_to_anthropic_format(message)
|
||||
result = chat_client._prepare_message_for_anthropic(message)
|
||||
|
||||
assert result["role"] == "assistant"
|
||||
assert len(result["content"]) == 1
|
||||
@@ -230,7 +230,7 @@ def test_convert_message_to_anthropic_format_text_reasoning(mock_anthropic_clien
|
||||
assert result["content"][0]["thinking"] == "Let me think about this..."
|
||||
|
||||
|
||||
def test_convert_messages_to_anthropic_format_with_system(mock_anthropic_client: MagicMock) -> None:
|
||||
def test_prepare_messages_for_anthropic_with_system(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test converting messages list with system message."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
messages = [
|
||||
@@ -238,7 +238,7 @@ def test_convert_messages_to_anthropic_format_with_system(mock_anthropic_client:
|
||||
ChatMessage(role=Role.USER, text="Hello!"),
|
||||
]
|
||||
|
||||
result = chat_client._convert_messages_to_anthropic_format(messages)
|
||||
result = chat_client._prepare_messages_for_anthropic(messages)
|
||||
|
||||
# System message should be skipped
|
||||
assert len(result) == 1
|
||||
@@ -246,7 +246,7 @@ def test_convert_messages_to_anthropic_format_with_system(mock_anthropic_client:
|
||||
assert result[0]["content"][0]["text"] == "Hello!"
|
||||
|
||||
|
||||
def test_convert_messages_to_anthropic_format_without_system(mock_anthropic_client: MagicMock) -> None:
|
||||
def test_prepare_messages_for_anthropic_without_system(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test converting messages list without system message."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
messages = [
|
||||
@@ -254,7 +254,7 @@ def test_convert_messages_to_anthropic_format_without_system(mock_anthropic_clie
|
||||
ChatMessage(role=Role.ASSISTANT, text="Hi there!"),
|
||||
]
|
||||
|
||||
result = chat_client._convert_messages_to_anthropic_format(messages)
|
||||
result = chat_client._prepare_messages_for_anthropic(messages)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["role"] == "user"
|
||||
@@ -264,7 +264,7 @@ def test_convert_messages_to_anthropic_format_without_system(mock_anthropic_clie
|
||||
# Tool Conversion Tests
|
||||
|
||||
|
||||
def test_convert_tools_to_anthropic_format_ai_function(mock_anthropic_client: MagicMock) -> None:
|
||||
def test_prepare_tools_for_anthropic_ai_function(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test converting AIFunction to Anthropic format."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
|
||||
@@ -273,9 +273,8 @@ def test_convert_tools_to_anthropic_format_ai_function(mock_anthropic_client: Ma
|
||||
"""Get weather for a location."""
|
||||
return f"Weather for {location}"
|
||||
|
||||
tools = [get_weather]
|
||||
|
||||
result = chat_client._convert_tools_to_anthropic_format(tools)
|
||||
chat_options = ChatOptions(tools=[get_weather])
|
||||
result = chat_client._prepare_tools_for_anthropic(chat_options)
|
||||
|
||||
assert result is not None
|
||||
assert "tools" in result
|
||||
@@ -285,12 +284,12 @@ def test_convert_tools_to_anthropic_format_ai_function(mock_anthropic_client: Ma
|
||||
assert "Get weather for a location" in result["tools"][0]["description"]
|
||||
|
||||
|
||||
def test_convert_tools_to_anthropic_format_web_search(mock_anthropic_client: MagicMock) -> None:
|
||||
def test_prepare_tools_for_anthropic_web_search(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test converting HostedWebSearchTool to Anthropic format."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
tools = [HostedWebSearchTool()]
|
||||
chat_options = ChatOptions(tools=[HostedWebSearchTool()])
|
||||
|
||||
result = chat_client._convert_tools_to_anthropic_format(tools)
|
||||
result = chat_client._prepare_tools_for_anthropic(chat_options)
|
||||
|
||||
assert result is not None
|
||||
assert "tools" in result
|
||||
@@ -299,12 +298,12 @@ def test_convert_tools_to_anthropic_format_web_search(mock_anthropic_client: Mag
|
||||
assert result["tools"][0]["name"] == "web_search"
|
||||
|
||||
|
||||
def test_convert_tools_to_anthropic_format_code_interpreter(mock_anthropic_client: MagicMock) -> None:
|
||||
def test_prepare_tools_for_anthropic_code_interpreter(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test converting HostedCodeInterpreterTool to Anthropic format."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
tools = [HostedCodeInterpreterTool()]
|
||||
chat_options = ChatOptions(tools=[HostedCodeInterpreterTool()])
|
||||
|
||||
result = chat_client._convert_tools_to_anthropic_format(tools)
|
||||
result = chat_client._prepare_tools_for_anthropic(chat_options)
|
||||
|
||||
assert result is not None
|
||||
assert "tools" in result
|
||||
@@ -313,12 +312,12 @@ def test_convert_tools_to_anthropic_format_code_interpreter(mock_anthropic_clien
|
||||
assert result["tools"][0]["name"] == "code_execution"
|
||||
|
||||
|
||||
def test_convert_tools_to_anthropic_format_mcp_tool(mock_anthropic_client: MagicMock) -> None:
|
||||
def test_prepare_tools_for_anthropic_mcp_tool(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test converting HostedMCPTool to Anthropic format."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
tools = [HostedMCPTool(name="test-mcp", url="https://example.com/mcp")]
|
||||
chat_options = ChatOptions(tools=[HostedMCPTool(name="test-mcp", url="https://example.com/mcp")])
|
||||
|
||||
result = chat_client._convert_tools_to_anthropic_format(tools)
|
||||
result = chat_client._prepare_tools_for_anthropic(chat_options)
|
||||
|
||||
assert result is not None
|
||||
assert "mcp_servers" in result
|
||||
@@ -328,18 +327,20 @@ def test_convert_tools_to_anthropic_format_mcp_tool(mock_anthropic_client: Magic
|
||||
assert result["mcp_servers"][0]["url"] == "https://example.com/mcp"
|
||||
|
||||
|
||||
def test_convert_tools_to_anthropic_format_mcp_with_auth(mock_anthropic_client: MagicMock) -> None:
|
||||
def test_prepare_tools_for_anthropic_mcp_with_auth(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test converting HostedMCPTool with authorization headers."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
tools = [
|
||||
HostedMCPTool(
|
||||
name="test-mcp",
|
||||
url="https://example.com/mcp",
|
||||
headers={"authorization": "Bearer token123"},
|
||||
)
|
||||
]
|
||||
chat_options = ChatOptions(
|
||||
tools=[
|
||||
HostedMCPTool(
|
||||
name="test-mcp",
|
||||
url="https://example.com/mcp",
|
||||
headers={"authorization": "Bearer token123"},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
result = chat_client._convert_tools_to_anthropic_format(tools)
|
||||
result = chat_client._prepare_tools_for_anthropic(chat_options)
|
||||
|
||||
assert result is not None
|
||||
assert "mcp_servers" in result
|
||||
@@ -348,12 +349,12 @@ def test_convert_tools_to_anthropic_format_mcp_with_auth(mock_anthropic_client:
|
||||
assert result["mcp_servers"][0]["authorization_token"] == "Bearer token123"
|
||||
|
||||
|
||||
def test_convert_tools_to_anthropic_format_dict_tool(mock_anthropic_client: MagicMock) -> None:
|
||||
def test_prepare_tools_for_anthropic_dict_tool(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test converting dict tool to Anthropic format."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
tools = [{"type": "custom", "name": "custom_tool", "description": "A custom tool"}]
|
||||
chat_options = ChatOptions(tools=[{"type": "custom", "name": "custom_tool", "description": "A custom tool"}])
|
||||
|
||||
result = chat_client._convert_tools_to_anthropic_format(tools)
|
||||
result = chat_client._prepare_tools_for_anthropic(chat_options)
|
||||
|
||||
assert result is not None
|
||||
assert "tools" in result
|
||||
@@ -361,11 +362,12 @@ def test_convert_tools_to_anthropic_format_dict_tool(mock_anthropic_client: Magi
|
||||
assert result["tools"][0]["name"] == "custom_tool"
|
||||
|
||||
|
||||
def test_convert_tools_to_anthropic_format_none(mock_anthropic_client: MagicMock) -> None:
|
||||
def test_prepare_tools_for_anthropic_none(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test converting None tools."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
chat_options = ChatOptions()
|
||||
|
||||
result = chat_client._convert_tools_to_anthropic_format(None)
|
||||
result = chat_client._prepare_tools_for_anthropic(chat_options)
|
||||
|
||||
assert result is None
|
||||
|
||||
@@ -373,14 +375,14 @@ def test_convert_tools_to_anthropic_format_none(mock_anthropic_client: MagicMock
|
||||
# Run Options Tests
|
||||
|
||||
|
||||
async def test_create_run_options_basic(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _create_run_options with basic ChatOptions."""
|
||||
async def test_prepare_options_basic(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _prepare_options with basic ChatOptions."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, text="Hello")]
|
||||
chat_options = ChatOptions(max_tokens=100, temperature=0.7)
|
||||
|
||||
run_options = chat_client._create_run_options(messages, chat_options)
|
||||
run_options = chat_client._prepare_options(messages, chat_options)
|
||||
|
||||
assert run_options["model"] == chat_client.model_id
|
||||
assert run_options["max_tokens"] == 100
|
||||
@@ -388,8 +390,8 @@ async def test_create_run_options_basic(mock_anthropic_client: MagicMock) -> Non
|
||||
assert "messages" in run_options
|
||||
|
||||
|
||||
async def test_create_run_options_with_system_message(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _create_run_options with system message."""
|
||||
async def test_prepare_options_with_system_message(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _prepare_options with system message."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
|
||||
messages = [
|
||||
@@ -398,52 +400,52 @@ async def test_create_run_options_with_system_message(mock_anthropic_client: Mag
|
||||
]
|
||||
chat_options = ChatOptions()
|
||||
|
||||
run_options = chat_client._create_run_options(messages, chat_options)
|
||||
run_options = chat_client._prepare_options(messages, chat_options)
|
||||
|
||||
assert run_options["system"] == "You are helpful."
|
||||
assert len(run_options["messages"]) == 1 # System message not in messages list
|
||||
|
||||
|
||||
async def test_create_run_options_with_tool_choice_auto(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _create_run_options with auto tool choice."""
|
||||
async def test_prepare_options_with_tool_choice_auto(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _prepare_options with auto tool choice."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, text="Hello")]
|
||||
chat_options = ChatOptions(tool_choice="auto")
|
||||
|
||||
run_options = chat_client._create_run_options(messages, chat_options)
|
||||
run_options = chat_client._prepare_options(messages, chat_options)
|
||||
|
||||
assert run_options["tool_choice"]["type"] == "auto"
|
||||
|
||||
|
||||
async def test_create_run_options_with_tool_choice_required(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _create_run_options with required tool choice."""
|
||||
async def test_prepare_options_with_tool_choice_required(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _prepare_options with required tool choice."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, text="Hello")]
|
||||
# For required with specific function, need to pass as dict
|
||||
chat_options = ChatOptions(tool_choice={"mode": "required", "required_function_name": "get_weather"})
|
||||
|
||||
run_options = chat_client._create_run_options(messages, chat_options)
|
||||
run_options = chat_client._prepare_options(messages, chat_options)
|
||||
|
||||
assert run_options["tool_choice"]["type"] == "tool"
|
||||
assert run_options["tool_choice"]["name"] == "get_weather"
|
||||
|
||||
|
||||
async def test_create_run_options_with_tool_choice_none(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _create_run_options with none tool choice."""
|
||||
async def test_prepare_options_with_tool_choice_none(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _prepare_options with none tool choice."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, text="Hello")]
|
||||
chat_options = ChatOptions(tool_choice="none")
|
||||
|
||||
run_options = chat_client._create_run_options(messages, chat_options)
|
||||
run_options = chat_client._prepare_options(messages, chat_options)
|
||||
|
||||
assert run_options["tool_choice"]["type"] == "none"
|
||||
|
||||
|
||||
async def test_create_run_options_with_tools(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _create_run_options with tools."""
|
||||
async def test_prepare_options_with_tools(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _prepare_options with tools."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
|
||||
@ai_function
|
||||
@@ -454,32 +456,32 @@ async def test_create_run_options_with_tools(mock_anthropic_client: MagicMock) -
|
||||
messages = [ChatMessage(role=Role.USER, text="Hello")]
|
||||
chat_options = ChatOptions(tools=[get_weather])
|
||||
|
||||
run_options = chat_client._create_run_options(messages, chat_options)
|
||||
run_options = chat_client._prepare_options(messages, chat_options)
|
||||
|
||||
assert "tools" in run_options
|
||||
assert len(run_options["tools"]) == 1
|
||||
|
||||
|
||||
async def test_create_run_options_with_stop_sequences(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _create_run_options with stop sequences."""
|
||||
async def test_prepare_options_with_stop_sequences(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _prepare_options with stop sequences."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, text="Hello")]
|
||||
chat_options = ChatOptions(stop=["STOP", "END"])
|
||||
|
||||
run_options = chat_client._create_run_options(messages, chat_options)
|
||||
run_options = chat_client._prepare_options(messages, chat_options)
|
||||
|
||||
assert run_options["stop_sequences"] == ["STOP", "END"]
|
||||
|
||||
|
||||
async def test_create_run_options_with_top_p(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _create_run_options with top_p."""
|
||||
async def test_prepare_options_with_top_p(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _prepare_options with top_p."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, text="Hello")]
|
||||
chat_options = ChatOptions(top_p=0.9)
|
||||
|
||||
run_options = chat_client._create_run_options(messages, chat_options)
|
||||
run_options = chat_client._prepare_options(messages, chat_options)
|
||||
|
||||
assert run_options["top_p"] == 0.9
|
||||
|
||||
@@ -540,41 +542,41 @@ def test_process_message_with_tool_use(mock_anthropic_client: MagicMock) -> None
|
||||
assert response.finish_reason == FinishReason.TOOL_CALLS
|
||||
|
||||
|
||||
def test_parse_message_usage_basic(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _parse_message_usage with basic usage."""
|
||||
def test_parse_usage_from_anthropic_basic(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _parse_usage_from_anthropic with basic usage."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
|
||||
usage = BetaUsage(input_tokens=10, output_tokens=5)
|
||||
result = chat_client._parse_message_usage(usage)
|
||||
result = chat_client._parse_usage_from_anthropic(usage)
|
||||
|
||||
assert result is not None
|
||||
assert result.input_token_count == 10
|
||||
assert result.output_token_count == 5
|
||||
|
||||
|
||||
def test_parse_message_usage_none(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _parse_message_usage with None usage."""
|
||||
def test_parse_usage_from_anthropic_none(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _parse_usage_from_anthropic with None usage."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
|
||||
result = chat_client._parse_message_usage(None)
|
||||
result = chat_client._parse_usage_from_anthropic(None)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_parse_message_contents_text(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _parse_message_contents with text content."""
|
||||
def test_parse_contents_from_anthropic_text(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _parse_contents_from_anthropic with text content."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
|
||||
content = [BetaTextBlock(type="text", text="Hello!")]
|
||||
result = chat_client._parse_message_contents(content)
|
||||
result = chat_client._parse_contents_from_anthropic(content)
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], TextContent)
|
||||
assert result[0].text == "Hello!"
|
||||
|
||||
|
||||
def test_parse_message_contents_tool_use(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _parse_message_contents with tool use."""
|
||||
def test_parse_contents_from_anthropic_tool_use(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _parse_contents_from_anthropic with tool use."""
|
||||
chat_client = create_test_anthropic_client(mock_anthropic_client)
|
||||
|
||||
content = [
|
||||
@@ -585,7 +587,7 @@ def test_parse_message_contents_tool_use(mock_anthropic_client: MagicMock) -> No
|
||||
input={"location": "SF"},
|
||||
)
|
||||
]
|
||||
result = chat_client._parse_message_contents(content)
|
||||
result = chat_client._parse_contents_from_anthropic(content)
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], FunctionCallContent)
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -278,22 +278,13 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[ChatResponseUpdate]:
|
||||
# Extract necessary state from messages and options
|
||||
run_options, required_action_results = await self._create_run_options(messages, chat_options, **kwargs)
|
||||
|
||||
# Get the thread ID
|
||||
thread_id: str | None = (
|
||||
chat_options.conversation_id
|
||||
if chat_options.conversation_id is not None
|
||||
else run_options.get("conversation_id", self.thread_id)
|
||||
)
|
||||
|
||||
# Determine which agent to use and create if needed
|
||||
# prepare
|
||||
run_options, required_action_results = await self._prepare_options(messages, chat_options, **kwargs)
|
||||
agent_id = await self._get_agent_id_or_create(run_options)
|
||||
|
||||
# Process and yield each update from the stream
|
||||
# execute and process
|
||||
async for update in self._process_stream(
|
||||
*(await self._create_agent_stream(thread_id, agent_id, run_options, required_action_results))
|
||||
*(await self._create_agent_stream(agent_id, run_options, required_action_results))
|
||||
):
|
||||
yield update
|
||||
|
||||
@@ -342,7 +333,6 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
|
||||
async def _create_agent_stream(
|
||||
self,
|
||||
thread_id: str | None,
|
||||
agent_id: str,
|
||||
run_options: dict[str, Any],
|
||||
required_action_results: list[FunctionResultContent | FunctionApprovalResponseContent] | None,
|
||||
@@ -352,14 +342,14 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
Returns:
|
||||
tuple: (stream, final_thread_id)
|
||||
"""
|
||||
thread_id = run_options.pop("thread_id", None)
|
||||
|
||||
# Get any active run for this thread
|
||||
thread_run = await self._get_active_thread_run(thread_id)
|
||||
|
||||
stream: AsyncAgentRunStream[AsyncAgentEventHandler[Any]] | AsyncAgentEventHandler[Any]
|
||||
handler: AsyncAgentEventHandler[Any] = AsyncAgentEventHandler()
|
||||
tool_run_id, tool_outputs, tool_approvals = self._convert_required_action_to_tool_output(
|
||||
required_action_results
|
||||
)
|
||||
tool_run_id, tool_outputs, tool_approvals = self._prepare_tool_outputs_for_azure_ai(required_action_results)
|
||||
|
||||
if (
|
||||
thread_run is not None
|
||||
@@ -421,19 +411,11 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
|
||||
# No thread ID was provided, so create a new thread.
|
||||
thread = await self.agents_client.threads.create(
|
||||
tool_resources=run_options.get("tool_resources"), metadata=run_options.get("metadata")
|
||||
tool_resources=run_options.get("tool_resources"),
|
||||
metadata=run_options.get("metadata"),
|
||||
messages=run_options.get("additional_messages"),
|
||||
)
|
||||
thread_id = thread.id
|
||||
# workaround for: https://github.com/Azure/azure-sdk-for-python/issues/42805
|
||||
# this occurs when otel is enabled
|
||||
# once fixed, in the function above, readd:
|
||||
# `messages=run_options.pop("additional_messages")`
|
||||
for msg in run_options.pop("additional_messages", []):
|
||||
await self.agents_client.messages.create(
|
||||
thread_id=thread_id, role=msg.role, content=msg.content, metadata=msg.metadata
|
||||
)
|
||||
# and remove until here.
|
||||
return thread_id
|
||||
return thread.id
|
||||
|
||||
def _extract_url_citations(
|
||||
self, message_delta_chunk: MessageDeltaChunk, azure_search_tool_calls: list[dict[str, Any]]
|
||||
@@ -611,7 +593,7 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
"submit_tool_outputs",
|
||||
"submit_tool_approval",
|
||||
]:
|
||||
function_call_contents = self._create_function_call_contents(
|
||||
function_call_contents = self._parse_function_calls_from_azure_ai(
|
||||
event_data, response_id
|
||||
)
|
||||
if function_call_contents:
|
||||
@@ -753,8 +735,8 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
except Exception as ex:
|
||||
logger.debug(f"Failed to capture Azure AI Search tool call: {ex}")
|
||||
|
||||
def _create_function_call_contents(self, event_data: ThreadRun, response_id: str | None) -> list[Contents]:
|
||||
"""Create function call contents from a tool action event."""
|
||||
def _parse_function_calls_from_azure_ai(self, event_data: ThreadRun, response_id: str | None) -> list[Contents]:
|
||||
"""Parse function call contents from an Azure AI tool action event."""
|
||||
if isinstance(event_data, ThreadRun) and event_data.required_action is not None:
|
||||
if isinstance(event_data.required_action, SubmitToolOutputsAction):
|
||||
return [
|
||||
@@ -815,117 +797,197 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
|
||||
chat_options.tool_choice = chat_tool_mode
|
||||
|
||||
async def _create_run_options(
|
||||
async def _prepare_options(
|
||||
self,
|
||||
messages: MutableSequence[ChatMessage],
|
||||
chat_options: ChatOptions | None,
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> tuple[dict[str, Any], list[FunctionResultContent | FunctionApprovalResponseContent] | None]:
|
||||
run_options: dict[str, Any] = {**kwargs}
|
||||
|
||||
agent_definition = await self._load_agent_definition_if_needed()
|
||||
|
||||
if chat_options is not None:
|
||||
run_options["max_completion_tokens"] = chat_options.max_tokens
|
||||
if chat_options.model_id is not None:
|
||||
run_options["model"] = chat_options.model_id
|
||||
else:
|
||||
run_options["model"] = self.model_id
|
||||
run_options["top_p"] = chat_options.top_p
|
||||
run_options["temperature"] = chat_options.temperature
|
||||
run_options["parallel_tool_calls"] = chat_options.allow_multiple_tool_calls
|
||||
# Use to_dict with exclusions for properties handled separately
|
||||
run_options: dict[str, Any] = chat_options.to_dict(
|
||||
exclude={
|
||||
"type",
|
||||
"instructions", # handled via messages
|
||||
"tools", # handled separately
|
||||
"tool_choice", # handled separately
|
||||
"response_format", # handled separately
|
||||
"additional_properties", # handled separately
|
||||
"frequency_penalty", # not supported
|
||||
"presence_penalty", # not supported
|
||||
"user", # not supported
|
||||
"stop", # not supported
|
||||
"logit_bias", # not supported
|
||||
"seed", # not supported
|
||||
"store", # not supported
|
||||
}
|
||||
)
|
||||
|
||||
tool_definitions: list[ToolDefinition | dict[str, Any]] = []
|
||||
# Translation between ChatOptions and Azure AI Agents API
|
||||
translations = {
|
||||
"model_id": "model",
|
||||
"allow_multiple_tool_calls": "parallel_tool_calls",
|
||||
"max_tokens": "max_completion_tokens",
|
||||
}
|
||||
for old_key, new_key in translations.items():
|
||||
if old_key in run_options and old_key != new_key:
|
||||
run_options[new_key] = run_options.pop(old_key)
|
||||
|
||||
# Add tools from existing agent
|
||||
if agent_definition is not None:
|
||||
# Don't include function tools, since they will be passed through chat_options.tools
|
||||
agent_tools = [tool for tool in agent_definition.tools if not isinstance(tool, FunctionToolDefinition)]
|
||||
if agent_tools:
|
||||
tool_definitions.extend(agent_tools)
|
||||
if agent_definition.tool_resources:
|
||||
run_options["tool_resources"] = agent_definition.tool_resources
|
||||
# model id fallback
|
||||
if not run_options.get("model"):
|
||||
run_options["model"] = self.model_id
|
||||
|
||||
if chat_options.tool_choice is not None:
|
||||
if chat_options.tool_choice != "none" and chat_options.tools:
|
||||
# Add run tools
|
||||
tool_definitions.extend(await self._prep_tools(chat_options.tools, run_options))
|
||||
# tools and tool_choice
|
||||
if tool_definitions := await self._prepare_tool_definitions_and_resources(
|
||||
chat_options, agent_definition, run_options
|
||||
):
|
||||
run_options["tools"] = tool_definitions
|
||||
|
||||
# Handle MCP tool resources for approval mode
|
||||
mcp_tools = [tool for tool in chat_options.tools if isinstance(tool, HostedMCPTool)]
|
||||
if mcp_tools:
|
||||
mcp_resources = []
|
||||
for mcp_tool in mcp_tools:
|
||||
server_label = mcp_tool.name.replace(" ", "_")
|
||||
mcp_resource: dict[str, Any] = {"server_label": server_label}
|
||||
if tool_choice := self._prepare_tool_choice_mode(chat_options):
|
||||
run_options["tool_choice"] = tool_choice
|
||||
|
||||
# Add headers if they exist
|
||||
if mcp_tool.headers:
|
||||
mcp_resource["headers"] = mcp_tool.headers
|
||||
|
||||
if mcp_tool.approval_mode is not None:
|
||||
match mcp_tool.approval_mode:
|
||||
case str():
|
||||
# Map agent framework approval modes to Azure AI approval modes
|
||||
approval_mode = (
|
||||
"always" if mcp_tool.approval_mode == "always_require" else "never"
|
||||
)
|
||||
mcp_resource["require_approval"] = approval_mode
|
||||
case _:
|
||||
if "always_require_approval" in mcp_tool.approval_mode:
|
||||
mcp_resource["require_approval"] = {
|
||||
"always": mcp_tool.approval_mode["always_require_approval"]
|
||||
}
|
||||
elif "never_require_approval" in mcp_tool.approval_mode:
|
||||
mcp_resource["require_approval"] = {
|
||||
"never": mcp_tool.approval_mode["never_require_approval"]
|
||||
}
|
||||
|
||||
mcp_resources.append(mcp_resource)
|
||||
|
||||
# Add MCP resources to tool_resources
|
||||
if "tool_resources" not in run_options:
|
||||
run_options["tool_resources"] = {}
|
||||
run_options["tool_resources"]["mcp"] = mcp_resources
|
||||
|
||||
if chat_options.tool_choice == "none":
|
||||
run_options["tool_choice"] = AgentsToolChoiceOptionMode.NONE
|
||||
elif chat_options.tool_choice == "auto":
|
||||
run_options["tool_choice"] = AgentsToolChoiceOptionMode.AUTO
|
||||
elif (
|
||||
isinstance(chat_options.tool_choice, ToolMode)
|
||||
and chat_options.tool_choice == "required"
|
||||
and chat_options.tool_choice.required_function_name is not None
|
||||
):
|
||||
run_options["tool_choice"] = AgentsNamedToolChoice(
|
||||
type=AgentsNamedToolChoiceType.FUNCTION,
|
||||
function=FunctionName(name=chat_options.tool_choice.required_function_name),
|
||||
)
|
||||
|
||||
if tool_definitions:
|
||||
run_options["tools"] = tool_definitions
|
||||
|
||||
if chat_options.response_format is not None:
|
||||
run_options["response_format"] = ResponseFormatJsonSchemaType(
|
||||
json_schema=ResponseFormatJsonSchema(
|
||||
name=chat_options.response_format.__name__,
|
||||
schema=chat_options.response_format.model_json_schema(),
|
||||
)
|
||||
# response format
|
||||
if chat_options.response_format is not None:
|
||||
run_options["response_format"] = ResponseFormatJsonSchemaType(
|
||||
json_schema=ResponseFormatJsonSchema(
|
||||
name=chat_options.response_format.__name__,
|
||||
schema=chat_options.response_format.model_json_schema(),
|
||||
)
|
||||
)
|
||||
|
||||
# messages
|
||||
additional_messages, instructions, required_action_results = self._prepare_messages(messages)
|
||||
if additional_messages:
|
||||
run_options["additional_messages"] = additional_messages
|
||||
|
||||
# Add instruction from existing agent at the beginning
|
||||
if (
|
||||
agent_definition is not None
|
||||
and agent_definition.instructions
|
||||
and agent_definition.instructions not in instructions
|
||||
):
|
||||
instructions.insert(0, agent_definition.instructions)
|
||||
|
||||
if instructions:
|
||||
run_options["instructions"] = "\n".join(instructions)
|
||||
|
||||
# thread_id resolution (conversation_id takes precedence, then kwargs, then instance default)
|
||||
run_options["thread_id"] = chat_options.conversation_id or kwargs.get("conversation_id") or self.thread_id
|
||||
|
||||
return run_options, required_action_results
|
||||
|
||||
def _prepare_tool_choice_mode(
|
||||
self, chat_options: ChatOptions
|
||||
) -> AgentsToolChoiceOptionMode | AgentsNamedToolChoice | None:
|
||||
"""Prepare the tool choice mode for Azure AI Agents API."""
|
||||
if chat_options.tool_choice is None:
|
||||
return None
|
||||
if chat_options.tool_choice == "none":
|
||||
return AgentsToolChoiceOptionMode.NONE
|
||||
if chat_options.tool_choice == "auto":
|
||||
return AgentsToolChoiceOptionMode.AUTO
|
||||
if (
|
||||
isinstance(chat_options.tool_choice, ToolMode)
|
||||
and chat_options.tool_choice == "required"
|
||||
and chat_options.tool_choice.required_function_name is not None
|
||||
):
|
||||
return AgentsNamedToolChoice(
|
||||
type=AgentsNamedToolChoiceType.FUNCTION,
|
||||
function=FunctionName(name=chat_options.tool_choice.required_function_name),
|
||||
)
|
||||
return None
|
||||
|
||||
async def _prepare_tool_definitions_and_resources(
|
||||
self,
|
||||
chat_options: ChatOptions,
|
||||
agent_definition: Agent | None,
|
||||
run_options: dict[str, Any],
|
||||
) -> list[ToolDefinition | dict[str, Any]]:
|
||||
"""Prepare tool definitions and resources for the run options."""
|
||||
tool_definitions: list[ToolDefinition | dict[str, Any]] = []
|
||||
|
||||
# Add tools from existing agent (exclude function tools - passed via chat_options.tools)
|
||||
if agent_definition is not None:
|
||||
agent_tools = [tool for tool in agent_definition.tools if not isinstance(tool, FunctionToolDefinition)]
|
||||
if agent_tools:
|
||||
tool_definitions.extend(agent_tools)
|
||||
if agent_definition.tool_resources:
|
||||
run_options["tool_resources"] = agent_definition.tool_resources
|
||||
|
||||
# Add run tools if tool_choice allows
|
||||
if chat_options.tool_choice is not None and chat_options.tool_choice != "none" and chat_options.tools:
|
||||
tool_definitions.extend(await self._prepare_tools_for_azure_ai(chat_options.tools, run_options))
|
||||
|
||||
# Handle MCP tool resources
|
||||
mcp_resources = self._prepare_mcp_resources(chat_options.tools)
|
||||
if mcp_resources:
|
||||
if "tool_resources" not in run_options:
|
||||
run_options["tool_resources"] = {}
|
||||
run_options["tool_resources"]["mcp"] = mcp_resources
|
||||
|
||||
return tool_definitions
|
||||
|
||||
def _prepare_mcp_resources(
|
||||
self, tools: Sequence["ToolProtocol | MutableMapping[str, Any]"]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Prepare MCP tool resources for approval mode configuration."""
|
||||
mcp_tools = [tool for tool in tools if isinstance(tool, HostedMCPTool)]
|
||||
if not mcp_tools:
|
||||
return []
|
||||
|
||||
mcp_resources: list[dict[str, Any]] = []
|
||||
for mcp_tool in mcp_tools:
|
||||
server_label = mcp_tool.name.replace(" ", "_")
|
||||
mcp_resource: dict[str, Any] = {"server_label": server_label}
|
||||
|
||||
if mcp_tool.headers:
|
||||
mcp_resource["headers"] = mcp_tool.headers
|
||||
|
||||
if mcp_tool.approval_mode is not None:
|
||||
match mcp_tool.approval_mode:
|
||||
case str():
|
||||
# Map agent framework approval modes to Azure AI approval modes
|
||||
approval_mode = "always" if mcp_tool.approval_mode == "always_require" else "never"
|
||||
mcp_resource["require_approval"] = approval_mode
|
||||
case _:
|
||||
if "always_require_approval" in mcp_tool.approval_mode:
|
||||
mcp_resource["require_approval"] = {
|
||||
"always": mcp_tool.approval_mode["always_require_approval"]
|
||||
}
|
||||
elif "never_require_approval" in mcp_tool.approval_mode:
|
||||
mcp_resource["require_approval"] = {
|
||||
"never": mcp_tool.approval_mode["never_require_approval"]
|
||||
}
|
||||
|
||||
mcp_resources.append(mcp_resource)
|
||||
|
||||
return mcp_resources
|
||||
|
||||
def _prepare_messages(
|
||||
self, messages: MutableSequence[ChatMessage]
|
||||
) -> tuple[
|
||||
list[ThreadMessageOptions] | None,
|
||||
list[str],
|
||||
list[FunctionResultContent | FunctionApprovalResponseContent] | None,
|
||||
]:
|
||||
"""Prepare messages for Azure AI Agents API.
|
||||
|
||||
System/developer messages are turned into instructions, since there is no such message roles in Azure AI.
|
||||
All other messages are added 1:1, treating assistant messages as agent messages
|
||||
and everything else as user messages.
|
||||
|
||||
Returns:
|
||||
Tuple of (additional_messages, instructions, required_action_results)
|
||||
"""
|
||||
instructions: list[str] = []
|
||||
required_action_results: list[FunctionResultContent | FunctionApprovalResponseContent] | None = None
|
||||
|
||||
additional_messages: list[ThreadMessageOptions] | None = None
|
||||
|
||||
# System/developer messages are turned into instructions, since there is no such message roles in Azure AI.
|
||||
# All other messages are added 1:1, treating assistant messages as agent messages
|
||||
# and everything else as user messages.
|
||||
for chat_message in messages:
|
||||
if chat_message.role.value in ["system", "developer"]:
|
||||
for text_content in [content for content in chat_message.contents if isinstance(content, TextContent)]:
|
||||
instructions.append(text_content.text)
|
||||
|
||||
continue
|
||||
|
||||
message_contents: list[MessageInputContentBlock] = []
|
||||
@@ -942,7 +1004,7 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
elif isinstance(content.raw_representation, MessageInputContentBlock):
|
||||
message_contents.append(content.raw_representation)
|
||||
|
||||
if len(message_contents) > 0:
|
||||
if message_contents:
|
||||
if additional_messages is None:
|
||||
additional_messages = []
|
||||
additional_messages.append(
|
||||
@@ -952,26 +1014,12 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
)
|
||||
)
|
||||
|
||||
if additional_messages is not None:
|
||||
run_options["additional_messages"] = additional_messages
|
||||
return additional_messages, instructions, required_action_results
|
||||
|
||||
# Add instruction from existing agent at the beginning
|
||||
if (
|
||||
agent_definition is not None
|
||||
and agent_definition.instructions
|
||||
and agent_definition.instructions not in instructions
|
||||
):
|
||||
instructions.insert(0, agent_definition.instructions)
|
||||
|
||||
if len(instructions) > 0:
|
||||
run_options["instructions"] = "".join(instructions)
|
||||
|
||||
return run_options, required_action_results
|
||||
|
||||
async def _prep_tools(
|
||||
async def _prepare_tools_for_azure_ai(
|
||||
self, tools: Sequence["ToolProtocol | MutableMapping[str, Any]"], run_options: dict[str, Any] | None = None
|
||||
) -> list[ToolDefinition | dict[str, Any]]:
|
||||
"""Prepare tool definitions for the run options."""
|
||||
"""Prepare tool definitions for the Azure AI Agents API."""
|
||||
tool_definitions: list[ToolDefinition | dict[str, Any]] = []
|
||||
for tool in tools:
|
||||
match tool:
|
||||
@@ -1044,10 +1092,11 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
raise ServiceInitializationError(f"Unsupported tool type: {type(tool)}")
|
||||
return tool_definitions
|
||||
|
||||
def _convert_required_action_to_tool_output(
|
||||
def _prepare_tool_outputs_for_azure_ai(
|
||||
self,
|
||||
required_action_results: list[FunctionResultContent | FunctionApprovalResponseContent] | None,
|
||||
) -> tuple[str | None, list[ToolOutput] | None, list[ToolApproval] | None]:
|
||||
"""Prepare function results and approvals for submission to the Azure AI API."""
|
||||
run_id: str | None = None
|
||||
tool_outputs: list[ToolOutput] | None = None
|
||||
tool_approvals: list[ToolApproval] | None = None
|
||||
|
||||
@@ -28,10 +28,6 @@ from azure.ai.projects.models import (
|
||||
)
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
from openai.types.responses.parsed_response import (
|
||||
ParsedResponse,
|
||||
)
|
||||
from openai.types.responses.response import Response as OpenAIResponse
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from ._shared import AzureAISettings
|
||||
@@ -41,6 +37,11 @@ if sys.version_info >= (3, 11):
|
||||
else:
|
||||
from typing_extensions import Self # pragma: no cover
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # type: ignore[import] # pragma: no cover
|
||||
|
||||
|
||||
logger = get_logger("agent_framework.azure")
|
||||
|
||||
@@ -335,6 +336,10 @@ class AzureAIClient(OpenAIBaseResponsesClient):
|
||||
|
||||
if "tools" in run_options:
|
||||
args["tools"] = run_options["tools"]
|
||||
if "temperature" in run_options:
|
||||
args["temperature"] = run_options["temperature"]
|
||||
if "top_p" in run_options:
|
||||
args["top_p"] = run_options["top_p"]
|
||||
|
||||
if "response_format" in run_options:
|
||||
response_format = run_options["response_format"]
|
||||
@@ -364,7 +369,38 @@ class AzureAIClient(OpenAIBaseResponsesClient):
|
||||
if self._should_close_client:
|
||||
await self.project_client.close()
|
||||
|
||||
def _prepare_input(self, messages: MutableSequence[ChatMessage]) -> tuple[list[ChatMessage], str | None]:
|
||||
@override
|
||||
async def _prepare_options(
|
||||
self,
|
||||
messages: MutableSequence[ChatMessage],
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Take ChatOptions and create the specific options for Azure AI."""
|
||||
prepared_messages, instructions = self._prepare_messages_for_azure_ai(messages)
|
||||
run_options = await super()._prepare_options(prepared_messages, chat_options, **kwargs)
|
||||
if not self._is_application_endpoint:
|
||||
# Application-scoped response APIs do not support "agent" property.
|
||||
agent_reference = await self._get_agent_reference_or_create(run_options, instructions)
|
||||
run_options["extra_body"] = {"agent": agent_reference}
|
||||
|
||||
# Remove properties that are not supported on request level
|
||||
# but were configured on agent level
|
||||
exclude = ["model", "tools", "response_format", "temperature", "top_p"]
|
||||
|
||||
for property in exclude:
|
||||
run_options.pop(property, None)
|
||||
|
||||
return run_options
|
||||
|
||||
@override
|
||||
def _get_current_conversation_id(self, chat_options: ChatOptions, **kwargs: Any) -> str | None:
|
||||
"""Get the current conversation ID from chat options or kwargs."""
|
||||
return chat_options.conversation_id or kwargs.get("conversation_id") or self.conversation_id
|
||||
|
||||
def _prepare_messages_for_azure_ai(
|
||||
self, messages: MutableSequence[ChatMessage]
|
||||
) -> tuple[list[ChatMessage], str | None]:
|
||||
"""Prepare input from messages and convert system/developer messages to instructions."""
|
||||
result: list[ChatMessage] = []
|
||||
instructions_list: list[str] = []
|
||||
@@ -383,44 +419,7 @@ class AzureAIClient(OpenAIBaseResponsesClient):
|
||||
|
||||
return result, instructions
|
||||
|
||||
async def prepare_options(
|
||||
self,
|
||||
messages: MutableSequence[ChatMessage],
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Take ChatOptions and create the specific options for Azure AI."""
|
||||
prepared_messages, instructions = self._prepare_input(messages)
|
||||
run_options = await super().prepare_options(prepared_messages, chat_options, **kwargs)
|
||||
|
||||
if not self._is_application_endpoint:
|
||||
# Application-scoped response APIs do not support "agent" property.
|
||||
agent_reference = await self._get_agent_reference_or_create(run_options, instructions)
|
||||
run_options["extra_body"] = {"agent": agent_reference}
|
||||
|
||||
conversation_id = chat_options.conversation_id or self.conversation_id
|
||||
|
||||
# Handle different conversation ID formats
|
||||
if conversation_id:
|
||||
if conversation_id.startswith("resp_"):
|
||||
# For response IDs, set previous_response_id and remove conversation property
|
||||
run_options.pop("conversation", None)
|
||||
run_options["previous_response_id"] = conversation_id
|
||||
elif conversation_id.startswith("conv_"):
|
||||
# For conversation IDs, set conversation and remove previous_response_id property
|
||||
run_options.pop("previous_response_id", None)
|
||||
run_options["conversation"] = conversation_id
|
||||
|
||||
# Remove properties that are not supported on request level
|
||||
# but were configured on agent level
|
||||
exclude = ["model", "tools", "response_format"]
|
||||
|
||||
for property in exclude:
|
||||
run_options.pop(property, None)
|
||||
|
||||
return run_options
|
||||
|
||||
async def initialize_client(self) -> None:
|
||||
async def _initialize_client(self) -> None:
|
||||
"""Initialize OpenAI client."""
|
||||
self.client = self.project_client.get_openai_client() # type: ignore
|
||||
|
||||
@@ -438,7 +437,8 @@ class AzureAIClient(OpenAIBaseResponsesClient):
|
||||
if description and not self.agent_description:
|
||||
self.agent_description = description
|
||||
|
||||
def get_mcp_tool(self, tool: HostedMCPTool) -> Any:
|
||||
@staticmethod
|
||||
def _prepare_mcp_tool(tool: HostedMCPTool) -> MCPTool: # type: ignore[override]
|
||||
"""Get MCP tool from HostedMCPTool."""
|
||||
mcp = MCPTool(server_label=tool.name.replace(" ", "_"), server_url=str(tool.url))
|
||||
|
||||
@@ -456,17 +456,3 @@ class AzureAIClient(OpenAIBaseResponsesClient):
|
||||
mcp["require_approval"] = {"never": {"tool_names": list(never_require_approvals)}}
|
||||
|
||||
return mcp
|
||||
|
||||
def get_conversation_id(
|
||||
self, response: OpenAIResponse | ParsedResponse[BaseModel], store: bool | None
|
||||
) -> str | None:
|
||||
"""Get the conversation ID from the response if store is True."""
|
||||
if store is False:
|
||||
return None
|
||||
# If conversation ID exists, it means that we operate with conversation
|
||||
# so we use conversation ID as input and output.
|
||||
if response.conversation and response.conversation.id:
|
||||
return response.conversation.id
|
||||
# If conversation ID doesn't exist, we operate with responses
|
||||
# so we use response ID as input and output.
|
||||
return response.id
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -83,6 +83,13 @@ include = "../../shared_tasks.toml"
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai"
|
||||
test = "pytest --cov=agent_framework_azure_ai --cov-report=term-missing:skip-covered tests"
|
||||
|
||||
[tool.poe.tasks.integration-tests]
|
||||
cmd = """
|
||||
pytest --import-mode=importlib
|
||||
-n logical --dist loadfile --dist worksteal
|
||||
tests
|
||||
"""
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
|
||||
@@ -367,33 +367,33 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_missing_model(
|
||||
await chat_client._get_agent_id_or_create() # type: ignore
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_create_run_options_basic(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _create_run_options with basic ChatOptions."""
|
||||
async def test_azure_ai_chat_client_prepare_options_basic(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prepare_options with basic ChatOptions."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, text="Hello")]
|
||||
chat_options = ChatOptions(max_tokens=100, temperature=0.7)
|
||||
|
||||
run_options, tool_results = await chat_client._create_run_options(messages, chat_options) # type: ignore
|
||||
run_options, tool_results = await chat_client._prepare_options(messages, chat_options) # type: ignore
|
||||
|
||||
assert run_options is not None
|
||||
assert tool_results is None
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_create_run_options_no_chat_options(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _create_run_options with no ChatOptions."""
|
||||
async def test_azure_ai_chat_client_prepare_options_no_chat_options(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prepare_options with default ChatOptions."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, text="Hello")]
|
||||
|
||||
run_options, tool_results = await chat_client._create_run_options(messages, None) # type: ignore
|
||||
run_options, tool_results = await chat_client._prepare_options(messages, ChatOptions()) # type: ignore
|
||||
|
||||
assert run_options is not None
|
||||
assert tool_results is None
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_create_run_options_with_image_content(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _create_run_options with image content."""
|
||||
async def test_azure_ai_chat_client_prepare_options_with_image_content(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prepare_options with image content."""
|
||||
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
@@ -403,7 +403,7 @@ async def test_azure_ai_chat_client_create_run_options_with_image_content(mock_a
|
||||
image_content = UriContent(uri="https://example.com/image.jpg", media_type="image/jpeg")
|
||||
messages = [ChatMessage(role=Role.USER, contents=[image_content])]
|
||||
|
||||
run_options, _ = await chat_client._create_run_options(messages, None) # type: ignore
|
||||
run_options, _ = await chat_client._prepare_options(messages, ChatOptions()) # type: ignore
|
||||
|
||||
assert "additional_messages" in run_options
|
||||
assert len(run_options["additional_messages"]) == 1
|
||||
@@ -412,11 +412,11 @@ async def test_azure_ai_chat_client_create_run_options_with_image_content(mock_a
|
||||
assert len(message.content) == 1
|
||||
|
||||
|
||||
def test_azure_ai_chat_client_convert_function_results_to_tool_output_none(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _convert_required_action_to_tool_output with None input."""
|
||||
def test_azure_ai_chat_client_prepare_tool_outputs_for_azure_ai_none(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prepare_tool_outputs_for_azure_ai with None input."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
|
||||
|
||||
run_id, tool_outputs, tool_approvals = chat_client._convert_required_action_to_tool_output(None) # type: ignore
|
||||
run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai(None) # type: ignore
|
||||
|
||||
assert run_id is None
|
||||
assert tool_outputs is None
|
||||
@@ -484,8 +484,8 @@ def test_azure_ai_chat_client_update_agent_name_and_description_with_none_input(
|
||||
assert chat_client.agent_description is None
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_create_run_options_with_messages(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _create_run_options with different message types."""
|
||||
async def test_azure_ai_chat_client_prepare_options_with_messages(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prepare_options with different message types."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
|
||||
|
||||
# Test with system message (becomes instruction)
|
||||
@@ -494,7 +494,7 @@ async def test_azure_ai_chat_client_create_run_options_with_messages(mock_agents
|
||||
ChatMessage(role=Role.USER, text="Hello"),
|
||||
]
|
||||
|
||||
run_options, _ = await chat_client._create_run_options(messages, None) # type: ignore
|
||||
run_options, _ = await chat_client._prepare_options(messages, ChatOptions()) # type: ignore
|
||||
|
||||
assert "instructions" in run_options
|
||||
assert "You are a helpful assistant" in run_options["instructions"]
|
||||
@@ -565,8 +565,8 @@ async def test_azure_ai_chat_client_prepare_thread_cancels_active_run(mock_agent
|
||||
mock_agents_client.runs.cancel.assert_called_once_with("test-thread", "run_123")
|
||||
|
||||
|
||||
def test_azure_ai_chat_client_create_function_call_contents_basic(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _create_function_call_contents with basic function call."""
|
||||
def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_basic(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _parse_function_calls_from_azure_ai with basic function call."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
|
||||
|
||||
mock_tool_call = MagicMock(spec=RequiredFunctionToolCall)
|
||||
@@ -580,7 +580,7 @@ def test_azure_ai_chat_client_create_function_call_contents_basic(mock_agents_cl
|
||||
mock_event_data = MagicMock(spec=ThreadRun)
|
||||
mock_event_data.required_action = mock_submit_action
|
||||
|
||||
result = chat_client._create_function_call_contents(mock_event_data, "response_123") # type: ignore
|
||||
result = chat_client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], FunctionCallContent)
|
||||
@@ -588,22 +588,24 @@ def test_azure_ai_chat_client_create_function_call_contents_basic(mock_agents_cl
|
||||
assert result[0].call_id == '["response_123", "call_123"]'
|
||||
|
||||
|
||||
def test_azure_ai_chat_client_create_function_call_contents_no_submit_action(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _create_function_call_contents when required_action is not SubmitToolOutputsAction."""
|
||||
def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_no_submit_action(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _parse_function_calls_from_azure_ai when required_action is not SubmitToolOutputsAction."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
|
||||
|
||||
mock_event_data = MagicMock(spec=ThreadRun)
|
||||
mock_event_data.required_action = MagicMock()
|
||||
|
||||
result = chat_client._create_function_call_contents(mock_event_data, "response_123") # type: ignore
|
||||
result = chat_client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_azure_ai_chat_client_create_function_call_contents_non_function_tool_call(
|
||||
def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_non_function_tool_call(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _create_function_call_contents with non-function tool call."""
|
||||
"""Test _parse_function_calls_from_azure_ai with non-function tool call."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
|
||||
|
||||
mock_tool_call = MagicMock()
|
||||
@@ -614,37 +616,37 @@ def test_azure_ai_chat_client_create_function_call_contents_non_function_tool_ca
|
||||
mock_event_data = MagicMock(spec=ThreadRun)
|
||||
mock_event_data.required_action = mock_submit_action
|
||||
|
||||
result = chat_client._create_function_call_contents(mock_event_data, "response_123") # type: ignore
|
||||
result = chat_client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_create_run_options_with_none_tool_choice(
|
||||
async def test_azure_ai_chat_client_prepare_options_with_none_tool_choice(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _create_run_options with tool_choice set to 'none'."""
|
||||
"""Test _prepare_options with tool_choice set to 'none'."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
|
||||
|
||||
chat_options = ChatOptions()
|
||||
chat_options.tool_choice = "none"
|
||||
|
||||
run_options, _ = await chat_client._create_run_options([], chat_options) # type: ignore
|
||||
run_options, _ = await chat_client._prepare_options([], chat_options) # type: ignore
|
||||
|
||||
from azure.ai.agents.models import AgentsToolChoiceOptionMode
|
||||
|
||||
assert run_options["tool_choice"] == AgentsToolChoiceOptionMode.NONE
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_create_run_options_with_auto_tool_choice(
|
||||
async def test_azure_ai_chat_client_prepare_options_with_auto_tool_choice(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _create_run_options with tool_choice set to 'auto'."""
|
||||
"""Test _prepare_options with tool_choice set to 'auto'."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
|
||||
|
||||
chat_options = ChatOptions()
|
||||
chat_options.tool_choice = "auto"
|
||||
|
||||
run_options, _ = await chat_client._create_run_options([], chat_options) # type: ignore
|
||||
run_options, _ = await chat_client._prepare_options([], chat_options) # type: ignore
|
||||
|
||||
from azure.ai.agents.models import AgentsToolChoiceOptionMode
|
||||
|
||||
@@ -669,10 +671,10 @@ async def test_azure_ai_chat_client_prepare_tool_choice_none_string(
|
||||
assert chat_options.tool_choice == ToolMode.NONE.mode
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_create_run_options_tool_choice_required_specific_function(
|
||||
async def test_azure_ai_chat_client_prepare_options_tool_choice_required_specific_function(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _create_run_options with ToolMode.REQUIRED specifying a specific function name."""
|
||||
"""Test _prepare_options with ToolMode.REQUIRED specifying a specific function name."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
|
||||
|
||||
required_tool_mode = ToolMode.REQUIRED("specific_function_name")
|
||||
@@ -682,7 +684,7 @@ async def test_azure_ai_chat_client_create_run_options_tool_choice_required_spec
|
||||
chat_options = ChatOptions(tools=[dict_tool], tool_choice=required_tool_mode)
|
||||
messages = [ChatMessage(role=Role.USER, text="Hello")]
|
||||
|
||||
run_options, _ = await chat_client._create_run_options(messages, chat_options) # type: ignore
|
||||
run_options, _ = await chat_client._prepare_options(messages, chat_options) # type: ignore
|
||||
|
||||
# Verify tool_choice is set to the specific named function
|
||||
assert "tool_choice" in run_options
|
||||
@@ -692,10 +694,10 @@ async def test_azure_ai_chat_client_create_run_options_tool_choice_required_spec
|
||||
assert tool_choice.function.name == "specific_function_name" # type: ignore
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_create_run_options_with_response_format(
|
||||
async def test_azure_ai_chat_client_prepare_options_with_response_format(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _create_run_options with response_format configured."""
|
||||
"""Test _prepare_options with response_format configured."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
|
||||
|
||||
class TestResponseModel(BaseModel):
|
||||
@@ -704,7 +706,7 @@ async def test_azure_ai_chat_client_create_run_options_with_response_format(
|
||||
chat_options = ChatOptions()
|
||||
chat_options.response_format = TestResponseModel
|
||||
|
||||
run_options, _ = await chat_client._create_run_options([], chat_options) # type: ignore
|
||||
run_options, _ = await chat_client._prepare_options([], chat_options) # type: ignore
|
||||
|
||||
assert "response_format" in run_options
|
||||
response_format = run_options["response_format"]
|
||||
@@ -720,8 +722,8 @@ def test_azure_ai_chat_client_service_url_method(mock_agents_client: MagicMock)
|
||||
assert url == "https://test-endpoint.com/"
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_prep_tools_ai_function(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prep_tools with AIFunction tool."""
|
||||
async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_ai_function(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prepare_tools_for_azure_ai with AIFunction tool."""
|
||||
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
@@ -729,28 +731,28 @@ async def test_azure_ai_chat_client_prep_tools_ai_function(mock_agents_client: M
|
||||
mock_ai_function = MagicMock(spec=AIFunction)
|
||||
mock_ai_function.to_json_schema_spec.return_value = {"type": "function", "function": {"name": "test_function"}}
|
||||
|
||||
result = await chat_client._prep_tools([mock_ai_function]) # type: ignore
|
||||
result = await chat_client._prepare_tools_for_azure_ai([mock_ai_function]) # type: ignore
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == {"type": "function", "function": {"name": "test_function"}}
|
||||
mock_ai_function.to_json_schema_spec.assert_called_once()
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_prep_tools_code_interpreter(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prep_tools with HostedCodeInterpreterTool."""
|
||||
async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_code_interpreter(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prepare_tools_for_azure_ai with HostedCodeInterpreterTool."""
|
||||
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
code_interpreter_tool = HostedCodeInterpreterTool()
|
||||
|
||||
result = await chat_client._prep_tools([code_interpreter_tool]) # type: ignore
|
||||
result = await chat_client._prepare_tools_for_azure_ai([code_interpreter_tool]) # type: ignore
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], CodeInterpreterToolDefinition)
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_prep_tools_mcp_tool(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prep_tools with HostedMCPTool."""
|
||||
async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_mcp_tool(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prepare_tools_for_azure_ai with HostedMCPTool."""
|
||||
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
@@ -762,7 +764,7 @@ async def test_azure_ai_chat_client_prep_tools_mcp_tool(mock_agents_client: Magi
|
||||
mock_mcp_tool.definitions = [{"type": "mcp", "name": "test_mcp"}]
|
||||
mock_mcp_tool_class.return_value = mock_mcp_tool
|
||||
|
||||
result = await chat_client._prep_tools([mcp_tool]) # type: ignore
|
||||
result = await chat_client._prepare_tools_for_azure_ai([mcp_tool]) # type: ignore
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == {"type": "mcp", "name": "test_mcp"}
|
||||
@@ -774,8 +776,8 @@ async def test_azure_ai_chat_client_prep_tools_mcp_tool(mock_agents_client: Magi
|
||||
assert set(call_args["allowed_tools"]) == {"tool1", "tool2"}
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_create_run_options_mcp_never_require(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _create_run_options with HostedMCPTool having never_require approval mode."""
|
||||
async def test_azure_ai_chat_client_prepare_options_mcp_never_require(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prepare_options with HostedMCPTool having never_require approval mode."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
|
||||
|
||||
mcp_tool = HostedMCPTool(name="Test MCP Tool", url="https://example.com/mcp", approval_mode="never_require")
|
||||
@@ -784,12 +786,12 @@ async def test_azure_ai_chat_client_create_run_options_mcp_never_require(mock_ag
|
||||
chat_options = ChatOptions(tools=[mcp_tool], tool_choice="auto")
|
||||
|
||||
with patch("agent_framework_azure_ai._chat_client.McpTool") as mock_mcp_tool_class:
|
||||
# Mock _prep_tools to avoid actual tool preparation
|
||||
# Mock _prepare_tools_for_azure_ai to avoid actual tool preparation
|
||||
mock_mcp_tool_instance = MagicMock()
|
||||
mock_mcp_tool_instance.definitions = [{"type": "mcp", "name": "test_mcp"}]
|
||||
mock_mcp_tool_class.return_value = mock_mcp_tool_instance
|
||||
|
||||
run_options, _ = await chat_client._create_run_options(messages, chat_options) # type: ignore
|
||||
run_options, _ = await chat_client._prepare_options(messages, chat_options) # type: ignore
|
||||
|
||||
# Verify tool_resources is created with correct MCP approval structure
|
||||
assert "tool_resources" in run_options, (
|
||||
@@ -803,8 +805,8 @@ async def test_azure_ai_chat_client_create_run_options_mcp_never_require(mock_ag
|
||||
assert mcp_resource["require_approval"] == "never"
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_create_run_options_mcp_with_headers(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _create_run_options with HostedMCPTool having headers."""
|
||||
async def test_azure_ai_chat_client_prepare_options_mcp_with_headers(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prepare_options with HostedMCPTool having headers."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
|
||||
|
||||
# Test with headers
|
||||
@@ -817,12 +819,12 @@ async def test_azure_ai_chat_client_create_run_options_mcp_with_headers(mock_age
|
||||
chat_options = ChatOptions(tools=[mcp_tool], tool_choice="auto")
|
||||
|
||||
with patch("agent_framework_azure_ai._chat_client.McpTool") as mock_mcp_tool_class:
|
||||
# Mock _prep_tools to avoid actual tool preparation
|
||||
# Mock _prepare_tools_for_azure_ai to avoid actual tool preparation
|
||||
mock_mcp_tool_instance = MagicMock()
|
||||
mock_mcp_tool_instance.definitions = [{"type": "mcp", "name": "test_mcp"}]
|
||||
mock_mcp_tool_class.return_value = mock_mcp_tool_instance
|
||||
|
||||
run_options, _ = await chat_client._create_run_options(messages, chat_options) # type: ignore
|
||||
run_options, _ = await chat_client._prepare_options(messages, chat_options) # type: ignore
|
||||
|
||||
# Verify tool_resources is created with headers
|
||||
assert "tool_resources" in run_options
|
||||
@@ -835,8 +837,10 @@ async def test_azure_ai_chat_client_create_run_options_mcp_with_headers(mock_age
|
||||
assert mcp_resource["headers"] == headers
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_prep_tools_web_search_bing_grounding(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prep_tools with HostedWebSearchTool using Bing Grounding."""
|
||||
async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_bing_grounding(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _prepare_tools_for_azure_ai with HostedWebSearchTool using Bing Grounding."""
|
||||
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
@@ -856,7 +860,7 @@ async def test_azure_ai_chat_client_prep_tools_web_search_bing_grounding(mock_ag
|
||||
mock_bing_tool.definitions = [{"type": "bing_grounding"}]
|
||||
mock_bing_grounding.return_value = mock_bing_tool
|
||||
|
||||
result = await chat_client._prep_tools([web_search_tool]) # type: ignore
|
||||
result = await chat_client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == {"type": "bing_grounding"}
|
||||
@@ -868,10 +872,10 @@ async def test_azure_ai_chat_client_prep_tools_web_search_bing_grounding(mock_ag
|
||||
assert "connection_id" in call_args
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_prep_tools_web_search_bing_grounding_with_connection_id(
|
||||
async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_bing_grounding_with_connection_id(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _prep_tools with HostedWebSearchTool using Bing Grounding with connection_id (no HTTP call)."""
|
||||
"""Test _prepare_tools_... with HostedWebSearchTool using Bing Grounding with connection_id (no HTTP call)."""
|
||||
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
@@ -888,15 +892,17 @@ async def test_azure_ai_chat_client_prep_tools_web_search_bing_grounding_with_co
|
||||
mock_bing_tool.definitions = [{"type": "bing_grounding"}]
|
||||
mock_bing_grounding.return_value = mock_bing_tool
|
||||
|
||||
result = await chat_client._prep_tools([web_search_tool]) # type: ignore
|
||||
result = await chat_client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == {"type": "bing_grounding"}
|
||||
mock_bing_grounding.assert_called_once_with(connection_id="direct-connection-id", count=3)
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_prep_tools_web_search_custom_bing(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prep_tools with HostedWebSearchTool using Custom Bing Search."""
|
||||
async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_custom_bing(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _prepare_tools_for_azure_ai with HostedWebSearchTool using Custom Bing Search."""
|
||||
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
@@ -914,16 +920,16 @@ async def test_azure_ai_chat_client_prep_tools_web_search_custom_bing(mock_agent
|
||||
mock_custom_tool.definitions = [{"type": "bing_custom_search"}]
|
||||
mock_custom_bing.return_value = mock_custom_tool
|
||||
|
||||
result = await chat_client._prep_tools([web_search_tool]) # type: ignore
|
||||
result = await chat_client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == {"type": "bing_custom_search"}
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_prep_tools_file_search_with_vector_stores(
|
||||
async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_file_search_with_vector_stores(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _prep_tools with HostedFileSearchTool using vector stores."""
|
||||
"""Test _prepare_tools_for_azure_ai with HostedFileSearchTool using vector stores."""
|
||||
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
@@ -938,7 +944,7 @@ async def test_azure_ai_chat_client_prep_tools_file_search_with_vector_stores(
|
||||
mock_file_search.return_value = mock_file_tool
|
||||
|
||||
run_options = {}
|
||||
result = await chat_client._prep_tools([file_search_tool], run_options) # type: ignore
|
||||
result = await chat_client._prepare_tools_for_azure_ai([file_search_tool], run_options) # type: ignore
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == {"type": "file_search"}
|
||||
@@ -973,7 +979,7 @@ async def test_azure_ai_chat_client_create_agent_stream_submit_tool_approvals(
|
||||
|
||||
with patch("azure.ai.agents.models.AsyncAgentEventHandler", return_value=mock_handler):
|
||||
stream, final_thread_id = await chat_client._create_agent_stream( # type: ignore
|
||||
"test-thread", "test-agent", {}, [approval_response]
|
||||
"test-agent", {"thread_id": "test-thread"}, [approval_response]
|
||||
)
|
||||
|
||||
# Verify the approvals path was taken
|
||||
@@ -987,26 +993,26 @@ async def test_azure_ai_chat_client_create_agent_stream_submit_tool_approvals(
|
||||
assert call_args["tool_approvals"][0].approve is True
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_prep_tools_dict_tool(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prep_tools with dictionary tool definition."""
|
||||
async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_dict_tool(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prepare_tools_for_azure_ai with dictionary tool definition."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
dict_tool = {"type": "custom_tool", "config": {"param": "value"}}
|
||||
|
||||
result = await chat_client._prep_tools([dict_tool]) # type: ignore
|
||||
result = await chat_client._prepare_tools_for_azure_ai([dict_tool]) # type: ignore
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == dict_tool
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_prep_tools_unsupported_tool(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prep_tools with unsupported tool type."""
|
||||
async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_unsupported_tool(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _prepare_tools_for_azure_ai with unsupported tool type."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
unsupported_tool = "not_a_tool"
|
||||
|
||||
with pytest.raises(ServiceInitializationError, match="Unsupported tool type: <class 'str'>"):
|
||||
await chat_client._prep_tools([unsupported_tool]) # type: ignore
|
||||
await chat_client._prepare_tools_for_azure_ai([unsupported_tool]) # type: ignore
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_get_active_thread_run_with_active_run(mock_agents_client: MagicMock) -> None:
|
||||
@@ -1072,16 +1078,16 @@ async def test_azure_ai_chat_client_service_url(mock_agents_client: MagicMock) -
|
||||
assert result == "https://test-endpoint.com/"
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_convert_required_action_to_tool_output_function_result(
|
||||
async def test_azure_ai_chat_client_prepare_tool_outputs_for_azure_ai_function_result(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _convert_required_action_to_tool_output with FunctionResultContent."""
|
||||
"""Test _prepare_tool_outputs_for_azure_ai with FunctionResultContent."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
# Test with simple result
|
||||
function_result = FunctionResultContent(call_id='["run_123", "call_456"]', result="Simple result")
|
||||
|
||||
run_id, tool_outputs, tool_approvals = chat_client._convert_required_action_to_tool_output([function_result]) # type: ignore
|
||||
run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore
|
||||
|
||||
assert run_id == "run_123"
|
||||
assert tool_approvals is None
|
||||
@@ -1092,7 +1098,7 @@ async def test_azure_ai_chat_client_convert_required_action_to_tool_output_funct
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_convert_required_action_invalid_call_id(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _convert_required_action_to_tool_output with invalid call_id format."""
|
||||
"""Test _prepare_tool_outputs_for_azure_ai with invalid call_id format."""
|
||||
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
@@ -1100,19 +1106,19 @@ async def test_azure_ai_chat_client_convert_required_action_invalid_call_id(mock
|
||||
function_result = FunctionResultContent(call_id="invalid_json", result="result")
|
||||
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
chat_client._convert_required_action_to_tool_output([function_result]) # type: ignore
|
||||
chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_convert_required_action_invalid_structure(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _convert_required_action_to_tool_output with invalid call_id structure."""
|
||||
"""Test _prepare_tool_outputs_for_azure_ai with invalid call_id structure."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
# Valid JSON but invalid structure (missing second element)
|
||||
function_result = FunctionResultContent(call_id='["run_123"]', result="result")
|
||||
|
||||
run_id, tool_outputs, tool_approvals = chat_client._convert_required_action_to_tool_output([function_result]) # type: ignore
|
||||
run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore
|
||||
|
||||
# Should return None values when structure is invalid
|
||||
assert run_id is None
|
||||
@@ -1123,7 +1129,7 @@ async def test_azure_ai_chat_client_convert_required_action_invalid_structure(
|
||||
async def test_azure_ai_chat_client_convert_required_action_serde_model_results(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _convert_required_action_to_tool_output with BaseModel results."""
|
||||
"""Test _prepare_tool_outputs_for_azure_ai with BaseModel results."""
|
||||
|
||||
class MockResult(SerializationMixin):
|
||||
def __init__(self, name: str, value: int):
|
||||
@@ -1136,7 +1142,7 @@ async def test_azure_ai_chat_client_convert_required_action_serde_model_results(
|
||||
mock_result = MockResult(name="test", value=42)
|
||||
function_result = FunctionResultContent(call_id='["run_123", "call_456"]', result=mock_result)
|
||||
|
||||
run_id, tool_outputs, tool_approvals = chat_client._convert_required_action_to_tool_output([function_result]) # type: ignore
|
||||
run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore
|
||||
|
||||
assert run_id == "run_123"
|
||||
assert tool_approvals is None
|
||||
@@ -1151,7 +1157,7 @@ async def test_azure_ai_chat_client_convert_required_action_serde_model_results(
|
||||
async def test_azure_ai_chat_client_convert_required_action_multiple_results(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _convert_required_action_to_tool_output with multiple results."""
|
||||
"""Test _prepare_tool_outputs_for_azure_ai with multiple results."""
|
||||
|
||||
class MockResult(SerializationMixin):
|
||||
def __init__(self, data: str):
|
||||
@@ -1164,7 +1170,7 @@ async def test_azure_ai_chat_client_convert_required_action_multiple_results(
|
||||
results_list = [mock_basemodel, {"key": "value"}, "string_result"]
|
||||
function_result = FunctionResultContent(call_id='["run_123", "call_456"]', result=results_list)
|
||||
|
||||
run_id, tool_outputs, tool_approvals = chat_client._convert_required_action_to_tool_output([function_result]) # type: ignore
|
||||
run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore
|
||||
|
||||
assert run_id == "run_123"
|
||||
assert tool_outputs is not None
|
||||
@@ -1184,7 +1190,7 @@ async def test_azure_ai_chat_client_convert_required_action_multiple_results(
|
||||
async def test_azure_ai_chat_client_convert_required_action_approval_response(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _convert_required_action_to_tool_output with FunctionApprovalResponseContent."""
|
||||
"""Test _prepare_tool_outputs_for_azure_ai with FunctionApprovalResponseContent."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
# Test with approval response - need to provide required fields
|
||||
@@ -1194,7 +1200,7 @@ async def test_azure_ai_chat_client_convert_required_action_approval_response(
|
||||
approved=True,
|
||||
)
|
||||
|
||||
run_id, tool_outputs, tool_approvals = chat_client._convert_required_action_to_tool_output([approval_response]) # type: ignore
|
||||
run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([approval_response]) # type: ignore
|
||||
|
||||
assert run_id == "run_123"
|
||||
assert tool_outputs is None
|
||||
@@ -1204,10 +1210,10 @@ async def test_azure_ai_chat_client_convert_required_action_approval_response(
|
||||
assert tool_approvals[0].approve is True
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_create_function_call_contents_approval_request(
|
||||
async def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_approval_request(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _create_function_call_contents with approval action."""
|
||||
"""Test _parse_function_calls_from_azure_ai with approval action."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
# Mock SubmitToolApprovalAction with RequiredMcpToolCall
|
||||
@@ -1222,7 +1228,7 @@ async def test_azure_ai_chat_client_create_function_call_contents_approval_reque
|
||||
mock_event_data = MagicMock(spec=ThreadRun)
|
||||
mock_event_data.required_action = mock_approval_action
|
||||
|
||||
result = chat_client._create_function_call_contents(mock_event_data, "response_123") # type: ignore
|
||||
result = chat_client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], FunctionApprovalRequestContent)
|
||||
@@ -1312,7 +1318,7 @@ async def test_azure_ai_chat_client_create_agent_stream_submit_tool_outputs(
|
||||
|
||||
with patch("azure.ai.agents.models.AsyncAgentEventHandler", return_value=mock_handler):
|
||||
stream, final_thread_id = await chat_client._create_agent_stream( # type: ignore
|
||||
thread_id="test-thread", agent_id="test-agent", run_options={}, required_action_results=[function_result]
|
||||
agent_id="test-agent", run_options={"thread_id": "test-thread"}, required_action_results=[function_result]
|
||||
)
|
||||
|
||||
# Should call submit_tool_outputs_stream since we have matching run ID
|
||||
|
||||
@@ -249,10 +249,10 @@ async def test_azure_ai_client_get_agent_reference_missing_model(
|
||||
await client._get_agent_reference_or_create({}, None) # type: ignore
|
||||
|
||||
|
||||
async def test_azure_ai_client_prepare_input_with_system_messages(
|
||||
async def test_azure_ai_client_prepare_messages_for_azure_ai_with_system_messages(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _prepare_input converts system/developer messages to instructions."""
|
||||
"""Test _prepare_messages_for_azure_ai converts system/developer messages to instructions."""
|
||||
client = create_test_azure_ai_client(mock_project_client)
|
||||
|
||||
messages = [
|
||||
@@ -261,7 +261,7 @@ async def test_azure_ai_client_prepare_input_with_system_messages(
|
||||
ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text="System response")]),
|
||||
]
|
||||
|
||||
result_messages, instructions = client._prepare_input(messages) # type: ignore
|
||||
result_messages, instructions = client._prepare_messages_for_azure_ai(messages) # type: ignore
|
||||
|
||||
assert len(result_messages) == 2
|
||||
assert result_messages[0].role == Role.USER
|
||||
@@ -269,10 +269,10 @@ async def test_azure_ai_client_prepare_input_with_system_messages(
|
||||
assert instructions == "You are a helpful assistant."
|
||||
|
||||
|
||||
async def test_azure_ai_client_prepare_input_no_system_messages(
|
||||
async def test_azure_ai_client_prepare_messages_for_azure_ai_no_system_messages(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _prepare_input with no system/developer messages."""
|
||||
"""Test _prepare_messages_for_azure_ai with no system/developer messages."""
|
||||
client = create_test_azure_ai_client(mock_project_client)
|
||||
|
||||
messages = [
|
||||
@@ -280,7 +280,7 @@ async def test_azure_ai_client_prepare_input_no_system_messages(
|
||||
ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text="Hi there!")]),
|
||||
]
|
||||
|
||||
result_messages, instructions = client._prepare_input(messages) # type: ignore
|
||||
result_messages, instructions = client._prepare_messages_for_azure_ai(messages) # type: ignore
|
||||
|
||||
assert len(result_messages) == 2
|
||||
assert instructions is None
|
||||
@@ -294,14 +294,14 @@ async def test_azure_ai_client_prepare_options_basic(mock_project_client: MagicM
|
||||
chat_options = ChatOptions()
|
||||
|
||||
with (
|
||||
patch.object(client.__class__.__bases__[0], "prepare_options", return_value={"model": "test-model"}),
|
||||
patch.object(client.__class__.__bases__[0], "_prepare_options", return_value={"model": "test-model"}),
|
||||
patch.object(
|
||||
client,
|
||||
"_get_agent_reference_or_create",
|
||||
return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"},
|
||||
),
|
||||
):
|
||||
run_options = await client.prepare_options(messages, chat_options)
|
||||
run_options = await client._prepare_options(messages, chat_options)
|
||||
|
||||
assert "extra_body" in run_options
|
||||
assert run_options["extra_body"]["agent"]["name"] == "test-agent"
|
||||
@@ -329,14 +329,14 @@ async def test_azure_ai_client_prepare_options_with_application_endpoint(
|
||||
chat_options = ChatOptions()
|
||||
|
||||
with (
|
||||
patch.object(client.__class__.__bases__[0], "prepare_options", return_value={"model": "test-model"}),
|
||||
patch.object(client.__class__.__bases__[0], "_prepare_options", return_value={"model": "test-model"}),
|
||||
patch.object(
|
||||
client,
|
||||
"_get_agent_reference_or_create",
|
||||
return_value={"name": "test-agent", "version": "1", "type": "agent_reference"},
|
||||
),
|
||||
):
|
||||
run_options = await client.prepare_options(messages, chat_options)
|
||||
run_options = await client._prepare_options(messages, chat_options)
|
||||
|
||||
if expects_agent:
|
||||
assert "extra_body" in run_options
|
||||
@@ -369,14 +369,14 @@ async def test_azure_ai_client_prepare_options_with_application_project_client(
|
||||
chat_options = ChatOptions()
|
||||
|
||||
with (
|
||||
patch.object(client.__class__.__bases__[0], "prepare_options", return_value={"model": "test-model"}),
|
||||
patch.object(client.__class__.__bases__[0], "_prepare_options", return_value={"model": "test-model"}),
|
||||
patch.object(
|
||||
client,
|
||||
"_get_agent_reference_or_create",
|
||||
return_value={"name": "test-agent", "version": "1", "type": "agent_reference"},
|
||||
),
|
||||
):
|
||||
run_options = await client.prepare_options(messages, chat_options)
|
||||
run_options = await client._prepare_options(messages, chat_options)
|
||||
|
||||
if expects_agent:
|
||||
assert "extra_body" in run_options
|
||||
@@ -386,13 +386,13 @@ async def test_azure_ai_client_prepare_options_with_application_project_client(
|
||||
|
||||
|
||||
async def test_azure_ai_client_initialize_client(mock_project_client: MagicMock) -> None:
|
||||
"""Test initialize_client method."""
|
||||
"""Test _initialize_client method."""
|
||||
client = create_test_azure_ai_client(mock_project_client)
|
||||
|
||||
mock_openai_client = MagicMock()
|
||||
mock_project_client.get_openai_client = MagicMock(return_value=mock_openai_client)
|
||||
|
||||
await client.initialize_client()
|
||||
await client._initialize_client()
|
||||
|
||||
assert client.client is mock_openai_client
|
||||
mock_project_client.get_openai_client.assert_called_once()
|
||||
@@ -477,6 +477,30 @@ async def test_azure_ai_client_agent_creation_with_instructions(
|
||||
assert call_args[1]["definition"].instructions == "Message instructions. Option instructions. "
|
||||
|
||||
|
||||
async def test_azure_ai_client_agent_creation_with_additional_args(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test agent creation with additional arguments."""
|
||||
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent")
|
||||
|
||||
# Mock agent creation response
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.name = "test-agent"
|
||||
mock_agent.version = "1.0"
|
||||
mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent)
|
||||
|
||||
run_options = {"model": "test-model", "temperature": 0.9, "top_p": 0.8}
|
||||
messages_instructions = "Message instructions. "
|
||||
|
||||
await client._get_agent_reference_or_create(run_options, messages_instructions) # type: ignore
|
||||
|
||||
# Verify agent was created with provided arguments
|
||||
call_args = mock_project_client.agents.create_version.call_args
|
||||
definition = call_args[1]["definition"]
|
||||
assert definition.temperature == 0.9
|
||||
assert definition.top_p == 0.8
|
||||
|
||||
|
||||
async def test_azure_ai_client_agent_creation_with_tools(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
@@ -703,7 +727,7 @@ async def test_azure_ai_client_prepare_options_excludes_response_format(
|
||||
with (
|
||||
patch.object(
|
||||
client.__class__.__bases__[0],
|
||||
"prepare_options",
|
||||
"_prepare_options",
|
||||
return_value={"model": "test-model", "response_format": ResponseFormatModel},
|
||||
),
|
||||
patch.object(
|
||||
@@ -712,7 +736,7 @@ async def test_azure_ai_client_prepare_options_excludes_response_format(
|
||||
return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"},
|
||||
),
|
||||
):
|
||||
run_options = await client.prepare_options(messages, chat_options)
|
||||
run_options = await client._prepare_options(messages, chat_options)
|
||||
|
||||
# response_format should be excluded from final run options
|
||||
assert "response_format" not in run_options
|
||||
@@ -721,94 +745,8 @@ async def test_azure_ai_client_prepare_options_excludes_response_format(
|
||||
assert run_options["extra_body"]["agent"]["name"] == "test-agent"
|
||||
|
||||
|
||||
async def test_azure_ai_client_prepare_options_with_resp_conversation_id(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test prepare_options with conversation ID starting with 'resp_'."""
|
||||
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0")
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
|
||||
chat_options = ChatOptions(conversation_id="resp_12345")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client.__class__.__bases__[0],
|
||||
"prepare_options",
|
||||
return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"},
|
||||
),
|
||||
patch.object(
|
||||
client,
|
||||
"_get_agent_reference_or_create",
|
||||
return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"},
|
||||
),
|
||||
):
|
||||
run_options = await client.prepare_options(messages, chat_options)
|
||||
|
||||
# Should set previous_response_id and remove conversation property
|
||||
assert run_options["previous_response_id"] == "resp_12345"
|
||||
assert "conversation" not in run_options
|
||||
|
||||
|
||||
async def test_azure_ai_client_prepare_options_with_conv_conversation_id(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test prepare_options with conversation ID starting with 'conv_'."""
|
||||
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0")
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
|
||||
chat_options = ChatOptions(conversation_id="conv_67890")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client.__class__.__bases__[0],
|
||||
"prepare_options",
|
||||
return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"},
|
||||
),
|
||||
patch.object(
|
||||
client,
|
||||
"_get_agent_reference_or_create",
|
||||
return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"},
|
||||
),
|
||||
):
|
||||
run_options = await client.prepare_options(messages, chat_options)
|
||||
|
||||
# Should set conversation and remove previous_response_id property
|
||||
assert run_options["conversation"] == "conv_67890"
|
||||
assert "previous_response_id" not in run_options
|
||||
|
||||
|
||||
async def test_azure_ai_client_prepare_options_with_client_conversation_id(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test prepare_options using client's default conversation ID when chat options don't have one."""
|
||||
client = create_test_azure_ai_client(
|
||||
mock_project_client, agent_name="test-agent", agent_version="1.0", conversation_id="resp_client_default"
|
||||
)
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
|
||||
chat_options = ChatOptions() # No conversation_id specified
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client.__class__.__bases__[0],
|
||||
"prepare_options",
|
||||
return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"},
|
||||
),
|
||||
patch.object(
|
||||
client,
|
||||
"_get_agent_reference_or_create",
|
||||
return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"},
|
||||
),
|
||||
):
|
||||
run_options = await client.prepare_options(messages, chat_options)
|
||||
|
||||
# Should use client's default conversation_id and set previous_response_id
|
||||
assert run_options["previous_response_id"] == "resp_client_default"
|
||||
assert "conversation" not in run_options
|
||||
|
||||
|
||||
def test_get_conversation_id_with_store_true_and_conversation_id() -> None:
|
||||
"""Test get_conversation_id returns conversation ID when store is True and conversation exists."""
|
||||
"""Test _get_conversation_id returns conversation ID when store is True and conversation exists."""
|
||||
client = create_test_azure_ai_client(MagicMock())
|
||||
|
||||
# Mock OpenAI response with conversation
|
||||
@@ -818,13 +756,13 @@ def test_get_conversation_id_with_store_true_and_conversation_id() -> None:
|
||||
mock_conversation.id = "conv_67890"
|
||||
mock_response.conversation = mock_conversation
|
||||
|
||||
result = client.get_conversation_id(mock_response, store=True)
|
||||
result = client._get_conversation_id(mock_response, store=True)
|
||||
|
||||
assert result == "conv_67890"
|
||||
|
||||
|
||||
def test_get_conversation_id_with_store_true_and_no_conversation() -> None:
|
||||
"""Test get_conversation_id returns response ID when store is True and no conversation exists."""
|
||||
"""Test _get_conversation_id returns response ID when store is True and no conversation exists."""
|
||||
client = create_test_azure_ai_client(MagicMock())
|
||||
|
||||
# Mock OpenAI response without conversation
|
||||
@@ -832,13 +770,13 @@ def test_get_conversation_id_with_store_true_and_no_conversation() -> None:
|
||||
mock_response.id = "resp_12345"
|
||||
mock_response.conversation = None
|
||||
|
||||
result = client.get_conversation_id(mock_response, store=True)
|
||||
result = client._get_conversation_id(mock_response, store=True)
|
||||
|
||||
assert result == "resp_12345"
|
||||
|
||||
|
||||
def test_get_conversation_id_with_store_true_and_empty_conversation_id() -> None:
|
||||
"""Test get_conversation_id returns response ID when store is True and conversation ID is empty."""
|
||||
"""Test _get_conversation_id returns response ID when store is True and conversation ID is empty."""
|
||||
client = create_test_azure_ai_client(MagicMock())
|
||||
|
||||
# Mock OpenAI response with conversation but empty ID
|
||||
@@ -848,13 +786,13 @@ def test_get_conversation_id_with_store_true_and_empty_conversation_id() -> None
|
||||
mock_conversation.id = ""
|
||||
mock_response.conversation = mock_conversation
|
||||
|
||||
result = client.get_conversation_id(mock_response, store=True)
|
||||
result = client._get_conversation_id(mock_response, store=True)
|
||||
|
||||
assert result == "resp_12345"
|
||||
|
||||
|
||||
def test_get_conversation_id_with_store_false() -> None:
|
||||
"""Test get_conversation_id returns None when store is False."""
|
||||
"""Test _get_conversation_id returns None when store is False."""
|
||||
client = create_test_azure_ai_client(MagicMock())
|
||||
|
||||
# Mock OpenAI response with conversation
|
||||
@@ -864,13 +802,13 @@ def test_get_conversation_id_with_store_false() -> None:
|
||||
mock_conversation.id = "conv_67890"
|
||||
mock_response.conversation = mock_conversation
|
||||
|
||||
result = client.get_conversation_id(mock_response, store=False)
|
||||
result = client._get_conversation_id(mock_response, store=False)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_get_conversation_id_with_parsed_response_and_store_true() -> None:
|
||||
"""Test get_conversation_id works with ParsedResponse when store is True."""
|
||||
"""Test _get_conversation_id works with ParsedResponse when store is True."""
|
||||
client = create_test_azure_ai_client(MagicMock())
|
||||
|
||||
# Mock ParsedResponse with conversation
|
||||
@@ -880,13 +818,13 @@ def test_get_conversation_id_with_parsed_response_and_store_true() -> None:
|
||||
mock_conversation.id = "conv_parsed_67890"
|
||||
mock_response.conversation = mock_conversation
|
||||
|
||||
result = client.get_conversation_id(mock_response, store=True)
|
||||
result = client._get_conversation_id(mock_response, store=True)
|
||||
|
||||
assert result == "conv_parsed_67890"
|
||||
|
||||
|
||||
def test_get_conversation_id_with_parsed_response_no_conversation() -> None:
|
||||
"""Test get_conversation_id returns response ID with ParsedResponse when no conversation exists."""
|
||||
"""Test _get_conversation_id returns response ID with ParsedResponse when no conversation exists."""
|
||||
client = create_test_azure_ai_client(MagicMock())
|
||||
|
||||
# Mock ParsedResponse without conversation
|
||||
@@ -894,7 +832,7 @@ def test_get_conversation_id_with_parsed_response_no_conversation() -> None:
|
||||
mock_response.id = "resp_parsed_12345"
|
||||
mock_response.conversation = None
|
||||
|
||||
result = client.get_conversation_id(mock_response, store=True)
|
||||
result = client._get_conversation_id(mock_response, store=True)
|
||||
|
||||
assert result == "resp_parsed_12345"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,19 @@
|
||||
# Get Started with Microsoft Agent Framework Bedrock
|
||||
|
||||
Install the provider package:
|
||||
|
||||
```bash
|
||||
pip install agent-framework-bedrock --pre
|
||||
```
|
||||
|
||||
## Bedrock Integration
|
||||
|
||||
The Bedrock integration enables Microsoft Agent Framework applications to call Amazon Bedrock models with familiar chat abstractions, including tool/function calling when you attach tools through `ChatOptions`.
|
||||
|
||||
### Basic Usage Example
|
||||
|
||||
See the [Bedrock sample script](samples/bedrock_sample.py) for a runnable end-to-end script that:
|
||||
|
||||
- Loads credentials from the `BEDROCK_*` environment variables
|
||||
- Instantiates `BedrockChatClient`
|
||||
- Sends a simple conversation turn and prints the response
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._chat_client import BedrockChatClient
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = [
|
||||
"BedrockChatClient",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,527 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections import deque
|
||||
from collections.abc import AsyncIterable, MutableMapping, MutableSequence, Sequence
|
||||
from typing import Any, ClassVar
|
||||
from uuid import uuid4
|
||||
|
||||
from agent_framework import (
|
||||
AGENT_FRAMEWORK_USER_AGENT,
|
||||
AIFunction,
|
||||
BaseChatClient,
|
||||
ChatMessage,
|
||||
ChatOptions,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
Contents,
|
||||
FinishReason,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
Role,
|
||||
TextContent,
|
||||
ToolProtocol,
|
||||
UsageContent,
|
||||
UsageDetails,
|
||||
get_logger,
|
||||
prepare_function_call_results,
|
||||
use_chat_middleware,
|
||||
use_function_invocation,
|
||||
)
|
||||
from agent_framework._pydantic import AFBaseSettings
|
||||
from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidResponseError
|
||||
from agent_framework.observability import use_instrumentation
|
||||
from boto3.session import Session as Boto3Session
|
||||
from botocore.client import BaseClient
|
||||
from botocore.config import Config as BotoConfig
|
||||
from pydantic import SecretStr, ValidationError
|
||||
|
||||
logger = get_logger("agent_framework.bedrock")
|
||||
|
||||
DEFAULT_REGION = "us-east-1"
|
||||
DEFAULT_MAX_TOKENS = 1024
|
||||
|
||||
ROLE_MAP: dict[Role, str] = {
|
||||
Role.USER: "user",
|
||||
Role.ASSISTANT: "assistant",
|
||||
Role.SYSTEM: "user",
|
||||
Role.TOOL: "user",
|
||||
}
|
||||
|
||||
FINISH_REASON_MAP: dict[str, FinishReason] = {
|
||||
"end_turn": FinishReason.STOP,
|
||||
"stop_sequence": FinishReason.STOP,
|
||||
"max_tokens": FinishReason.LENGTH,
|
||||
"length": FinishReason.LENGTH,
|
||||
"content_filtered": FinishReason.CONTENT_FILTER,
|
||||
"tool_use": FinishReason.TOOL_CALLS,
|
||||
}
|
||||
|
||||
|
||||
class BedrockSettings(AFBaseSettings):
|
||||
"""Bedrock configuration settings pulled from environment variables or .env files."""
|
||||
|
||||
env_prefix: ClassVar[str] = "BEDROCK_"
|
||||
|
||||
region: str = DEFAULT_REGION
|
||||
chat_model_id: str | None = None
|
||||
access_key: SecretStr | None = None
|
||||
secret_key: SecretStr | None = None
|
||||
session_token: SecretStr | None = None
|
||||
|
||||
|
||||
@use_function_invocation
|
||||
@use_instrumentation
|
||||
@use_chat_middleware
|
||||
class BedrockChatClient(BaseChatClient):
|
||||
"""Async chat client for Amazon Bedrock's Converse API."""
|
||||
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "aws.bedrock" # type: ignore[reportIncompatibleVariableOverride, misc]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
region: str | None = None,
|
||||
model_id: str | None = None,
|
||||
access_key: str | None = None,
|
||||
secret_key: str | None = None,
|
||||
session_token: str | None = None,
|
||||
client: BaseClient | None = None,
|
||||
boto3_session: Boto3Session | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Create a Bedrock chat client and load AWS credentials.
|
||||
|
||||
Args:
|
||||
region: Region to send Bedrock requests to; falls back to BEDROCK_REGION.
|
||||
model_id: Default model identifier; falls back to BEDROCK_CHAT_MODEL_ID.
|
||||
access_key: Optional AWS access key for manual credential injection.
|
||||
secret_key: Optional AWS secret key paired with ``access_key``.
|
||||
session_token: Optional AWS session token for temporary credentials.
|
||||
client: Preconfigured Bedrock runtime client; when omitted a boto3 session is created.
|
||||
boto3_session: Custom boto3 session used to build the runtime client if provided.
|
||||
env_file_path: Optional .env file path used by ``BedrockSettings`` to load defaults.
|
||||
env_file_encoding: Encoding for the optional .env file.
|
||||
kwargs: Additional arguments forwarded to ``BaseChatClient``.
|
||||
"""
|
||||
try:
|
||||
settings = BedrockSettings(
|
||||
region=region,
|
||||
chat_model_id=model_id,
|
||||
access_key=access_key, # type: ignore[arg-type]
|
||||
secret_key=secret_key, # type: ignore[arg-type]
|
||||
session_token=session_token, # type: ignore[arg-type]
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
except ValidationError as ex:
|
||||
raise ServiceInitializationError("Failed to initialize Bedrock settings.", ex) from ex
|
||||
|
||||
if client is None:
|
||||
session = boto3_session or self._create_session(settings)
|
||||
client = session.client(
|
||||
"bedrock-runtime",
|
||||
region_name=settings.region,
|
||||
config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT),
|
||||
)
|
||||
|
||||
super().__init__(**kwargs)
|
||||
self._bedrock_client = client
|
||||
self.model_id = settings.chat_model_id
|
||||
self.region = settings.region
|
||||
|
||||
@staticmethod
|
||||
def _create_session(settings: BedrockSettings) -> Boto3Session:
|
||||
session_kwargs: dict[str, Any] = {"region_name": settings.region or DEFAULT_REGION}
|
||||
if settings.access_key and settings.secret_key:
|
||||
session_kwargs["aws_access_key_id"] = settings.access_key.get_secret_value()
|
||||
session_kwargs["aws_secret_access_key"] = settings.secret_key.get_secret_value()
|
||||
if settings.session_token:
|
||||
session_kwargs["aws_session_token"] = settings.session_token.get_secret_value()
|
||||
return Boto3Session(**session_kwargs)
|
||||
|
||||
async def _inner_get_response(
|
||||
self,
|
||||
*,
|
||||
messages: MutableSequence[ChatMessage],
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> ChatResponse:
|
||||
request = self._build_converse_request(messages, chat_options, **kwargs)
|
||||
raw_response = await asyncio.to_thread(self._bedrock_client.converse, **request)
|
||||
return self._process_converse_response(raw_response)
|
||||
|
||||
async def _inner_get_streaming_response(
|
||||
self,
|
||||
*,
|
||||
messages: MutableSequence[ChatMessage],
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[ChatResponseUpdate]:
|
||||
response = await self._inner_get_response(messages=messages, chat_options=chat_options, **kwargs)
|
||||
contents = list(response.messages[0].contents if response.messages else [])
|
||||
if response.usage_details:
|
||||
contents.append(UsageContent(details=response.usage_details))
|
||||
yield ChatResponseUpdate(
|
||||
response_id=response.response_id,
|
||||
contents=contents,
|
||||
model_id=response.model_id,
|
||||
finish_reason=response.finish_reason,
|
||||
raw_representation=response.raw_representation,
|
||||
)
|
||||
|
||||
def _build_converse_request(
|
||||
self,
|
||||
messages: MutableSequence[ChatMessage],
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
model_id = chat_options.model_id or self.model_id
|
||||
if not model_id:
|
||||
raise ServiceInitializationError(
|
||||
"Bedrock model_id is required. Set via chat options or BEDROCK_CHAT_MODEL_ID environment variable."
|
||||
)
|
||||
|
||||
system_prompts, conversation = self._prepare_bedrock_messages(messages)
|
||||
if not conversation:
|
||||
raise ServiceInitializationError("At least one non-system message is required for Bedrock requests.")
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"modelId": model_id,
|
||||
"messages": conversation,
|
||||
}
|
||||
if system_prompts:
|
||||
payload["system"] = system_prompts
|
||||
|
||||
inference_config: dict[str, Any] = {}
|
||||
inference_config["maxTokens"] = (
|
||||
chat_options.max_tokens if chat_options.max_tokens is not None else DEFAULT_MAX_TOKENS
|
||||
)
|
||||
if chat_options.temperature is not None:
|
||||
inference_config["temperature"] = chat_options.temperature
|
||||
if chat_options.top_p is not None:
|
||||
inference_config["topP"] = chat_options.top_p
|
||||
if chat_options.stop is not None:
|
||||
inference_config["stopSequences"] = chat_options.stop
|
||||
if inference_config:
|
||||
payload["inferenceConfig"] = inference_config
|
||||
|
||||
tool_config = self._convert_tools_to_bedrock_config(chat_options.tools)
|
||||
if tool_choice := self._convert_tool_choice(chat_options.tool_choice):
|
||||
if tool_config is None:
|
||||
tool_config = {}
|
||||
tool_config["toolChoice"] = tool_choice
|
||||
if tool_config:
|
||||
payload["toolConfig"] = tool_config
|
||||
|
||||
if chat_options.additional_properties:
|
||||
payload.update(chat_options.additional_properties)
|
||||
if kwargs:
|
||||
payload.update(kwargs)
|
||||
return payload
|
||||
|
||||
def _prepare_bedrock_messages(
|
||||
self, messages: Sequence[ChatMessage]
|
||||
) -> tuple[list[dict[str, str]], list[dict[str, Any]]]:
|
||||
prompts: list[dict[str, str]] = []
|
||||
conversation: list[dict[str, Any]] = []
|
||||
pending_tool_use_ids: deque[str] = deque()
|
||||
for message in messages:
|
||||
if message.role == Role.SYSTEM:
|
||||
text_value = message.text
|
||||
if text_value:
|
||||
prompts.append({"text": text_value})
|
||||
continue
|
||||
|
||||
content_blocks = self._convert_message_to_content_blocks(message)
|
||||
if not content_blocks:
|
||||
continue
|
||||
|
||||
role = ROLE_MAP.get(message.role, "user")
|
||||
if role == "assistant":
|
||||
pending_tool_use_ids = deque(
|
||||
block["toolUse"]["toolUseId"]
|
||||
for block in content_blocks
|
||||
if isinstance(block, MutableMapping) and "toolUse" in block
|
||||
)
|
||||
elif message.role == Role.TOOL:
|
||||
content_blocks = self._align_tool_results_with_pending(content_blocks, pending_tool_use_ids)
|
||||
pending_tool_use_ids.clear()
|
||||
if not content_blocks:
|
||||
continue
|
||||
else:
|
||||
pending_tool_use_ids.clear()
|
||||
|
||||
conversation.append({"role": role, "content": content_blocks})
|
||||
|
||||
return prompts, conversation
|
||||
|
||||
def _align_tool_results_with_pending(
|
||||
self, content_blocks: list[dict[str, Any]], pending_tool_use_ids: deque[str]
|
||||
) -> list[dict[str, Any]]:
|
||||
if not content_blocks:
|
||||
return content_blocks
|
||||
if not pending_tool_use_ids:
|
||||
# No pending tool calls; drop toolResult blocks to avoid Bedrock validation errors
|
||||
return [
|
||||
block for block in content_blocks if not (isinstance(block, MutableMapping) and "toolResult" in block)
|
||||
]
|
||||
|
||||
aligned_blocks: list[dict[str, Any]] = []
|
||||
pending = deque(pending_tool_use_ids)
|
||||
for block in content_blocks:
|
||||
if not isinstance(block, MutableMapping):
|
||||
aligned_blocks.append(block)
|
||||
continue
|
||||
tool_result = block.get("toolResult")
|
||||
if not tool_result:
|
||||
aligned_blocks.append(block)
|
||||
continue
|
||||
if not pending:
|
||||
logger.debug("Dropping extra tool result block due to missing pending tool uses: %s", block)
|
||||
continue
|
||||
tool_use_id = tool_result.get("toolUseId")
|
||||
if tool_use_id:
|
||||
try:
|
||||
pending.remove(tool_use_id)
|
||||
except ValueError:
|
||||
logger.debug("Tool result references unknown toolUseId '%s'. Dropping block.", tool_use_id)
|
||||
continue
|
||||
else:
|
||||
tool_result["toolUseId"] = pending.popleft()
|
||||
aligned_blocks.append(block)
|
||||
|
||||
return aligned_blocks
|
||||
|
||||
def _convert_message_to_content_blocks(self, message: ChatMessage) -> list[dict[str, Any]]:
|
||||
blocks: list[dict[str, Any]] = []
|
||||
for content in message.contents:
|
||||
block = self._convert_content_to_bedrock_block(content)
|
||||
if block is None:
|
||||
logger.debug("Skipping unsupported content type for Bedrock: %s", type(content))
|
||||
continue
|
||||
blocks.append(block)
|
||||
return blocks
|
||||
|
||||
def _convert_content_to_bedrock_block(self, content: Contents) -> dict[str, Any] | None:
|
||||
if isinstance(content, TextContent):
|
||||
return {"text": content.text}
|
||||
if isinstance(content, FunctionCallContent):
|
||||
arguments = content.parse_arguments() or {}
|
||||
return {
|
||||
"toolUse": {
|
||||
"toolUseId": content.call_id or self._generate_tool_call_id(),
|
||||
"name": content.name,
|
||||
"input": arguments,
|
||||
}
|
||||
}
|
||||
if isinstance(content, FunctionResultContent):
|
||||
tool_result_block = {
|
||||
"toolResult": {
|
||||
"toolUseId": content.call_id,
|
||||
"content": self._convert_tool_result_to_blocks(content.result),
|
||||
"status": "error" if content.exception else "success",
|
||||
}
|
||||
}
|
||||
if content.exception:
|
||||
tool_result = tool_result_block["toolResult"]
|
||||
existing_content = tool_result.get("content")
|
||||
content_list: list[dict[str, Any]]
|
||||
if isinstance(existing_content, list):
|
||||
content_list = existing_content
|
||||
else:
|
||||
content_list = []
|
||||
tool_result["content"] = content_list
|
||||
content_list.append({"text": str(content.exception)})
|
||||
return tool_result_block
|
||||
return None
|
||||
|
||||
def _convert_tool_result_to_blocks(self, result: Any) -> list[dict[str, Any]]:
|
||||
prepared_result = prepare_function_call_results(result)
|
||||
try:
|
||||
parsed_result = json.loads(prepared_result)
|
||||
except json.JSONDecodeError:
|
||||
return [{"text": prepared_result}]
|
||||
|
||||
return self._convert_prepared_tool_result_to_blocks(parsed_result)
|
||||
|
||||
def _convert_prepared_tool_result_to_blocks(self, value: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(value, list):
|
||||
blocks: list[dict[str, Any]] = []
|
||||
for item in value:
|
||||
blocks.extend(self._convert_prepared_tool_result_to_blocks(item))
|
||||
return blocks or [{"text": ""}]
|
||||
return [self._normalize_tool_result_value(value)]
|
||||
|
||||
def _normalize_tool_result_value(self, value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return {"json": value}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return {"json": list(value)}
|
||||
if isinstance(value, str):
|
||||
return {"text": value}
|
||||
if isinstance(value, (int, float, bool)) or value is None:
|
||||
return {"json": value}
|
||||
if isinstance(value, TextContent) and getattr(value, "text", None):
|
||||
return {"text": value.text}
|
||||
if hasattr(value, "to_dict"):
|
||||
try:
|
||||
return {"json": value.to_dict()} # type: ignore[call-arg]
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return {"text": str(value)}
|
||||
return {"text": str(value)}
|
||||
|
||||
def _convert_tools_to_bedrock_config(
|
||||
self, tools: list[ToolProtocol | MutableMapping[str, Any]] | None
|
||||
) -> dict[str, Any] | None:
|
||||
if not tools:
|
||||
return None
|
||||
converted: list[dict[str, Any]] = []
|
||||
for tool in tools:
|
||||
if isinstance(tool, MutableMapping):
|
||||
converted.append(dict(tool))
|
||||
continue
|
||||
if isinstance(tool, AIFunction):
|
||||
converted.append({
|
||||
"toolSpec": {
|
||||
"name": tool.name,
|
||||
"description": tool.description or "",
|
||||
"inputSchema": {"json": tool.parameters()},
|
||||
}
|
||||
})
|
||||
continue
|
||||
logger.debug("Ignoring unsupported tool type for Bedrock: %s", type(tool))
|
||||
return {"tools": converted} if converted else None
|
||||
|
||||
def _convert_tool_choice(self, tool_choice: Any) -> dict[str, Any] | None:
|
||||
if not tool_choice:
|
||||
return None
|
||||
mode = tool_choice.mode if hasattr(tool_choice, "mode") else str(tool_choice)
|
||||
required_name = getattr(tool_choice, "required_function_name", None)
|
||||
match mode:
|
||||
case "auto":
|
||||
return {"auto": {}}
|
||||
case "none":
|
||||
return {"none": {}}
|
||||
case "required":
|
||||
if required_name:
|
||||
return {"tool": {"name": required_name}}
|
||||
return {"any": {}}
|
||||
case _:
|
||||
logger.debug("Unsupported tool choice mode for Bedrock: %s", mode)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _generate_tool_call_id() -> str:
|
||||
return f"tool-call-{uuid4().hex}"
|
||||
|
||||
def _process_converse_response(self, response: dict[str, Any]) -> ChatResponse:
|
||||
output = response.get("output", {})
|
||||
message = output.get("message", {})
|
||||
content_blocks = message.get("content", []) or []
|
||||
contents = self._parse_message_contents(content_blocks)
|
||||
chat_message = ChatMessage(role=Role.ASSISTANT, contents=contents, raw_representation=message)
|
||||
usage_details = self._parse_usage(response.get("usage") or output.get("usage"))
|
||||
finish_reason = self._map_finish_reason(output.get("completionReason") or response.get("stopReason"))
|
||||
response_id = response.get("responseId") or message.get("id")
|
||||
model_id = response.get("modelId") or output.get("modelId") or self.model_id
|
||||
return ChatResponse(
|
||||
response_id=response_id,
|
||||
messages=[chat_message],
|
||||
usage_details=usage_details,
|
||||
model_id=model_id,
|
||||
finish_reason=finish_reason,
|
||||
raw_representation=response,
|
||||
)
|
||||
|
||||
def _parse_usage(self, usage: dict[str, Any] | None) -> UsageDetails | None:
|
||||
if not usage:
|
||||
return None
|
||||
details = UsageDetails()
|
||||
if (input_tokens := usage.get("inputTokens")) is not None:
|
||||
details.input_token_count = input_tokens
|
||||
if (output_tokens := usage.get("outputTokens")) is not None:
|
||||
details.output_token_count = output_tokens
|
||||
if (total_tokens := usage.get("totalTokens")) is not None:
|
||||
details.additional_counts["bedrock.total_tokens"] = total_tokens
|
||||
return details
|
||||
|
||||
def _parse_message_contents(self, content_blocks: Sequence[MutableMapping[str, Any]]) -> list[Any]:
|
||||
contents: list[Any] = []
|
||||
for block in content_blocks:
|
||||
if text_value := block.get("text"):
|
||||
contents.append(TextContent(text=text_value, raw_representation=block))
|
||||
continue
|
||||
if (json_value := block.get("json")) is not None:
|
||||
contents.append(TextContent(text=json.dumps(json_value), raw_representation=block))
|
||||
continue
|
||||
tool_use = block.get("toolUse")
|
||||
if isinstance(tool_use, MutableMapping):
|
||||
tool_name = tool_use.get("name")
|
||||
if not tool_name:
|
||||
raise ServiceInvalidResponseError("Bedrock response missing required tool name in toolUse block.")
|
||||
contents.append(
|
||||
FunctionCallContent(
|
||||
call_id=tool_use.get("toolUseId") or self._generate_tool_call_id(),
|
||||
name=tool_name,
|
||||
arguments=tool_use.get("input"),
|
||||
raw_representation=block,
|
||||
)
|
||||
)
|
||||
continue
|
||||
tool_result = block.get("toolResult")
|
||||
if isinstance(tool_result, MutableMapping):
|
||||
status = (tool_result.get("status") or "success").lower()
|
||||
exception = None
|
||||
if status not in {"success", "ok"}:
|
||||
exception = RuntimeError(f"Bedrock tool result status: {status}")
|
||||
result_value = self._convert_bedrock_tool_result_to_value(tool_result.get("content"))
|
||||
contents.append(
|
||||
FunctionResultContent(
|
||||
call_id=tool_result.get("toolUseId") or self._generate_tool_call_id(),
|
||||
result=result_value,
|
||||
exception=exception,
|
||||
raw_representation=block,
|
||||
)
|
||||
)
|
||||
continue
|
||||
logger.debug("Ignoring unsupported Bedrock content block: %s", block)
|
||||
return contents
|
||||
|
||||
def _map_finish_reason(self, reason: str | None) -> FinishReason | None:
|
||||
if not reason:
|
||||
return None
|
||||
return FINISH_REASON_MAP.get(reason.lower())
|
||||
|
||||
def service_url(self) -> str:
|
||||
"""Returns the service URL for the Bedrock runtime in the configured AWS region.
|
||||
|
||||
Returns:
|
||||
str: The Bedrock runtime service URL.
|
||||
"""
|
||||
return f"https://bedrock-runtime.{self.region}.amazonaws.com"
|
||||
|
||||
def _convert_bedrock_tool_result_to_value(self, content: Any) -> Any:
|
||||
if not content:
|
||||
return None
|
||||
if isinstance(content, Sequence) and not isinstance(content, (str, bytes, bytearray)):
|
||||
values: list[Any] = []
|
||||
for item in content:
|
||||
if isinstance(item, MutableMapping):
|
||||
if (text_value := item.get("text")) is not None:
|
||||
values.append(text_value)
|
||||
continue
|
||||
if "json" in item:
|
||||
values.append(item["json"])
|
||||
continue
|
||||
values.append(item)
|
||||
return values[0] if len(values) == 1 else values
|
||||
if isinstance(content, MutableMapping):
|
||||
if (text_value := content.get("text")) is not None:
|
||||
return text_value
|
||||
if "json" in content:
|
||||
return content["json"]
|
||||
return content
|
||||
@@ -0,0 +1,90 @@
|
||||
[project]
|
||||
name = "agent-framework-bedrock"
|
||||
description = "Amazon Bedrock integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251120"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core",
|
||||
"boto3>=1.35.0,<2.0.0",
|
||||
"botocore>=1.35.0,<2.0.0",
|
||||
]
|
||||
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = []
|
||||
timeout = 120
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_bedrock"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_bedrock"
|
||||
test = "pytest --cov=agent_framework_bedrock --cov-report=term-missing:skip-covered tests"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
@@ -0,0 +1,64 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
|
||||
from agent_framework import (
|
||||
AgentRunResponse,
|
||||
ChatAgent,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
Role,
|
||||
TextContent,
|
||||
ToolMode,
|
||||
ai_function,
|
||||
)
|
||||
|
||||
from agent_framework_bedrock import BedrockChatClient
|
||||
|
||||
|
||||
@ai_function
|
||||
def get_weather(city: str) -> dict[str, str]:
|
||||
"""Return a mock forecast for the requested city."""
|
||||
normalized = city.strip() or "New York"
|
||||
return {"city": normalized, "forecast": "72F and sunny"}
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the Bedrock sample agent, invoke the weather tool, and log the response."""
|
||||
agent = ChatAgent(
|
||||
chat_client=BedrockChatClient(),
|
||||
instructions="You are a concise travel assistant.",
|
||||
name="BedrockWeatherAgent",
|
||||
tool_choice=ToolMode.AUTO,
|
||||
tools=[get_weather],
|
||||
)
|
||||
|
||||
response = await agent.run("Use the weather tool to check the forecast for new york.")
|
||||
logging.info("\nAssistant reply:", response.text or "<no text returned>")
|
||||
_log_response(response)
|
||||
|
||||
|
||||
def _log_response(response: AgentRunResponse) -> None:
|
||||
logging.info("\nConversation transcript:")
|
||||
for idx, message in enumerate(response.messages, start=1):
|
||||
tag = f"{idx}. {message.role.value if isinstance(message.role, Role) else message.role}"
|
||||
_log_contents(tag, message.contents)
|
||||
|
||||
|
||||
def _log_contents(tag: str, contents: Sequence[object]) -> None:
|
||||
logging.info(f"[{tag}] {len(contents)} content blocks")
|
||||
for idx, content in enumerate(contents, start=1):
|
||||
if isinstance(content, TextContent):
|
||||
logging.info(f" {idx}. text -> {content.text}")
|
||||
elif isinstance(content, FunctionCallContent):
|
||||
logging.info(f" {idx}. tool_call ({content.name}) -> {content.arguments}")
|
||||
elif isinstance(content, FunctionResultContent):
|
||||
logging.info(f" {idx}. tool_result ({content.call_id}) -> {content.result}")
|
||||
else: # pragma: no cover - defensive
|
||||
logging.info(f" {idx}. {content.type}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,69 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from agent_framework import ChatMessage, ChatOptions, Role, TextContent
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
|
||||
from agent_framework_bedrock import BedrockChatClient
|
||||
|
||||
|
||||
class _StubBedrockRuntime:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def converse(self, **kwargs: Any) -> dict[str, Any]:
|
||||
self.calls.append(kwargs)
|
||||
return {
|
||||
"modelId": kwargs["modelId"],
|
||||
"responseId": "resp-123",
|
||||
"usage": {"inputTokens": 10, "outputTokens": 5, "totalTokens": 15},
|
||||
"output": {
|
||||
"completionReason": "end_turn",
|
||||
"message": {
|
||||
"id": "msg-1",
|
||||
"role": "assistant",
|
||||
"content": [{"text": "Bedrock says hi"}],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_get_response_invokes_bedrock_runtime() -> None:
|
||||
stub = _StubBedrockRuntime()
|
||||
client = BedrockChatClient(
|
||||
model_id="amazon.titan-text",
|
||||
region="us-west-2",
|
||||
client=stub,
|
||||
)
|
||||
|
||||
messages = [
|
||||
ChatMessage(role=Role.SYSTEM, contents=[TextContent(text="You are concise.")]),
|
||||
ChatMessage(role=Role.USER, contents=[TextContent(text="hello")]),
|
||||
]
|
||||
|
||||
response = asyncio.run(client.get_response(messages=messages, chat_options=ChatOptions(max_tokens=32)))
|
||||
|
||||
assert stub.calls, "Expected the runtime client to be called"
|
||||
payload = stub.calls[0]
|
||||
assert payload["modelId"] == "amazon.titan-text"
|
||||
assert payload["messages"][0]["content"][0]["text"] == "hello"
|
||||
assert response.messages[0].contents[0].text == "Bedrock says hi"
|
||||
assert response.usage_details and response.usage_details.input_token_count == 10
|
||||
|
||||
|
||||
def test_build_request_requires_non_system_messages() -> None:
|
||||
client = BedrockChatClient(
|
||||
model_id="amazon.titan-text",
|
||||
region="us-west-2",
|
||||
client=_StubBedrockRuntime(),
|
||||
)
|
||||
|
||||
messages = [ChatMessage(role=Role.SYSTEM, contents=[TextContent(text="Only system text")])]
|
||||
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
client._build_converse_request(messages, ChatOptions())
|
||||
@@ -0,0 +1,133 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from agent_framework import (
|
||||
AIFunction,
|
||||
ChatMessage,
|
||||
ChatOptions,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
Role,
|
||||
TextContent,
|
||||
ToolMode,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_bedrock._chat_client import BedrockChatClient, BedrockSettings
|
||||
|
||||
|
||||
class _WeatherArgs(BaseModel):
|
||||
location: str
|
||||
|
||||
|
||||
def _build_client() -> BedrockChatClient:
|
||||
fake_runtime = MagicMock()
|
||||
fake_runtime.converse.return_value = {}
|
||||
return BedrockChatClient(model_id="test-model", client=fake_runtime)
|
||||
|
||||
|
||||
def _dummy_weather(location: str) -> str: # pragma: no cover - helper
|
||||
return f"Weather in {location}"
|
||||
|
||||
|
||||
def test_settings_load_from_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("BEDROCK_REGION", "us-west-2")
|
||||
monkeypatch.setenv("BEDROCK_CHAT_MODEL_ID", "anthropic.claude-v2")
|
||||
settings = BedrockSettings()
|
||||
assert settings.region == "us-west-2"
|
||||
assert settings.chat_model_id == "anthropic.claude-v2"
|
||||
|
||||
|
||||
def test_build_request_includes_tool_config() -> None:
|
||||
client = _build_client()
|
||||
|
||||
tool = AIFunction(name="get_weather", description="desc", func=_dummy_weather, input_model=_WeatherArgs)
|
||||
options = ChatOptions(tools=[tool], tool_choice=ToolMode.REQUIRED("get_weather"))
|
||||
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="hi")])]
|
||||
|
||||
request = client._build_converse_request(messages, options)
|
||||
|
||||
assert request["toolConfig"]["tools"][0]["toolSpec"]["name"] == "get_weather"
|
||||
assert request["toolConfig"]["toolChoice"] == {"tool": {"name": "get_weather"}}
|
||||
|
||||
|
||||
def test_build_request_serializes_tool_history() -> None:
|
||||
client = _build_client()
|
||||
options = ChatOptions()
|
||||
messages = [
|
||||
ChatMessage(role=Role.USER, contents=[TextContent(text="how's weather?")]),
|
||||
ChatMessage(
|
||||
role=Role.ASSISTANT,
|
||||
contents=[FunctionCallContent(call_id="call-1", name="get_weather", arguments='{"location": "SEA"}')],
|
||||
),
|
||||
ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id="call-1", result={"answer": "72F"})],
|
||||
),
|
||||
]
|
||||
|
||||
request = client._build_converse_request(messages, options)
|
||||
assistant_block = request["messages"][1]["content"][0]["toolUse"]
|
||||
result_block = request["messages"][2]["content"][0]["toolResult"]
|
||||
|
||||
assert assistant_block["name"] == "get_weather"
|
||||
assert assistant_block["input"] == {"location": "SEA"}
|
||||
assert result_block["toolUseId"] == "call-1"
|
||||
assert result_block["content"][0]["json"] == {"answer": "72F"}
|
||||
|
||||
|
||||
def test_process_response_parses_tool_use_and_result() -> None:
|
||||
client = _build_client()
|
||||
response = {
|
||||
"modelId": "model",
|
||||
"output": {
|
||||
"message": {
|
||||
"id": "msg-1",
|
||||
"content": [
|
||||
{"toolUse": {"toolUseId": "call-1", "name": "get_weather", "input": {"location": "NYC"}}},
|
||||
{"text": "Calling tool"},
|
||||
],
|
||||
},
|
||||
"completionReason": "tool_use",
|
||||
},
|
||||
}
|
||||
|
||||
chat_response = client._process_converse_response(response)
|
||||
contents = chat_response.messages[0].contents
|
||||
|
||||
assert isinstance(contents[0], FunctionCallContent)
|
||||
assert contents[0].name == "get_weather"
|
||||
assert isinstance(contents[1], TextContent)
|
||||
assert chat_response.finish_reason == client._map_finish_reason("tool_use")
|
||||
|
||||
|
||||
def test_process_response_parses_tool_result() -> None:
|
||||
client = _build_client()
|
||||
response = {
|
||||
"modelId": "model",
|
||||
"output": {
|
||||
"message": {
|
||||
"id": "msg-2",
|
||||
"content": [
|
||||
{
|
||||
"toolResult": {
|
||||
"toolUseId": "call-1",
|
||||
"status": "success",
|
||||
"content": [{"json": {"answer": 42}}],
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
"completionReason": "end_turn",
|
||||
},
|
||||
}
|
||||
|
||||
chat_response = client._process_converse_response(response)
|
||||
contents = chat_response.messages[0].contents
|
||||
|
||||
assert isinstance(contents[0], FunctionResultContent)
|
||||
assert contents[0].result == {"answer": 42}
|
||||
@@ -25,6 +25,7 @@ from chatkit.types import (
|
||||
Attachment,
|
||||
ClientToolCallItem,
|
||||
EndOfTurnItem,
|
||||
GeneratedImageItem,
|
||||
HiddenContextItem,
|
||||
ImageAttachment,
|
||||
SDKHiddenContextItem,
|
||||
@@ -528,6 +529,9 @@ class ThreadItemConverter:
|
||||
case SDKHiddenContextItem():
|
||||
out = self.hidden_context_to_input(item) or []
|
||||
return out if isinstance(out, list) else [out]
|
||||
case GeneratedImageItem():
|
||||
# TODO(evmattso): Implement generated image handling in a future PR
|
||||
return []
|
||||
case _:
|
||||
assert_never(item)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -501,7 +501,7 @@ class BaseChatClient(SerializationMixin, ABC):
|
||||
stop: str | Sequence[str] | None = None,
|
||||
store: bool | None = None,
|
||||
temperature: float | None = None,
|
||||
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
|
||||
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
|
||||
tools: ToolProtocol
|
||||
| Callable[..., Any]
|
||||
| MutableMapping[str, Any]
|
||||
@@ -535,6 +535,7 @@ class BaseChatClient(SerializationMixin, ABC):
|
||||
store: Whether to store the response.
|
||||
temperature: The sampling temperature to use.
|
||||
tool_choice: The tool choice for the request.
|
||||
Default is `auto`.
|
||||
tools: The tools to use for the request.
|
||||
top_p: The nucleus sampling probability to use.
|
||||
user: The user to associate with the request.
|
||||
@@ -595,7 +596,7 @@ class BaseChatClient(SerializationMixin, ABC):
|
||||
stop: str | Sequence[str] | None = None,
|
||||
store: bool | None = None,
|
||||
temperature: float | None = None,
|
||||
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
|
||||
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
|
||||
tools: ToolProtocol
|
||||
| Callable[..., Any]
|
||||
| MutableMapping[str, Any]
|
||||
@@ -629,6 +630,7 @@ class BaseChatClient(SerializationMixin, ABC):
|
||||
store: Whether to store the response.
|
||||
temperature: The sampling temperature to use.
|
||||
tool_choice: The tool choice for the request.
|
||||
Default is `auto`.
|
||||
tools: The tools to use for the request.
|
||||
top_p: The nucleus sampling probability to use.
|
||||
user: The user to associate with the request.
|
||||
|
||||
@@ -63,21 +63,21 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
def _mcp_prompt_message_to_chat_message(
|
||||
def _parse_message_from_mcp(
|
||||
mcp_type: types.PromptMessage | types.SamplingMessage,
|
||||
) -> ChatMessage:
|
||||
"""Convert a MCP container type to a Agent Framework type."""
|
||||
"""Parse an MCP container type into an Agent Framework type."""
|
||||
return ChatMessage(
|
||||
role=Role(value=mcp_type.role),
|
||||
contents=_mcp_type_to_ai_content(mcp_type.content),
|
||||
contents=_parse_content_from_mcp(mcp_type.content),
|
||||
raw_representation=mcp_type,
|
||||
)
|
||||
|
||||
|
||||
def _mcp_call_tool_result_to_ai_contents(
|
||||
def _parse_contents_from_mcp_tool_result(
|
||||
mcp_type: types.CallToolResult,
|
||||
) -> list[Contents]:
|
||||
"""Convert a MCP container type to a Agent Framework type.
|
||||
"""Parse an MCP CallToolResult into Agent Framework content types.
|
||||
|
||||
This function extracts the complete _meta field from CallToolResult objects
|
||||
and merges all metadata into the additional_properties field of converted
|
||||
@@ -111,7 +111,7 @@ def _mcp_call_tool_result_to_ai_contents(
|
||||
# Convert each content item and merge metadata
|
||||
result_contents = []
|
||||
for item in mcp_type.content:
|
||||
contents = _mcp_type_to_ai_content(item)
|
||||
contents = _parse_content_from_mcp(item)
|
||||
|
||||
if merged_meta_props:
|
||||
for content in contents:
|
||||
@@ -124,7 +124,7 @@ def _mcp_call_tool_result_to_ai_contents(
|
||||
return result_contents
|
||||
|
||||
|
||||
def _mcp_type_to_ai_content(
|
||||
def _parse_content_from_mcp(
|
||||
mcp_type: types.ImageContent
|
||||
| types.TextContent
|
||||
| types.AudioContent
|
||||
@@ -142,7 +142,7 @@ def _mcp_type_to_ai_content(
|
||||
| types.ToolResultContent
|
||||
],
|
||||
) -> list[Contents]:
|
||||
"""Convert a MCP type to a Agent Framework type."""
|
||||
"""Parse an MCP type into an Agent Framework type."""
|
||||
mcp_types = mcp_type if isinstance(mcp_type, Sequence) else [mcp_type]
|
||||
return_types: list[Contents] = []
|
||||
for mcp_type in mcp_types:
|
||||
@@ -152,7 +152,7 @@ def _mcp_type_to_ai_content(
|
||||
case types.ImageContent() | types.AudioContent():
|
||||
return_types.append(
|
||||
DataContent(
|
||||
uri=mcp_type.data,
|
||||
data=mcp_type.data,
|
||||
media_type=mcp_type.mimeType,
|
||||
raw_representation=mcp_type,
|
||||
)
|
||||
@@ -178,7 +178,7 @@ def _mcp_type_to_ai_content(
|
||||
return_types.append(
|
||||
FunctionResultContent(
|
||||
call_id=mcp_type.toolUseId,
|
||||
result=_mcp_type_to_ai_content(mcp_type.content)
|
||||
result=_parse_content_from_mcp(mcp_type.content)
|
||||
if mcp_type.content
|
||||
else mcp_type.structuredContent,
|
||||
exception=Exception() if mcp_type.isError else None,
|
||||
@@ -211,10 +211,10 @@ def _mcp_type_to_ai_content(
|
||||
return return_types
|
||||
|
||||
|
||||
def _ai_content_to_mcp_types(
|
||||
def _prepare_content_for_mcp(
|
||||
content: Contents,
|
||||
) -> types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink | None:
|
||||
"""Convert a BaseContent type to a MCP type."""
|
||||
"""Prepare an Agent Framework content type for MCP."""
|
||||
match content:
|
||||
case TextContent():
|
||||
return types.TextContent(type="text", text=content.text)
|
||||
@@ -253,15 +253,15 @@ def _ai_content_to_mcp_types(
|
||||
return None
|
||||
|
||||
|
||||
def _chat_message_to_mcp_types(
|
||||
def _prepare_message_for_mcp(
|
||||
content: ChatMessage,
|
||||
) -> list[types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink]:
|
||||
"""Convert a ChatMessage to a list of MCP types."""
|
||||
"""Prepare a ChatMessage for MCP format."""
|
||||
messages: list[
|
||||
types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink
|
||||
] = []
|
||||
for item in content.contents:
|
||||
mcp_content = _ai_content_to_mcp_types(item)
|
||||
mcp_content = _prepare_content_for_mcp(item)
|
||||
if mcp_content:
|
||||
messages.append(mcp_content)
|
||||
return messages
|
||||
@@ -469,7 +469,7 @@ class MCPTool:
|
||||
logger.debug("Sampling callback called with params: %s", params)
|
||||
messages: list[ChatMessage] = []
|
||||
for msg in params.messages:
|
||||
messages.append(_mcp_prompt_message_to_chat_message(msg))
|
||||
messages.append(_parse_message_from_mcp(msg))
|
||||
try:
|
||||
response = await self.chat_client.get_response(
|
||||
messages,
|
||||
@@ -487,7 +487,7 @@ class MCPTool:
|
||||
code=types.INTERNAL_ERROR,
|
||||
message="Failed to get chat message content.",
|
||||
)
|
||||
mcp_contents = _chat_message_to_mcp_types(response.messages[0])
|
||||
mcp_contents = _prepare_message_for_mcp(response.messages[0])
|
||||
# grab the first content that is of type TextContent or ImageContent
|
||||
mcp_content = next(
|
||||
(content for content in mcp_contents if isinstance(content, (types.TextContent, types.ImageContent))),
|
||||
@@ -692,7 +692,7 @@ class MCPTool:
|
||||
k: v for k, v in kwargs.items() if k not in {"chat_options", "tools", "tool_choice", "thread"}
|
||||
}
|
||||
try:
|
||||
return _mcp_call_tool_result_to_ai_contents(
|
||||
return _parse_contents_from_mcp_tool_result(
|
||||
await self.session.call_tool(tool_name, arguments=filtered_kwargs)
|
||||
)
|
||||
except McpError as mcp_exc:
|
||||
@@ -724,7 +724,7 @@ class MCPTool:
|
||||
)
|
||||
try:
|
||||
prompt_result = await self.session.get_prompt(prompt_name, arguments=kwargs)
|
||||
return [_mcp_prompt_message_to_chat_message(message) for message in prompt_result.messages]
|
||||
return [_parse_message_from_mcp(message) for message in prompt_result.messages]
|
||||
except McpError as mcp_exc:
|
||||
raise ToolExecutionException(mcp_exc.error.message, inner_exception=mcp_exc) from mcp_exc
|
||||
except Exception as ex:
|
||||
|
||||
@@ -573,7 +573,7 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
|
||||
"""
|
||||
|
||||
INJECTABLE: ClassVar[set[str]] = {"func"}
|
||||
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"input_model", "_invocation_duration_histogram"}
|
||||
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"input_model", "_invocation_duration_histogram", "_cached_parameters"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -615,6 +615,7 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
|
||||
self.func = func
|
||||
self._instance = None # Store the instance for bound methods
|
||||
self.input_model = self._resolve_input_model(input_model)
|
||||
self._cached_parameters: dict[str, Any] | None = None # Cache for model_json_schema()
|
||||
self.approval_mode = approval_mode or "never_require"
|
||||
if max_invocations is not None and max_invocations < 1:
|
||||
raise ValueError("max_invocations must be at least 1 or None.")
|
||||
@@ -802,8 +803,11 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
|
||||
|
||||
Returns:
|
||||
A dictionary containing the JSON schema for the function's parameters.
|
||||
The result is cached after the first call for performance.
|
||||
"""
|
||||
return self.input_model.model_json_schema()
|
||||
if self._cached_parameters is None:
|
||||
self._cached_parameters = self.input_model.model_json_schema()
|
||||
return self._cached_parameters
|
||||
|
||||
def to_json_schema_spec(self) -> dict[str, Any]:
|
||||
"""Convert a AIFunction to the JSON Schema function specification format.
|
||||
@@ -825,7 +829,7 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
|
||||
as_dict = super().to_dict(exclude=exclude, exclude_none=exclude_none)
|
||||
if (exclude and "input_model" in exclude) or not self.input_model:
|
||||
return as_dict
|
||||
as_dict["input_model"] = self.input_model.model_json_schema()
|
||||
as_dict["input_model"] = self.parameters() # Use cached parameters()
|
||||
return as_dict
|
||||
|
||||
|
||||
@@ -886,6 +890,8 @@ def _parse_annotation(annotation: Any) -> Any:
|
||||
If the second annotation (after the type) is a string, then we convert that to a Pydantic Field description.
|
||||
The rest are returned as-is, allowing for multiple annotations.
|
||||
|
||||
Literal types are returned as-is to preserve their enum-like values.
|
||||
|
||||
Args:
|
||||
annotation: The type annotation to parse.
|
||||
|
||||
@@ -894,6 +900,12 @@ def _parse_annotation(annotation: Any) -> Any:
|
||||
"""
|
||||
origin = get_origin(annotation)
|
||||
if origin is not None:
|
||||
# Literal types should be returned as-is - their args are the allowed values,
|
||||
# not type annotations to be parsed. For example, Literal["Data", "Security"]
|
||||
# has args ("Data", "Security") which are the valid string values.
|
||||
if origin is Literal:
|
||||
return annotation
|
||||
|
||||
args = get_args(annotation)
|
||||
# For other generics, return the origin type (e.g., list for List[int])
|
||||
if len(args) > 1 and isinstance(args[1], str):
|
||||
@@ -1771,11 +1783,6 @@ def _handle_function_calls_response(
|
||||
response: "ChatResponse | None" = None
|
||||
fcc_messages: "list[ChatMessage]" = []
|
||||
|
||||
# If tools are provided but tool_choice is not set, default to "auto" for function invocation
|
||||
tools = _extract_tools(kwargs)
|
||||
if tools and kwargs.get("tool_choice") is None:
|
||||
kwargs["tool_choice"] = "auto"
|
||||
|
||||
for attempt_idx in range(config.max_iterations if config.enabled else 0):
|
||||
fcc_todo = _collect_approval_responses(prepped_messages)
|
||||
if fcc_todo:
|
||||
|
||||
@@ -101,7 +101,7 @@ def _parse_content(content_data: MutableMapping[str, Any]) -> "Contents":
|
||||
Raises:
|
||||
ContentError if parsing fails
|
||||
"""
|
||||
content_type = str(content_data.get("type"))
|
||||
content_type: str | None = content_data.get("type", None)
|
||||
match content_type:
|
||||
case "text":
|
||||
return TextContent.from_dict(content_data)
|
||||
@@ -127,6 +127,8 @@ def _parse_content(content_data: MutableMapping[str, Any]) -> "Contents":
|
||||
return FunctionApprovalResponseContent.from_dict(content_data)
|
||||
case "text_reasoning":
|
||||
return TextReasoningContent.from_dict(content_data)
|
||||
case None:
|
||||
raise ContentError("Content type is missing")
|
||||
case _:
|
||||
raise ContentError(f"Unknown content type '{content_type}'")
|
||||
|
||||
@@ -789,8 +791,9 @@ class TextReasoningContent(BaseContent):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text: str,
|
||||
text: str | None,
|
||||
*,
|
||||
protected_data: str | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
raw_representation: Any | None = None,
|
||||
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
|
||||
@@ -802,6 +805,16 @@ class TextReasoningContent(BaseContent):
|
||||
text: The text content represented by this instance.
|
||||
|
||||
Keyword Args:
|
||||
protected_data: This property is used to store data from a provider that should be roundtripped back to the
|
||||
provider but that is not intended for human consumption. It is often encrypted or otherwise redacted
|
||||
information that is only intended to be sent back to the provider and not displayed to the user. It's
|
||||
possible for a TextReasoningContent to contain only `protected_data` and have an empty `text` property.
|
||||
This data also may be associated with the corresponding `text`, acting as a validation signature for it.
|
||||
|
||||
Note that whereas `text` can be provider agnostic, `protected_data` is provider-specific, and is likely
|
||||
to only be understood by the provider that created it. The data is often represented as a more complex
|
||||
object, so it should be serialized to a string before storing so that the whole object is easily
|
||||
serializable without loss.
|
||||
additional_properties: Optional additional properties associated with the content.
|
||||
raw_representation: Optional raw representation of the content.
|
||||
annotations: Optional annotations associated with the content.
|
||||
@@ -814,6 +827,7 @@ class TextReasoningContent(BaseContent):
|
||||
**kwargs,
|
||||
)
|
||||
self.text = text
|
||||
self.protected_data = protected_data
|
||||
self.type: Literal["text_reasoning"] = "text_reasoning"
|
||||
|
||||
def __add__(self, other: "TextReasoningContent") -> "TextReasoningContent":
|
||||
@@ -846,13 +860,18 @@ class TextReasoningContent(BaseContent):
|
||||
else:
|
||||
annotations = self.annotations + other.annotations
|
||||
|
||||
# Replace protected data.
|
||||
# Discussion: https://github.com/microsoft/agent-framework/pull/2950#discussion_r2634345613
|
||||
protected_data = other.protected_data or self.protected_data
|
||||
|
||||
# Create new instance using from_dict for proper deserialization
|
||||
result_dict = {
|
||||
"text": self.text + other.text,
|
||||
"text": (self.text or "") + (other.text or "") if self.text is not None or other.text is not None else None,
|
||||
"type": "text_reasoning",
|
||||
"annotations": [ann.to_dict(exclude_none=False) for ann in annotations] if annotations else None,
|
||||
"additional_properties": {**(self.additional_properties or {}), **(other.additional_properties or {})},
|
||||
"raw_representation": raw_representation,
|
||||
"protected_data": protected_data,
|
||||
}
|
||||
return TextReasoningContent.from_dict(result_dict)
|
||||
|
||||
@@ -869,7 +888,9 @@ class TextReasoningContent(BaseContent):
|
||||
raise TypeError("Incompatible type")
|
||||
|
||||
# Concatenate text
|
||||
self.text += other.text
|
||||
if self.text is not None or other.text is not None:
|
||||
self.text = (self.text or "") + (other.text or "")
|
||||
# if both are None, should keep as None
|
||||
|
||||
# Merge additional properties (self takes precedence)
|
||||
if self.additional_properties is None:
|
||||
@@ -888,6 +909,11 @@ class TextReasoningContent(BaseContent):
|
||||
self.raw_representation if isinstance(self.raw_representation, list) else [self.raw_representation]
|
||||
) + (other.raw_representation if isinstance(other.raw_representation, list) else [other.raw_representation])
|
||||
|
||||
# Replace protected data.
|
||||
# Discussion: https://github.com/microsoft/agent-framework/pull/2950#discussion_r2634345613
|
||||
if other.protected_data is not None:
|
||||
self.protected_data = other.protected_data
|
||||
|
||||
# Merge annotations
|
||||
if other.annotations:
|
||||
if self.annotations is None:
|
||||
@@ -925,6 +951,10 @@ class DataContent(BaseContent):
|
||||
image_data = b"raw image bytes"
|
||||
data_content = DataContent(data=image_data, media_type="image/png")
|
||||
|
||||
# Create from base64-encoded string
|
||||
base64_string = "iVBORw0KGgoAAAANS..."
|
||||
data_content = DataContent(data=base64_string, media_type="image/png")
|
||||
|
||||
# Create from data URI
|
||||
data_uri = "data:image/png;base64,iVBORw0KGgoAAAANS..."
|
||||
data_content = DataContent(uri=data_uri)
|
||||
@@ -986,11 +1016,38 @@ class DataContent(BaseContent):
|
||||
**kwargs: Any additional keyword arguments.
|
||||
"""
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
data: str,
|
||||
media_type: str,
|
||||
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
raw_representation: Any | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initializes a DataContent instance with base64-encoded string data.
|
||||
|
||||
Important:
|
||||
This is for binary data that is represented as a data URI, not for online resources.
|
||||
Use ``UriContent`` for online resources.
|
||||
|
||||
Keyword Args:
|
||||
data: The base64-encoded string data represented by this instance.
|
||||
The data is used directly to construct a data URI.
|
||||
media_type: The media type of the data.
|
||||
annotations: Optional annotations associated with the content.
|
||||
additional_properties: Optional additional properties associated with the content.
|
||||
raw_representation: Optional raw representation of the content.
|
||||
**kwargs: Any additional keyword arguments.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
uri: str | None = None,
|
||||
data: bytes | None = None,
|
||||
data: bytes | str | None = None,
|
||||
media_type: str | None = None,
|
||||
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
@@ -1006,8 +1063,9 @@ class DataContent(BaseContent):
|
||||
Keyword Args:
|
||||
uri: The URI of the data represented by this instance.
|
||||
Should be in the form: "data:{media_type};base64,{base64_data}".
|
||||
data: The binary data represented by this instance.
|
||||
The data is transformed into a base64-encoded data URI.
|
||||
data: The binary data or base64-encoded string represented by this instance.
|
||||
If bytes, the data is transformed into a base64-encoded data URI.
|
||||
If str, it is assumed to be already base64-encoded and used directly.
|
||||
media_type: The media type of the data.
|
||||
annotations: Optional annotations associated with the content.
|
||||
additional_properties: Optional additional properties associated with the content.
|
||||
@@ -1017,7 +1075,9 @@ class DataContent(BaseContent):
|
||||
if uri is None:
|
||||
if data is None or media_type is None:
|
||||
raise ValueError("Either 'data' and 'media_type' or 'uri' must be provided.")
|
||||
uri = f"data:{media_type};base64,{base64.b64encode(data).decode('utf-8')}"
|
||||
|
||||
base64_data: str = base64.b64encode(data).decode("utf-8") if isinstance(data, bytes) else data
|
||||
uri = f"data:{media_type};base64,{base64_data}"
|
||||
|
||||
# Validate URI format and extract media type if not provided
|
||||
validated_uri = self._validate_uri(uri)
|
||||
@@ -2190,27 +2250,30 @@ def _process_update(
|
||||
if update.message_id:
|
||||
message.message_id = update.message_id
|
||||
for content in update.contents:
|
||||
if (
|
||||
isinstance(content, FunctionCallContent)
|
||||
and len(message.contents) > 0
|
||||
and isinstance(message.contents[-1], FunctionCallContent)
|
||||
):
|
||||
# Fast path: get type attribute (most content will have it)
|
||||
content_type = getattr(content, "type", None)
|
||||
# Slow path: only check for dict if type is None
|
||||
if content_type is None and isinstance(content, (dict, MutableMapping)):
|
||||
try:
|
||||
message.contents[-1] += content
|
||||
except AdditionItemMismatch:
|
||||
message.contents.append(content)
|
||||
elif isinstance(content, UsageContent):
|
||||
if response.usage_details is None:
|
||||
response.usage_details = UsageDetails()
|
||||
response.usage_details += content.details
|
||||
elif isinstance(content, (dict, MutableMapping)):
|
||||
try:
|
||||
cont = _parse_content(content)
|
||||
message.contents.append(cont)
|
||||
content = _parse_content(content)
|
||||
content_type = content.type
|
||||
except ContentError as exc:
|
||||
logger.warning(f"Skipping unknown content type or invalid content: {exc}")
|
||||
else:
|
||||
message.contents.append(content)
|
||||
continue
|
||||
match content_type:
|
||||
# mypy doesn't narrow type based on match/case, but we know these are FunctionCallContents
|
||||
case "function_call" if message.contents and message.contents[-1].type == "function_call":
|
||||
try:
|
||||
message.contents[-1] += content # type: ignore[operator]
|
||||
except AdditionItemMismatch:
|
||||
message.contents.append(content)
|
||||
case "usage":
|
||||
if response.usage_details is None:
|
||||
response.usage_details = UsageDetails()
|
||||
# mypy doesn't narrow type based on match/case, but we know this is UsageContent
|
||||
response.usage_details += content.details # type: ignore[union-attr, arg-type]
|
||||
case _:
|
||||
message.contents.append(content)
|
||||
# Incorporate the update's properties into the response.
|
||||
if update.response_id:
|
||||
response.response_id = update.response_id
|
||||
|
||||
@@ -26,6 +26,7 @@ from agent_framework import (
|
||||
)
|
||||
|
||||
from ..exceptions import AgentExecutionException
|
||||
from ._agent_executor import AgentExecutor
|
||||
from ._checkpoint import CheckpointStorage
|
||||
from ._events import (
|
||||
AgentRunUpdateEvent,
|
||||
@@ -141,7 +142,8 @@ class WorkflowAgent(BaseAgent):
|
||||
checkpoint_storage: Runtime checkpoint storage. When provided with checkpoint_id,
|
||||
used to load and restore the checkpoint. When provided without checkpoint_id,
|
||||
enables checkpointing for this run.
|
||||
**kwargs: Additional keyword arguments.
|
||||
**kwargs: Additional keyword arguments passed through to underlying workflow
|
||||
and ai_function tools.
|
||||
|
||||
Returns:
|
||||
The final workflow response as an AgentRunResponse.
|
||||
@@ -153,7 +155,7 @@ class WorkflowAgent(BaseAgent):
|
||||
response_id = str(uuid.uuid4())
|
||||
|
||||
async for update in self._run_stream_impl(
|
||||
input_messages, response_id, thread, checkpoint_id, checkpoint_storage
|
||||
input_messages, response_id, thread, checkpoint_id, checkpoint_storage, **kwargs
|
||||
):
|
||||
response_updates.append(update)
|
||||
|
||||
@@ -187,7 +189,8 @@ class WorkflowAgent(BaseAgent):
|
||||
checkpoint_storage: Runtime checkpoint storage. When provided with checkpoint_id,
|
||||
used to load and restore the checkpoint. When provided without checkpoint_id,
|
||||
enables checkpointing for this run.
|
||||
**kwargs: Additional keyword arguments.
|
||||
**kwargs: Additional keyword arguments passed through to underlying workflow
|
||||
and ai_function tools.
|
||||
|
||||
Yields:
|
||||
AgentRunResponseUpdate objects representing the workflow execution progress.
|
||||
@@ -198,7 +201,7 @@ class WorkflowAgent(BaseAgent):
|
||||
response_id = str(uuid.uuid4())
|
||||
|
||||
async for update in self._run_stream_impl(
|
||||
input_messages, response_id, thread, checkpoint_id, checkpoint_storage
|
||||
input_messages, response_id, thread, checkpoint_id, checkpoint_storage, **kwargs
|
||||
):
|
||||
response_updates.append(update)
|
||||
yield update
|
||||
@@ -216,6 +219,7 @@ class WorkflowAgent(BaseAgent):
|
||||
thread: AgentThread,
|
||||
checkpoint_id: str | None = None,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[AgentRunResponseUpdate]:
|
||||
"""Internal implementation of streaming execution.
|
||||
|
||||
@@ -225,6 +229,8 @@ class WorkflowAgent(BaseAgent):
|
||||
thread: The conversation thread containing message history.
|
||||
checkpoint_id: ID of checkpoint to restore from.
|
||||
checkpoint_storage: Runtime checkpoint storage.
|
||||
**kwargs: Additional keyword arguments passed through to the underlying
|
||||
workflow and ai_function tools.
|
||||
|
||||
Yields:
|
||||
AgentRunResponseUpdate objects representing the workflow execution progress.
|
||||
@@ -255,6 +261,7 @@ class WorkflowAgent(BaseAgent):
|
||||
message=None,
|
||||
checkpoint_id=checkpoint_id,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
**kwargs,
|
||||
)
|
||||
else:
|
||||
# Execute workflow with streaming (initial run or no function responses)
|
||||
@@ -268,6 +275,7 @@ class WorkflowAgent(BaseAgent):
|
||||
event_stream = self.workflow.run_stream(
|
||||
message=conversation_messages,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Process events from the stream
|
||||
@@ -286,10 +294,20 @@ class WorkflowAgent(BaseAgent):
|
||||
|
||||
AgentRunUpdateEvent, RequestInfoEvent, and WorkflowOutputEvent are processed.
|
||||
Other workflow events are ignored as they are workflow-internal.
|
||||
|
||||
For AgentRunUpdateEvent from AgentExecutor instances, only events from executors
|
||||
with output_response=True are converted to agent updates. This prevents agent
|
||||
responses from executors that were not explicitly marked to surface their output.
|
||||
Non-AgentExecutor executors that emit AgentRunUpdateEvent directly are allowed
|
||||
through since they explicitly chose to emit the event.
|
||||
"""
|
||||
match event:
|
||||
case AgentRunUpdateEvent(data=update):
|
||||
# Direct pass-through of update in an agent streaming event
|
||||
case AgentRunUpdateEvent(data=update, executor_id=executor_id):
|
||||
# For AgentExecutor instances, only pass through if output_response=True.
|
||||
# Non-AgentExecutor executors that emit AgentRunUpdateEvent are allowed through.
|
||||
executor = self.workflow.executors.get(executor_id)
|
||||
if isinstance(executor, AgentExecutor) and not executor.output_response:
|
||||
return None
|
||||
if update:
|
||||
return update
|
||||
return None
|
||||
@@ -297,11 +315,17 @@ class WorkflowAgent(BaseAgent):
|
||||
case WorkflowOutputEvent(data=data, source_executor_id=source_executor_id):
|
||||
# Convert workflow output to an agent response update.
|
||||
# Handle different data types appropriately.
|
||||
|
||||
# Skip AgentRunResponse from AgentExecutor with output_response=True
|
||||
# since streaming events already surfaced the content.
|
||||
if isinstance(data, AgentRunResponse):
|
||||
executor = self.workflow.executors.get(source_executor_id)
|
||||
if isinstance(executor, AgentExecutor) and executor.output_response:
|
||||
return None
|
||||
|
||||
if isinstance(data, AgentRunResponseUpdate):
|
||||
# Already an update, pass through
|
||||
return data
|
||||
if isinstance(data, ChatMessage):
|
||||
# Convert ChatMessage to update
|
||||
return AgentRunResponseUpdate(
|
||||
contents=list(data.contents),
|
||||
role=data.role,
|
||||
@@ -311,15 +335,9 @@ class WorkflowAgent(BaseAgent):
|
||||
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
raw_representation=data,
|
||||
)
|
||||
# Determine contents based on data type
|
||||
if isinstance(data, BaseContent):
|
||||
# Already a content type (TextContent, ImageContent, etc.)
|
||||
contents: list[Contents] = [cast(Contents, data)]
|
||||
elif isinstance(data, str):
|
||||
contents = [TextContent(text=data)]
|
||||
else:
|
||||
# Fallback: convert to string representation
|
||||
contents = [TextContent(text=str(data))]
|
||||
contents = self._extract_contents(data)
|
||||
if not contents:
|
||||
return None
|
||||
return AgentRunResponseUpdate(
|
||||
contents=contents,
|
||||
role=Role.ASSISTANT,
|
||||
@@ -405,6 +423,18 @@ class WorkflowAgent(BaseAgent):
|
||||
raise AgentExecutionException("Unexpected content type while awaiting request info responses.")
|
||||
return function_responses
|
||||
|
||||
def _extract_contents(self, data: Any) -> list[Contents]:
|
||||
"""Recursively extract Contents from workflow output data."""
|
||||
if isinstance(data, ChatMessage):
|
||||
return list(data.contents)
|
||||
if isinstance(data, list):
|
||||
return [c for item in data for c in self._extract_contents(item)]
|
||||
if isinstance(data, BaseContent):
|
||||
return [cast(Contents, data)]
|
||||
if isinstance(data, str):
|
||||
return [TextContent(text=data)]
|
||||
return [TextContent(text=str(data))]
|
||||
|
||||
class _ResponseState(TypedDict):
|
||||
"""State for grouping response updates by message_id."""
|
||||
|
||||
|
||||
@@ -99,6 +99,11 @@ class AgentExecutor(Executor):
|
||||
self._output_response = output_response
|
||||
self._cache: list[ChatMessage] = []
|
||||
|
||||
@property
|
||||
def output_response(self) -> bool:
|
||||
"""Whether this executor yields AgentRunResponse as workflow output when complete."""
|
||||
return self._output_response
|
||||
|
||||
@property
|
||||
def workflow_output_types(self) -> list[type[Any]]:
|
||||
# Override to declare AgentRunResponse as a possible output type only if enabled.
|
||||
|
||||
@@ -871,8 +871,10 @@ class HandoffBuilder:
|
||||
HandoffBuilder(participants=[coordinator, refund, shipping])
|
||||
.set_coordinator(coordinator)
|
||||
.with_termination_condition(
|
||||
lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 5
|
||||
or any("goodbye" in msg.text.lower() for msg in conv[-2:])
|
||||
lambda conv: (
|
||||
sum(1 for msg in conv if msg.role.value == "user") >= 5
|
||||
or any("goodbye" in msg.text.lower() for msg in conv[-2:])
|
||||
)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
@@ -7,16 +7,16 @@ import uuid
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from ._edge import FanInEdgeGroup
|
||||
from ._edge import FanInEdgeGroup, InternalEdgeGroup
|
||||
from ._workflow import Workflow
|
||||
|
||||
# Import of WorkflowExecutor is performed lazily inside methods to avoid cycles
|
||||
|
||||
"""Workflow visualization module using graphviz."""
|
||||
"""Workflow visualization module using graphviz and Mermaid."""
|
||||
|
||||
|
||||
class WorkflowViz:
|
||||
"""A class for visualizing workflows using graphviz."""
|
||||
"""A class for visualizing workflows using graphviz and Mermaid."""
|
||||
|
||||
def __init__(self, workflow: Workflow):
|
||||
"""Initialize the WorkflowViz with a workflow.
|
||||
@@ -26,9 +26,13 @@ class WorkflowViz:
|
||||
"""
|
||||
self._workflow = workflow
|
||||
|
||||
def to_digraph(self) -> str:
|
||||
def to_digraph(self, include_internal_executors: bool = False) -> str:
|
||||
"""Export the workflow as a DOT format digraph string.
|
||||
|
||||
Args:
|
||||
include_internal_executors (bool): Whether to include internal executors in the visualization.
|
||||
Default is False.
|
||||
|
||||
Returns:
|
||||
A string representation of the workflow in DOT format.
|
||||
"""
|
||||
@@ -39,20 +43,37 @@ class WorkflowViz:
|
||||
lines.append("")
|
||||
|
||||
# Emit the top-level workflow nodes/edges
|
||||
self._emit_workflow_digraph(self._workflow, lines, indent=" ")
|
||||
self._emit_workflow_digraph(
|
||||
self._workflow,
|
||||
lines,
|
||||
indent=" ",
|
||||
include_internal_executors=include_internal_executors,
|
||||
)
|
||||
|
||||
# Emit sub-workflows hosted by WorkflowExecutor as nested clusters
|
||||
self._emit_sub_workflows_digraph(self._workflow, lines, indent=" ")
|
||||
self._emit_sub_workflows_digraph(
|
||||
self._workflow,
|
||||
lines,
|
||||
indent=" ",
|
||||
include_internal_executors=include_internal_executors,
|
||||
)
|
||||
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def export(self, format: Literal["svg", "png", "pdf", "dot"] = "svg", filename: str | None = None) -> str:
|
||||
def export(
|
||||
self,
|
||||
format: Literal["svg", "png", "pdf", "dot"] = "svg",
|
||||
filename: str | None = None,
|
||||
include_internal_executors: bool = False,
|
||||
) -> str:
|
||||
"""Export the workflow visualization to a file or return the file path.
|
||||
|
||||
Args:
|
||||
format: The output format. Supported formats: 'svg', 'png', 'pdf', 'dot'.
|
||||
filename: Optional filename to save the output. If None, creates a temporary file.
|
||||
include_internal_executors (bool): Whether to include internal executors in the visualization.
|
||||
Default is False.
|
||||
|
||||
Returns:
|
||||
The path to the saved file.
|
||||
@@ -66,7 +87,7 @@ class WorkflowViz:
|
||||
raise ValueError(f"Unsupported format: {format}. Supported formats: svg, png, pdf, dot")
|
||||
|
||||
if format == "dot":
|
||||
content = self.to_digraph()
|
||||
content = self.to_digraph(include_internal_executors=include_internal_executors)
|
||||
if filename:
|
||||
with open(filename, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
@@ -87,7 +108,7 @@ class WorkflowViz:
|
||||
) from e
|
||||
|
||||
# Create a temporary graphviz Source object
|
||||
dot_content = self.to_digraph()
|
||||
dot_content = self.to_digraph(include_internal_executors=include_internal_executors)
|
||||
source = graphviz.Source(dot_content)
|
||||
|
||||
try:
|
||||
@@ -99,7 +120,7 @@ class WorkflowViz:
|
||||
|
||||
# Remove extension if present since graphviz.render() adds it
|
||||
base_name = str(output_path.with_suffix(""))
|
||||
source.render(base_name, format=format, cleanup=True)
|
||||
source.render(base_name, format=format, cleanup=True) # type: ignore
|
||||
|
||||
# Return the actual filename with extension
|
||||
return f"{base_name}.{format}"
|
||||
@@ -108,7 +129,7 @@ class WorkflowViz:
|
||||
temp_path = Path(temp_file.name)
|
||||
base_name = str(temp_path.with_suffix(""))
|
||||
|
||||
source.render(base_name, format=format, cleanup=True)
|
||||
source.render(base_name, format=format, cleanup=True) # type: ignore
|
||||
return f"{base_name}.{format}"
|
||||
except graphviz.backend.execute.ExecutableNotFound as e:
|
||||
raise ImportError(
|
||||
@@ -118,60 +139,72 @@ class WorkflowViz:
|
||||
"brew install graphviz on macOS, or download from https://graphviz.org/download/ for other platforms."
|
||||
) from e
|
||||
|
||||
def save_svg(self, filename: str) -> str:
|
||||
def save_svg(self, filename: str, include_internal_executors: bool = False) -> str:
|
||||
"""Convenience method to save as SVG.
|
||||
|
||||
Args:
|
||||
filename: The filename to save the SVG file.
|
||||
include_internal_executors (bool): Whether to include internal executors in the visualization.
|
||||
Default is False.
|
||||
|
||||
Returns:
|
||||
The path to the saved SVG file.
|
||||
"""
|
||||
return self.export(format="svg", filename=filename)
|
||||
return self.export(format="svg", filename=filename, include_internal_executors=include_internal_executors)
|
||||
|
||||
def save_png(self, filename: str) -> str:
|
||||
def save_png(self, filename: str, include_internal_executors: bool = False) -> str:
|
||||
"""Convenience method to save as PNG.
|
||||
|
||||
Args:
|
||||
filename: The filename to save the PNG file.
|
||||
include_internal_executors (bool): Whether to include internal executors in the visualization.
|
||||
Default is False.
|
||||
|
||||
Returns:
|
||||
The path to the saved PNG file.
|
||||
"""
|
||||
return self.export(format="png", filename=filename)
|
||||
return self.export(format="png", filename=filename, include_internal_executors=include_internal_executors)
|
||||
|
||||
def save_pdf(self, filename: str) -> str:
|
||||
def save_pdf(self, filename: str, include_internal_executors: bool = False) -> str:
|
||||
"""Convenience method to save as PDF.
|
||||
|
||||
Args:
|
||||
filename: The filename to save the PDF file.
|
||||
include_internal_executors (bool): Whether to include internal executors in the visualization.
|
||||
Default is False.
|
||||
|
||||
Returns:
|
||||
The path to the saved PDF file.
|
||||
"""
|
||||
return self.export(format="pdf", filename=filename)
|
||||
return self.export(format="pdf", filename=filename, include_internal_executors=include_internal_executors)
|
||||
|
||||
def to_mermaid(self) -> str:
|
||||
def to_mermaid(self, include_internal_executors: bool = False) -> str:
|
||||
"""Export the workflow as a Mermaid flowchart string.
|
||||
|
||||
Args:
|
||||
include_internal_executors (bool): Whether to include internal executors in the visualization.
|
||||
Default is False.
|
||||
|
||||
Returns:
|
||||
A string representation of the workflow in Mermaid flowchart syntax.
|
||||
"""
|
||||
|
||||
def _san(s: str) -> str:
|
||||
"""Sanitize an ID for Mermaid (alphanumeric and underscore, start with letter)."""
|
||||
s2 = re.sub(r"[^0-9A-Za-z_]", "_", s)
|
||||
if not s2 or not s2[0].isalpha():
|
||||
s2 = f"n_{s2}"
|
||||
return s2
|
||||
|
||||
lines: list[str] = ["flowchart TD"]
|
||||
|
||||
# Emit top-level workflow
|
||||
self._emit_workflow_mermaid(self._workflow, lines, indent=" ")
|
||||
self._emit_workflow_mermaid(
|
||||
self._workflow,
|
||||
lines,
|
||||
indent=" ",
|
||||
include_internal_executors=include_internal_executors,
|
||||
)
|
||||
|
||||
# Emit sub-workflows as Mermaid subgraphs
|
||||
self._emit_sub_workflows_mermaid(self._workflow, lines, indent=" ")
|
||||
self._emit_sub_workflows_mermaid(
|
||||
self._workflow,
|
||||
lines,
|
||||
indent=" ",
|
||||
include_internal_executors=include_internal_executors,
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -181,13 +214,13 @@ class WorkflowViz:
|
||||
sources_sorted = sorted(sources)
|
||||
return hashlib.sha256((target + "|" + "|".join(sources_sorted)).encode("utf-8")).hexdigest()[:8]
|
||||
|
||||
def _compute_fan_in_descriptors(self, wf: Workflow | None = None) -> list[tuple[str, list[str], str]]:
|
||||
def _compute_fan_in_descriptors(self, workflow: Workflow | None = None) -> list[tuple[str, list[str], str]]:
|
||||
"""Return list of (node_id, sources, target) for fan-in groups.
|
||||
|
||||
node_id is DOT-oriented: fan_in::target::digest
|
||||
"""
|
||||
result: list[tuple[str, list[str], str]] = []
|
||||
workflow = wf or self._workflow
|
||||
workflow = workflow or self._workflow
|
||||
for group in workflow.edge_groups:
|
||||
if isinstance(group, FanInEdgeGroup):
|
||||
target = group.target_executor_ids[0]
|
||||
@@ -197,13 +230,19 @@ class WorkflowViz:
|
||||
result.append((node_id, sorted(sources), target))
|
||||
return result
|
||||
|
||||
def _compute_normal_edges(self, wf: Workflow | None = None) -> list[tuple[str, str, bool]]:
|
||||
def _compute_normal_edges(
|
||||
self,
|
||||
workflow: Workflow | None = None,
|
||||
include_internal_executors: bool = False,
|
||||
) -> list[tuple[str, str, bool]]:
|
||||
"""Return list of (source_id, target_id, is_conditional) for non-fan-in groups."""
|
||||
edges: list[tuple[str, str, bool]] = []
|
||||
workflow = wf or self._workflow
|
||||
workflow = workflow or self._workflow
|
||||
for group in workflow.edge_groups:
|
||||
if isinstance(group, FanInEdgeGroup):
|
||||
continue
|
||||
if isinstance(group, InternalEdgeGroup) and not include_internal_executors:
|
||||
continue
|
||||
for edge in group.edges:
|
||||
is_cond = getattr(edge, "_condition", None) is not None
|
||||
edges.append((edge.source_id, edge.target_id, is_cond))
|
||||
@@ -213,7 +252,14 @@ class WorkflowViz:
|
||||
|
||||
# region Internal emitters (DOT)
|
||||
|
||||
def _emit_workflow_digraph(self, wf: Workflow, lines: list[str], indent: str, ns: str | None = None) -> None:
|
||||
def _emit_workflow_digraph(
|
||||
self,
|
||||
workflow: Workflow,
|
||||
lines: list[str],
|
||||
indent: str,
|
||||
ns: str | None = None,
|
||||
include_internal_executors: bool = False,
|
||||
) -> None:
|
||||
"""Emit DOT nodes/edges for the given workflow.
|
||||
|
||||
If ns (namespace) is provided, node ids are prefixed with f"{ns}/" for uniqueness,
|
||||
@@ -224,16 +270,16 @@ class WorkflowViz:
|
||||
return f"{ns}/{x}" if ns else x
|
||||
|
||||
# Nodes
|
||||
start_executor_id = wf.start_executor_id
|
||||
start_executor_id = workflow.start_executor_id
|
||||
lines.append(
|
||||
f'{indent}"{map_id(start_executor_id)}" [fillcolor=lightgreen, label="{start_executor_id}\\n(Start)"];'
|
||||
)
|
||||
for executor_id in wf.executors:
|
||||
for executor_id in workflow.executors:
|
||||
if executor_id != start_executor_id:
|
||||
lines.append(f'{indent}"{map_id(executor_id)}" [label="{executor_id}"];')
|
||||
|
||||
# Fan-in nodes
|
||||
fan_in_nodes = self._compute_fan_in_descriptors(wf)
|
||||
fan_in_nodes = self._compute_fan_in_descriptors(workflow)
|
||||
if fan_in_nodes:
|
||||
lines.append("")
|
||||
for node_id, _, _ in fan_in_nodes:
|
||||
@@ -246,11 +292,19 @@ class WorkflowViz:
|
||||
lines.append(f'{indent}"{map_id(node_id)}" -> "{map_id(target)}";')
|
||||
|
||||
# Normal edges
|
||||
for src, tgt, is_cond in self._compute_normal_edges(wf):
|
||||
for src, tgt, is_cond in self._compute_normal_edges(
|
||||
workflow, include_internal_executors=include_internal_executors
|
||||
):
|
||||
edge_attr = ' [style=dashed, label="conditional"]' if is_cond else ""
|
||||
lines.append(f'{indent}"{map_id(src)}" -> "{map_id(tgt)}"{edge_attr};')
|
||||
|
||||
def _emit_sub_workflows_digraph(self, wf: Workflow, lines: list[str], indent: str) -> None:
|
||||
def _emit_sub_workflows_digraph(
|
||||
self,
|
||||
workflow: Workflow,
|
||||
lines: list[str],
|
||||
indent: str,
|
||||
include_internal_executors: bool = False,
|
||||
) -> None:
|
||||
"""Emit DOT subgraphs for any WorkflowExecutor instances found in the workflow."""
|
||||
# Lazy import to avoid any potential import cycles
|
||||
try:
|
||||
@@ -258,7 +312,7 @@ class WorkflowViz:
|
||||
except ImportError: # pragma: no cover - best-effort; if unavailable, skip subgraphs
|
||||
return
|
||||
|
||||
for exec_id, exec_obj in wf.executors.items():
|
||||
for exec_id, exec_obj in workflow.executors.items():
|
||||
if isinstance(exec_obj, WorkflowExecutor) and hasattr(exec_obj, "workflow") and exec_obj.workflow:
|
||||
subgraph_id = f"cluster_{uuid.uuid5(uuid.NAMESPACE_OID, exec_id).hex[:8]}"
|
||||
lines.append(f"{indent}subgraph {subgraph_id} {{")
|
||||
@@ -267,10 +321,21 @@ class WorkflowViz:
|
||||
|
||||
# Emit the nested workflow inside this cluster using a namespace
|
||||
ns = exec_id
|
||||
self._emit_workflow_digraph(exec_obj.workflow, lines, indent=f"{indent} ", ns=ns)
|
||||
self._emit_workflow_digraph(
|
||||
exec_obj.workflow,
|
||||
lines,
|
||||
indent=f"{indent} ",
|
||||
ns=ns,
|
||||
include_internal_executors=include_internal_executors,
|
||||
)
|
||||
|
||||
# Recurse into deeper nested sub-workflows
|
||||
self._emit_sub_workflows_digraph(exec_obj.workflow, lines, indent=f"{indent} ")
|
||||
self._emit_sub_workflows_digraph(
|
||||
exec_obj.workflow,
|
||||
lines,
|
||||
indent=f"{indent} ",
|
||||
include_internal_executors=include_internal_executors,
|
||||
)
|
||||
|
||||
lines.append(f"{indent}}}")
|
||||
|
||||
@@ -278,7 +343,14 @@ class WorkflowViz:
|
||||
|
||||
# region Internal emitters (Mermaid)
|
||||
|
||||
def _emit_workflow_mermaid(self, wf: Workflow, lines: list[str], indent: str, ns: str | None = None) -> None:
|
||||
def _emit_workflow_mermaid(
|
||||
self,
|
||||
workflow: Workflow,
|
||||
lines: list[str],
|
||||
indent: str,
|
||||
ns: str | None = None,
|
||||
include_internal_executors: bool = False,
|
||||
) -> None:
|
||||
def _san(s: str) -> str:
|
||||
s2 = re.sub(r"[^0-9A-Za-z_]", "_", s)
|
||||
if not s2 or not s2[0].isalpha():
|
||||
@@ -291,15 +363,15 @@ class WorkflowViz:
|
||||
return _san(x)
|
||||
|
||||
# Nodes
|
||||
start_executor_id = wf.start_executor_id
|
||||
start_executor_id = workflow.start_executor_id
|
||||
lines.append(f'{indent}{map_id(start_executor_id)}["{start_executor_id} (Start)"];')
|
||||
for executor_id in wf.executors:
|
||||
for executor_id in workflow.executors:
|
||||
if executor_id == start_executor_id:
|
||||
continue
|
||||
lines.append(f'{indent}{map_id(executor_id)}["{executor_id}"];')
|
||||
|
||||
# Fan-in nodes
|
||||
fan_in_nodes_dot = self._compute_fan_in_descriptors(wf)
|
||||
fan_in_nodes_dot = self._compute_fan_in_descriptors(workflow)
|
||||
fan_in_nodes: list[tuple[str, list[str], str]] = []
|
||||
for dot_node_id, sources, target in fan_in_nodes_dot:
|
||||
digest = dot_node_id.split("::")[-1]
|
||||
@@ -318,7 +390,9 @@ class WorkflowViz:
|
||||
lines.append(f"{indent}{fan_node_id} --> {map_id(target)};")
|
||||
|
||||
# Normal edges
|
||||
for src, tgt, is_cond in self._compute_normal_edges(wf):
|
||||
for src, tgt, is_cond in self._compute_normal_edges(
|
||||
workflow, include_internal_executors=include_internal_executors
|
||||
):
|
||||
s = map_id(src)
|
||||
t = map_id(tgt)
|
||||
if is_cond:
|
||||
@@ -326,7 +400,13 @@ class WorkflowViz:
|
||||
else:
|
||||
lines.append(f"{indent}{s} --> {t};")
|
||||
|
||||
def _emit_sub_workflows_mermaid(self, wf: Workflow, lines: list[str], indent: str) -> None:
|
||||
def _emit_sub_workflows_mermaid(
|
||||
self,
|
||||
workflow: Workflow,
|
||||
lines: list[str],
|
||||
indent: str,
|
||||
include_internal_executors: bool = False,
|
||||
) -> None:
|
||||
try:
|
||||
from ._workflow_executor import WorkflowExecutor # type: ignore
|
||||
except ImportError: # pragma: no cover
|
||||
@@ -338,14 +418,25 @@ class WorkflowViz:
|
||||
s2 = f"n_{s2}"
|
||||
return s2
|
||||
|
||||
for exec_id, exec_obj in wf.executors.items():
|
||||
for exec_id, exec_obj in workflow.executors.items():
|
||||
if isinstance(exec_obj, WorkflowExecutor) and hasattr(exec_obj, "workflow") and exec_obj.workflow:
|
||||
sg_id = _san(exec_id)
|
||||
lines.append(f"{indent}subgraph {sg_id}")
|
||||
# Render nested workflow within this subgraph using namespacing
|
||||
self._emit_workflow_mermaid(exec_obj.workflow, lines, indent=f"{indent} ", ns=exec_id)
|
||||
self._emit_workflow_mermaid(
|
||||
exec_obj.workflow,
|
||||
lines,
|
||||
indent=f"{indent} ",
|
||||
ns=exec_id,
|
||||
include_internal_executors=include_internal_executors,
|
||||
)
|
||||
# Recurse into deeper sub-workflows
|
||||
self._emit_sub_workflows_mermaid(exec_obj.workflow, lines, indent=f"{indent} ")
|
||||
self._emit_sub_workflows_mermaid(
|
||||
exec_obj.workflow,
|
||||
lines,
|
||||
indent=f"{indent} ",
|
||||
include_internal_executors=include_internal_executors,
|
||||
)
|
||||
lines.append(f"{indent}end")
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
|
||||
from ._workflow import Workflow
|
||||
|
||||
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
|
||||
from ._const import WORKFLOW_RUN_KWARGS_KEY
|
||||
from ._events import (
|
||||
RequestInfoEvent,
|
||||
WorkflowErrorEvent,
|
||||
@@ -366,8 +367,11 @@ class WorkflowExecutor(Executor):
|
||||
logger.debug(f"WorkflowExecutor {self.id} starting sub-workflow {self.workflow.id} execution {execution_id}")
|
||||
|
||||
try:
|
||||
# Run the sub-workflow and collect all events
|
||||
result = await self.workflow.run(input_data)
|
||||
# Get kwargs from parent workflow's SharedState to propagate to subworkflow
|
||||
parent_kwargs: dict[str, Any] = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY) or {}
|
||||
|
||||
# Run the sub-workflow and collect all events, passing parent kwargs
|
||||
result = await self.workflow.run(input_data, **parent_kwargs)
|
||||
|
||||
logger.debug(
|
||||
f"WorkflowExecutor {self.id} sub-workflow {self.workflow.id} "
|
||||
|
||||
@@ -154,7 +154,7 @@ class AzureOpenAIChatClient(AzureOpenAIConfigMixin, OpenAIBaseChatClient):
|
||||
)
|
||||
|
||||
@override
|
||||
def _parse_text_from_choice(self, choice: Choice | ChunkChoice) -> TextContent | None:
|
||||
def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> TextContent | None:
|
||||
"""Parse the choice into a TextContent object.
|
||||
|
||||
Overwritten from OpenAIBaseChatClient to deal with Azure On Your Data function.
|
||||
|
||||
@@ -1680,13 +1680,12 @@ def _capture_messages(
|
||||
prepped = prepare_messages(messages, system_instructions=system_instructions)
|
||||
otel_messages: list[dict[str, Any]] = []
|
||||
for index, message in enumerate(prepped):
|
||||
otel_messages.append(_to_otel_message(message))
|
||||
try:
|
||||
message_data = message.to_dict(exclude_none=True)
|
||||
except Exception:
|
||||
message_data = {"role": message.role.value, "contents": message.contents}
|
||||
# Reuse the otel message representation for logging instead of calling to_dict()
|
||||
# to avoid expensive Pydantic serialization overhead
|
||||
otel_message = _to_otel_message(message)
|
||||
otel_messages.append(otel_message)
|
||||
logger.info(
|
||||
message_data,
|
||||
otel_message,
|
||||
extra={
|
||||
OtelAttr.EVENT_NAME: OtelAttr.CHOICE if output else ROLE_EVENT_MAP.get(message.role.value),
|
||||
OtelAttr.PROVIDER_NAME: provider_name,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
IMPORT_PATH = "agent_framework_ollama"
|
||||
PACKAGE_NAME = "agent-framework-ollama"
|
||||
_IMPORTS = ["__version__", "OllamaChatClient", "OllamaSettings"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
try:
|
||||
return getattr(importlib.import_module(IMPORT_PATH), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`"
|
||||
) from exc
|
||||
raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return _IMPORTS
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_ollama import (
|
||||
OllamaChatClient,
|
||||
OllamaSettings,
|
||||
__version__,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"OllamaChatClient",
|
||||
"OllamaSettings",
|
||||
"__version__",
|
||||
]
|
||||
@@ -164,7 +164,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
async def close(self) -> None:
|
||||
"""Clean up any assistants we created."""
|
||||
if self._should_delete_assistant and self.assistant_id is not None:
|
||||
client = await self.ensure_client()
|
||||
client = await self._ensure_client()
|
||||
await client.beta.assistants.delete(self.assistant_id)
|
||||
object.__setattr__(self, "assistant_id", None)
|
||||
object.__setattr__(self, "_should_delete_assistant", False)
|
||||
@@ -188,7 +188,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[ChatResponseUpdate]:
|
||||
# Extract necessary state from messages and options
|
||||
# prepare
|
||||
run_options, tool_results = self._prepare_options(messages, chat_options, **kwargs)
|
||||
|
||||
# Get the thread ID
|
||||
@@ -204,10 +204,10 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
# Determine which assistant to use and create if needed
|
||||
assistant_id = await self._get_assistant_id_or_create()
|
||||
|
||||
# Create the streaming response
|
||||
# execute
|
||||
stream, thread_id = await self._create_assistant_stream(thread_id, assistant_id, run_options, tool_results)
|
||||
|
||||
# Process and yield each update from the stream
|
||||
# process
|
||||
async for update in self._process_stream_events(stream, thread_id):
|
||||
yield update
|
||||
|
||||
@@ -222,7 +222,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
if not self.model_id:
|
||||
raise ServiceInitializationError("Parameter 'model_id' is required for assistant creation.")
|
||||
|
||||
client = await self.ensure_client()
|
||||
client = await self._ensure_client()
|
||||
created_assistant = await client.beta.assistants.create(
|
||||
model=self.model_id,
|
||||
description=self.assistant_description,
|
||||
@@ -245,11 +245,11 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
Returns:
|
||||
tuple: (stream, final_thread_id)
|
||||
"""
|
||||
client = await self.ensure_client()
|
||||
client = await self._ensure_client()
|
||||
# Get any active run for this thread
|
||||
thread_run = await self._get_active_thread_run(thread_id)
|
||||
|
||||
tool_run_id, tool_outputs = self._convert_function_results_to_tool_output(tool_results)
|
||||
tool_run_id, tool_outputs = self._prepare_tool_outputs_for_assistants(tool_results)
|
||||
|
||||
if thread_run is not None and tool_run_id is not None and tool_run_id == thread_run.id and tool_outputs:
|
||||
# There's an active run and we have tool results to submit, so submit the results.
|
||||
@@ -270,7 +270,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
|
||||
async def _get_active_thread_run(self, thread_id: str | None) -> Run | None:
|
||||
"""Get any active run for the given thread."""
|
||||
client = await self.ensure_client()
|
||||
client = await self._ensure_client()
|
||||
if thread_id is None:
|
||||
return None
|
||||
|
||||
@@ -281,7 +281,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
|
||||
async def _prepare_thread(self, thread_id: str | None, thread_run: Run | None, run_options: dict[str, Any]) -> str:
|
||||
"""Prepare the thread for a new run, creating or cleaning up as needed."""
|
||||
client = await self.ensure_client()
|
||||
client = await self._ensure_client()
|
||||
if thread_id is None:
|
||||
# No thread ID was provided, so create a new thread.
|
||||
thread = await client.beta.threads.create( # type: ignore[reportDeprecated]
|
||||
@@ -330,7 +330,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
response_id=response_id,
|
||||
)
|
||||
elif response.event == "thread.run.requires_action" and isinstance(response.data, Run):
|
||||
contents = self._create_function_call_contents(response.data, response_id)
|
||||
contents = self._parse_function_calls_from_assistants(response.data, response_id)
|
||||
if contents:
|
||||
yield ChatResponseUpdate(
|
||||
role=Role.ASSISTANT,
|
||||
@@ -371,8 +371,8 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
role=Role.ASSISTANT,
|
||||
)
|
||||
|
||||
def _create_function_call_contents(self, event_data: Run, response_id: str | None) -> list[Contents]:
|
||||
"""Create function call contents from a tool action event."""
|
||||
def _parse_function_calls_from_assistants(self, event_data: Run, response_id: str | None) -> list[Contents]:
|
||||
"""Parse function call contents from an assistants tool action event."""
|
||||
contents: list[Contents] = []
|
||||
|
||||
if event_data.required_action is not None:
|
||||
@@ -437,7 +437,10 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
if chat_options.response_format is not None:
|
||||
run_options["response_format"] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": chat_options.response_format.model_json_schema(),
|
||||
"json_schema": {
|
||||
"name": chat_options.response_format.__name__,
|
||||
"schema": chat_options.response_format.model_json_schema(),
|
||||
},
|
||||
}
|
||||
|
||||
instructions: list[str] = []
|
||||
@@ -487,10 +490,11 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
|
||||
return run_options, tool_results
|
||||
|
||||
def _convert_function_results_to_tool_output(
|
||||
def _prepare_tool_outputs_for_assistants(
|
||||
self,
|
||||
tool_results: list[FunctionResultContent] | None,
|
||||
) -> tuple[str | None, list[ToolOutput] | None]:
|
||||
"""Prepare function results for submission to the assistants API."""
|
||||
run_id: str | None = None
|
||||
tool_outputs: list[ToolOutput] | None = None
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from openai.types.chat.chat_completion import ChatCompletion, Choice
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
|
||||
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
|
||||
from openai.types.chat.chat_completion_message_custom_tool_call import ChatCompletionMessageCustomToolCall
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from .._clients import BaseChatClient
|
||||
from .._logging import get_logger
|
||||
@@ -34,6 +34,7 @@ from .._types import (
|
||||
FunctionResultContent,
|
||||
Role,
|
||||
TextContent,
|
||||
TextReasoningContent,
|
||||
UriContent,
|
||||
UsageContent,
|
||||
UsageDetails,
|
||||
@@ -69,10 +70,12 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> ChatResponse:
|
||||
client = await self.ensure_client()
|
||||
client = await self._ensure_client()
|
||||
# prepare
|
||||
options_dict = self._prepare_options(messages, chat_options)
|
||||
try:
|
||||
return self._create_chat_response(
|
||||
# execute and process
|
||||
return self._parse_response_from_openai(
|
||||
await client.chat.completions.create(stream=False, **options_dict), chat_options
|
||||
)
|
||||
except BadRequestError as ex:
|
||||
@@ -98,14 +101,16 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[ChatResponseUpdate]:
|
||||
client = await self.ensure_client()
|
||||
client = await self._ensure_client()
|
||||
# prepare
|
||||
options_dict = self._prepare_options(messages, chat_options)
|
||||
options_dict["stream_options"] = {"include_usage": True}
|
||||
try:
|
||||
# execute and process
|
||||
async for chunk in await client.chat.completions.create(stream=True, **options_dict):
|
||||
if len(chunk.choices) == 0 and chunk.usage is None:
|
||||
continue
|
||||
yield self._create_chat_response_update(chunk)
|
||||
yield self._parse_response_update_from_openai(chunk)
|
||||
except BadRequestError as ex:
|
||||
if ex.code == "content_filter":
|
||||
raise OpenAIContentFilterException(
|
||||
@@ -124,7 +129,9 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
|
||||
# region content creation
|
||||
|
||||
def _chat_to_tool_spec(self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]]) -> list[dict[str, Any]]:
|
||||
def _prepare_tools_for_openai(
|
||||
self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
chat_tools: list[dict[str, Any]] = []
|
||||
for tool in tools:
|
||||
if isinstance(tool, ToolProtocol):
|
||||
@@ -157,51 +164,65 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
return None
|
||||
|
||||
def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions) -> dict[str, Any]:
|
||||
# Preprocess web search tool if it exists
|
||||
options_dict = chat_options.to_dict(
|
||||
run_options = chat_options.to_dict(
|
||||
exclude={
|
||||
"type",
|
||||
"instructions", # included as system message
|
||||
"allow_multiple_tool_calls", # handled separately
|
||||
"response_format", # handled separately
|
||||
"additional_properties", # handled separately
|
||||
}
|
||||
)
|
||||
|
||||
if messages and "messages" not in options_dict:
|
||||
options_dict["messages"] = self._prepare_chat_history_for_request(messages)
|
||||
if "messages" not in options_dict:
|
||||
# messages
|
||||
if messages and "messages" not in run_options:
|
||||
run_options["messages"] = self._prepare_messages_for_openai(messages)
|
||||
if "messages" not in run_options:
|
||||
raise ServiceInvalidRequestError("Messages are required for chat completions")
|
||||
|
||||
# Translation between ChatOptions and Chat Completion API
|
||||
translations = {
|
||||
"model_id": "model",
|
||||
"allow_multiple_tool_calls": "parallel_tool_calls",
|
||||
"max_tokens": "max_output_tokens",
|
||||
}
|
||||
for old_key, new_key in translations.items():
|
||||
if old_key in run_options and old_key != new_key:
|
||||
run_options[new_key] = run_options.pop(old_key)
|
||||
|
||||
# model id
|
||||
if not run_options.get("model"):
|
||||
if not self.model_id:
|
||||
raise ValueError("model_id must be a non-empty string")
|
||||
run_options["model"] = self.model_id
|
||||
|
||||
# tools
|
||||
if chat_options.tools is not None:
|
||||
web_search_options = self._process_web_search_tool(chat_options.tools)
|
||||
if web_search_options:
|
||||
options_dict["web_search_options"] = web_search_options
|
||||
options_dict["tools"] = self._chat_to_tool_spec(chat_options.tools)
|
||||
if chat_options.allow_multiple_tool_calls is not None:
|
||||
options_dict["parallel_tool_calls"] = chat_options.allow_multiple_tool_calls
|
||||
if not options_dict.get("tools", None):
|
||||
options_dict.pop("tools", None)
|
||||
options_dict.pop("parallel_tool_calls", None)
|
||||
options_dict.pop("tool_choice", None)
|
||||
# Preprocess web search tool if it exists
|
||||
if web_search_options := self._process_web_search_tool(chat_options.tools):
|
||||
run_options["web_search_options"] = web_search_options
|
||||
run_options["tools"] = self._prepare_tools_for_openai(chat_options.tools)
|
||||
if not run_options.get("tools", None):
|
||||
run_options.pop("tools", None)
|
||||
run_options.pop("parallel_tool_calls", None)
|
||||
run_options.pop("tool_choice", None)
|
||||
# tool choice when `tool_choice` is a dict with single key `mode`, extract the mode value
|
||||
if (tool_choice := run_options.get("tool_choice")) and len(tool_choice.keys()) == 1:
|
||||
run_options["tool_choice"] = tool_choice["mode"]
|
||||
|
||||
if "model_id" not in options_dict:
|
||||
options_dict["model"] = self.model_id
|
||||
else:
|
||||
options_dict["model"] = options_dict.pop("model_id")
|
||||
if (
|
||||
chat_options.response_format
|
||||
and isinstance(chat_options.response_format, type)
|
||||
and issubclass(chat_options.response_format, BaseModel)
|
||||
):
|
||||
options_dict["response_format"] = type_to_response_format_param(chat_options.response_format)
|
||||
if additional_properties := options_dict.pop("additional_properties", None):
|
||||
for key, value in additional_properties.items():
|
||||
if value is not None:
|
||||
options_dict[key] = value
|
||||
if (tool_choice := options_dict.get("tool_choice")) and len(tool_choice.keys()) == 1:
|
||||
options_dict["tool_choice"] = tool_choice["mode"]
|
||||
return options_dict
|
||||
# response format
|
||||
if chat_options.response_format:
|
||||
run_options["response_format"] = type_to_response_format_param(chat_options.response_format)
|
||||
|
||||
def _create_chat_response(self, response: ChatCompletion, chat_options: ChatOptions) -> "ChatResponse":
|
||||
"""Create a chat message content object from a choice."""
|
||||
# additional properties
|
||||
additional_options = {
|
||||
key: value for key, value in chat_options.additional_properties.items() if value is not None
|
||||
}
|
||||
if additional_options:
|
||||
run_options.update(additional_options)
|
||||
return run_options
|
||||
|
||||
def _parse_response_from_openai(self, response: ChatCompletion, chat_options: ChatOptions) -> "ChatResponse":
|
||||
"""Parse a response from OpenAI into a ChatResponse."""
|
||||
response_metadata = self._get_metadata_from_chat_response(response)
|
||||
messages: list[ChatMessage] = []
|
||||
finish_reason: FinishReason | None = None
|
||||
@@ -210,15 +231,17 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
if choice.finish_reason:
|
||||
finish_reason = FinishReason(value=choice.finish_reason)
|
||||
contents: list[Contents] = []
|
||||
if text_content := self._parse_text_from_choice(choice):
|
||||
if text_content := self._parse_text_from_openai(choice):
|
||||
contents.append(text_content)
|
||||
if parsed_tool_calls := [tool for tool in self._get_tool_calls_from_chat_choice(choice)]:
|
||||
if parsed_tool_calls := [tool for tool in self._parse_tool_calls_from_openai(choice)]:
|
||||
contents.extend(parsed_tool_calls)
|
||||
if reasoning_details := getattr(choice.message, "reasoning_details", None):
|
||||
contents.append(TextReasoningContent(None, protected_data=json.dumps(reasoning_details)))
|
||||
messages.append(ChatMessage(role="assistant", contents=contents))
|
||||
return ChatResponse(
|
||||
response_id=response.id,
|
||||
created_at=datetime.fromtimestamp(response.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
usage_details=self._usage_details_from_openai(response.usage) if response.usage else None,
|
||||
usage_details=self._parse_usage_from_openai(response.usage) if response.usage else None,
|
||||
messages=messages,
|
||||
model_id=response.model,
|
||||
additional_properties=response_metadata,
|
||||
@@ -226,16 +249,16 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
response_format=chat_options.response_format,
|
||||
)
|
||||
|
||||
def _create_chat_response_update(
|
||||
def _parse_response_update_from_openai(
|
||||
self,
|
||||
chunk: ChatCompletionChunk,
|
||||
) -> ChatResponseUpdate:
|
||||
"""Create a streaming chat message content object from a choice."""
|
||||
"""Parse a streaming response update from OpenAI."""
|
||||
chunk_metadata = self._get_metadata_from_streaming_chat_response(chunk)
|
||||
if chunk.usage:
|
||||
return ChatResponseUpdate(
|
||||
role=Role.ASSISTANT,
|
||||
contents=[UsageContent(details=self._usage_details_from_openai(chunk.usage), raw_representation=chunk)],
|
||||
contents=[UsageContent(details=self._parse_usage_from_openai(chunk.usage), raw_representation=chunk)],
|
||||
model_id=chunk.model,
|
||||
additional_properties=chunk_metadata,
|
||||
response_id=chunk.id,
|
||||
@@ -245,12 +268,14 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
finish_reason: FinishReason | None = None
|
||||
for choice in chunk.choices:
|
||||
chunk_metadata.update(self._get_metadata_from_chat_choice(choice))
|
||||
contents.extend(self._get_tool_calls_from_chat_choice(choice))
|
||||
contents.extend(self._parse_tool_calls_from_openai(choice))
|
||||
if choice.finish_reason:
|
||||
finish_reason = FinishReason(value=choice.finish_reason)
|
||||
|
||||
if text_content := self._parse_text_from_choice(choice):
|
||||
if text_content := self._parse_text_from_openai(choice):
|
||||
contents.append(text_content)
|
||||
if reasoning_details := getattr(choice.delta, "reasoning_details", None):
|
||||
contents.append(TextReasoningContent(None, protected_data=json.dumps(reasoning_details)))
|
||||
return ChatResponseUpdate(
|
||||
created_at=datetime.fromtimestamp(chunk.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
contents=contents,
|
||||
@@ -263,7 +288,7 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
message_id=chunk.id,
|
||||
)
|
||||
|
||||
def _usage_details_from_openai(self, usage: CompletionUsage) -> UsageDetails:
|
||||
def _parse_usage_from_openai(self, usage: CompletionUsage) -> UsageDetails:
|
||||
details = UsageDetails(
|
||||
input_token_count=usage.prompt_tokens,
|
||||
output_token_count=usage.completion_tokens,
|
||||
@@ -285,7 +310,7 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
details["prompt/cached_tokens"] = tokens
|
||||
return details
|
||||
|
||||
def _parse_text_from_choice(self, choice: Choice | ChunkChoice) -> TextContent | None:
|
||||
def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> TextContent | None:
|
||||
"""Parse the choice into a TextContent object."""
|
||||
message = choice.message if isinstance(choice, Choice) else choice.delta
|
||||
if message.content:
|
||||
@@ -312,8 +337,8 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
"logprobs": getattr(choice, "logprobs", None),
|
||||
}
|
||||
|
||||
def _get_tool_calls_from_chat_choice(self, choice: Choice | ChunkChoice) -> list[Contents]:
|
||||
"""Get tool calls from a chat choice."""
|
||||
def _parse_tool_calls_from_openai(self, choice: Choice | ChunkChoice) -> list[Contents]:
|
||||
"""Parse tool calls from an OpenAI response choice."""
|
||||
resp: list[Contents] = []
|
||||
content = choice.message if isinstance(choice, Choice) else choice.delta
|
||||
if content and content.tool_calls:
|
||||
@@ -331,13 +356,13 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
# When you enable asynchronous content filtering in Azure OpenAI, you may receive empty deltas
|
||||
return resp
|
||||
|
||||
def _prepare_chat_history_for_request(
|
||||
def _prepare_messages_for_openai(
|
||||
self,
|
||||
chat_messages: Sequence[ChatMessage],
|
||||
role_key: str = "role",
|
||||
content_key: str = "content",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Prepare the chat history for a request.
|
||||
"""Prepare the chat history for an OpenAI request.
|
||||
|
||||
Allowing customization of the key names for role/author, and optionally overriding the role.
|
||||
|
||||
@@ -355,14 +380,14 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
Returns:
|
||||
prepared_chat_history (Any): The prepared chat history for a request.
|
||||
"""
|
||||
list_of_list = [self._openai_chat_message_parser(message) for message in chat_messages]
|
||||
list_of_list = [self._prepare_message_for_openai(message) for message in chat_messages]
|
||||
# Flatten the list of lists into a single list
|
||||
return list(chain.from_iterable(list_of_list))
|
||||
|
||||
# region Parsers
|
||||
|
||||
def _openai_chat_message_parser(self, message: ChatMessage) -> list[dict[str, Any]]:
|
||||
"""Parse a chat message into the openai format."""
|
||||
def _prepare_message_for_openai(self, message: ChatMessage) -> list[dict[str, Any]]:
|
||||
"""Prepare a chat message for OpenAI."""
|
||||
all_messages: list[dict[str, Any]] = []
|
||||
for content in message.contents:
|
||||
# Skip approval content - it's internal framework state, not for the LLM
|
||||
@@ -372,28 +397,36 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
args: dict[str, Any] = {
|
||||
"role": message.role.value if isinstance(message.role, Role) else message.role,
|
||||
}
|
||||
if message.author_name and message.role != Role.TOOL:
|
||||
args["name"] = message.author_name
|
||||
if "reasoning_details" in message.additional_properties and (
|
||||
details := message.additional_properties["reasoning_details"]
|
||||
):
|
||||
args["reasoning_details"] = details
|
||||
match content:
|
||||
case FunctionCallContent():
|
||||
if all_messages and "tool_calls" in all_messages[-1]:
|
||||
# If the last message already has tool calls, append to it
|
||||
all_messages[-1]["tool_calls"].append(self._openai_content_parser(content))
|
||||
all_messages[-1]["tool_calls"].append(self._prepare_content_for_openai(content))
|
||||
else:
|
||||
args["tool_calls"] = [self._openai_content_parser(content)] # type: ignore
|
||||
args["tool_calls"] = [self._prepare_content_for_openai(content)] # type: ignore
|
||||
case FunctionResultContent():
|
||||
args["tool_call_id"] = content.call_id
|
||||
if content.result is not None:
|
||||
args["content"] = prepare_function_call_results(content.result)
|
||||
case TextReasoningContent(protected_data=protected_data) if protected_data is not None:
|
||||
all_messages[-1]["reasoning_details"] = json.loads(protected_data)
|
||||
case _:
|
||||
if "content" not in args:
|
||||
args["content"] = []
|
||||
# this is a list to allow multi-modal content
|
||||
args["content"].append(self._openai_content_parser(content)) # type: ignore
|
||||
args["content"].append(self._prepare_content_for_openai(content)) # type: ignore
|
||||
if "content" in args or "tool_calls" in args:
|
||||
all_messages.append(args)
|
||||
return all_messages
|
||||
|
||||
def _openai_content_parser(self, content: Contents) -> dict[str, Any]:
|
||||
"""Parse contents into the openai format."""
|
||||
def _prepare_content_for_openai(self, content: Contents) -> dict[str, Any]:
|
||||
"""Prepare content for OpenAI."""
|
||||
match content:
|
||||
case FunctionCallContent():
|
||||
args = json.dumps(content.arguments) if isinstance(content.arguments, Mapping) else content.arguments
|
||||
|
||||
@@ -89,28 +89,16 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> ChatResponse:
|
||||
client = await self.ensure_client()
|
||||
run_options = await self.prepare_options(messages, chat_options, **kwargs)
|
||||
response_format = run_options.pop("response_format", None)
|
||||
text_config = run_options.pop("text", None)
|
||||
text_format, text_config = self._prepare_text_config(response_format=response_format, text_config=text_config)
|
||||
if text_config:
|
||||
run_options["text"] = text_config
|
||||
client = await self._ensure_client()
|
||||
# prepare
|
||||
run_options = await self._prepare_options(messages, chat_options, **kwargs)
|
||||
try:
|
||||
if not text_format:
|
||||
response = await client.responses.create(
|
||||
stream=False,
|
||||
**run_options,
|
||||
)
|
||||
chat_options.conversation_id = self.get_conversation_id(response, chat_options.store)
|
||||
return self._create_response_content(response, chat_options=chat_options)
|
||||
parsed_response: ParsedResponse[BaseModel] = await client.responses.parse(
|
||||
text_format=text_format,
|
||||
stream=False,
|
||||
**run_options,
|
||||
)
|
||||
chat_options.conversation_id = self.get_conversation_id(parsed_response, chat_options.store)
|
||||
return self._create_response_content(parsed_response, chat_options=chat_options)
|
||||
# execute and process
|
||||
if "text_format" in run_options:
|
||||
response = await client.responses.parse(stream=False, **run_options)
|
||||
else:
|
||||
response = await client.responses.create(stream=False, **run_options)
|
||||
return self._parse_response_from_openai(response, chat_options=chat_options)
|
||||
except BadRequestError as ex:
|
||||
if ex.code == "content_filter":
|
||||
raise OpenAIContentFilterException(
|
||||
@@ -134,35 +122,23 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[ChatResponseUpdate]:
|
||||
client = await self.ensure_client()
|
||||
run_options = await self.prepare_options(messages, chat_options, **kwargs)
|
||||
client = await self._ensure_client()
|
||||
# prepare
|
||||
run_options = await self._prepare_options(messages, chat_options, **kwargs)
|
||||
function_call_ids: dict[int, tuple[str, str]] = {} # output_index: (call_id, name)
|
||||
response_format = run_options.pop("response_format", None)
|
||||
text_config = run_options.pop("text", None)
|
||||
text_format, text_config = self._prepare_text_config(response_format=response_format, text_config=text_config)
|
||||
if text_config:
|
||||
run_options["text"] = text_config
|
||||
try:
|
||||
if not text_format:
|
||||
response = await client.responses.create(
|
||||
stream=True,
|
||||
**run_options,
|
||||
)
|
||||
async for chunk in response:
|
||||
update = self._create_streaming_response_content(
|
||||
# execute and process
|
||||
if "text_format" not in run_options:
|
||||
async for chunk in await client.responses.create(stream=True, **run_options):
|
||||
yield self._parse_chunk_from_openai(
|
||||
chunk, chat_options=chat_options, function_call_ids=function_call_ids
|
||||
)
|
||||
yield update
|
||||
return
|
||||
async with client.responses.stream(
|
||||
text_format=text_format,
|
||||
**run_options,
|
||||
) as response:
|
||||
async with client.responses.stream(**run_options) as response:
|
||||
async for chunk in response:
|
||||
update = self._create_streaming_response_content(
|
||||
yield self._parse_chunk_from_openai(
|
||||
chunk, chat_options=chat_options, function_call_ids=function_call_ids
|
||||
)
|
||||
yield update
|
||||
except BadRequestError as ex:
|
||||
if ex.code == "content_filter":
|
||||
raise OpenAIContentFilterException(
|
||||
@@ -179,33 +155,33 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
inner_exception=ex,
|
||||
) from ex
|
||||
|
||||
def _prepare_text_config(
|
||||
def _prepare_response_and_text_format(
|
||||
self,
|
||||
*,
|
||||
response_format: Any,
|
||||
text_config: MutableMapping[str, Any] | None,
|
||||
) -> tuple[type[BaseModel] | None, dict[str, Any] | None]:
|
||||
"""Normalize response_format into Responses text configuration and parse target."""
|
||||
prepared_text = dict(text_config) if isinstance(text_config, MutableMapping) else None
|
||||
if text_config is not None and not isinstance(text_config, MutableMapping):
|
||||
raise ServiceInvalidRequestError("text must be a mapping when provided.")
|
||||
text_config = cast(dict[str, Any], text_config) if isinstance(text_config, MutableMapping) else None
|
||||
|
||||
if response_format is None:
|
||||
return None, prepared_text
|
||||
return None, text_config
|
||||
|
||||
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
|
||||
if prepared_text and "format" in prepared_text:
|
||||
if text_config and "format" in text_config:
|
||||
raise ServiceInvalidRequestError("response_format cannot be combined with explicit text.format.")
|
||||
return response_format, prepared_text
|
||||
return response_format, text_config
|
||||
|
||||
if isinstance(response_format, Mapping):
|
||||
format_config = self._convert_response_format(cast("Mapping[str, Any]", response_format))
|
||||
if prepared_text is None:
|
||||
prepared_text = {}
|
||||
elif "format" in prepared_text and prepared_text["format"] != format_config:
|
||||
if text_config is None:
|
||||
text_config = {}
|
||||
elif "format" in text_config and text_config["format"] != format_config:
|
||||
raise ServiceInvalidRequestError("Conflicting response_format definitions detected.")
|
||||
prepared_text["format"] = format_config
|
||||
return None, prepared_text
|
||||
text_config["format"] = format_config
|
||||
return None, text_config
|
||||
|
||||
raise ServiceInvalidRequestError("response_format must be a Pydantic model or mapping.")
|
||||
|
||||
@@ -245,23 +221,33 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
|
||||
raise ServiceInvalidRequestError("Unsupported response_format provided for Responses client.")
|
||||
|
||||
def get_conversation_id(
|
||||
def _get_conversation_id(
|
||||
self, response: OpenAIResponse | ParsedResponse[BaseModel], store: bool | None
|
||||
) -> str | None:
|
||||
"""Get the conversation ID from the response if store is True."""
|
||||
return None if store is False else response.id
|
||||
if store is False:
|
||||
return None
|
||||
# If conversation ID exists, it means that we operate with conversation
|
||||
# so we use conversation ID as input and output.
|
||||
if response.conversation and response.conversation.id:
|
||||
return response.conversation.id
|
||||
# If conversation ID doesn't exist, we operate with responses
|
||||
# so we use response ID as input and output.
|
||||
return response.id
|
||||
|
||||
# region Prep methods
|
||||
|
||||
def _tools_to_response_tools(
|
||||
self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]]
|
||||
def _prepare_tools_for_openai(
|
||||
self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]] | None
|
||||
) -> list[ToolParam | dict[str, Any]]:
|
||||
response_tools: list[ToolParam | dict[str, Any]] = []
|
||||
if not tools:
|
||||
return response_tools
|
||||
for tool in tools:
|
||||
if isinstance(tool, ToolProtocol):
|
||||
match tool:
|
||||
case HostedMCPTool():
|
||||
response_tools.append(self.get_mcp_tool(tool))
|
||||
response_tools.append(self._prepare_mcp_tool(tool))
|
||||
case HostedCodeInterpreterTool():
|
||||
tool_args: CodeInterpreterContainerCodeInterpreterToolAuto = {"type": "auto"}
|
||||
if tool.inputs:
|
||||
@@ -363,7 +349,8 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
response_tools.append(tool_dict)
|
||||
return response_tools
|
||||
|
||||
def get_mcp_tool(self, tool: HostedMCPTool) -> Any:
|
||||
@staticmethod
|
||||
def _prepare_mcp_tool(tool: HostedMCPTool) -> Mcp:
|
||||
"""Get MCP tool from HostedMCPTool."""
|
||||
mcp: Mcp = {
|
||||
"type": "mcp",
|
||||
@@ -386,18 +373,13 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
|
||||
return mcp
|
||||
|
||||
async def prepare_options(
|
||||
async def _prepare_options(
|
||||
self,
|
||||
messages: MutableSequence[ChatMessage],
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Take ChatOptions and create the specific options for Responses API."""
|
||||
conversation_id = kwargs.pop("conversation_id", None)
|
||||
|
||||
if conversation_id:
|
||||
chat_options.conversation_id = conversation_id
|
||||
|
||||
run_options: dict[str, Any] = chat_options.to_dict(
|
||||
exclude={
|
||||
"type",
|
||||
@@ -407,12 +389,24 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
"seed", # not supported
|
||||
"stop", # not supported
|
||||
"instructions", # already added as system message
|
||||
"response_format", # handled separately
|
||||
"conversation_id", # handled separately
|
||||
"additional_properties", # handled separately
|
||||
}
|
||||
)
|
||||
# messages
|
||||
request_input = self._prepare_messages_for_openai(messages)
|
||||
if not request_input:
|
||||
raise ServiceInvalidRequestError("Messages are required for chat completions")
|
||||
run_options["input"] = request_input
|
||||
|
||||
if chat_options.response_format:
|
||||
run_options["response_format"] = chat_options.response_format
|
||||
# model id
|
||||
if not run_options.get("model"):
|
||||
if not self.model_id:
|
||||
raise ValueError("model_id must be a non-empty string")
|
||||
run_options["model"] = self.model_id
|
||||
|
||||
# translations between ChatOptions and Responses API
|
||||
translations = {
|
||||
"model_id": "model",
|
||||
"allow_multiple_tool_calls": "parallel_tool_calls",
|
||||
@@ -423,34 +417,53 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
if old_key in run_options and old_key != new_key:
|
||||
run_options[new_key] = run_options.pop(old_key)
|
||||
|
||||
# Handle different conversation ID formats
|
||||
if conversation_id := self._get_current_conversation_id(chat_options, **kwargs):
|
||||
if conversation_id.startswith("resp_"):
|
||||
# For response IDs, set previous_response_id and remove conversation property
|
||||
run_options["previous_response_id"] = conversation_id
|
||||
elif conversation_id.startswith("conv_"):
|
||||
# For conversation IDs, set conversation and remove previous_response_id property
|
||||
run_options["conversation"] = conversation_id
|
||||
else:
|
||||
# If the format is unrecognized, default to previous_response_id
|
||||
run_options["previous_response_id"] = conversation_id
|
||||
|
||||
# tools
|
||||
if chat_options.tools is None:
|
||||
run_options.pop("parallel_tool_calls", None)
|
||||
if tools := self._prepare_tools_for_openai(chat_options.tools):
|
||||
run_options["tools"] = tools
|
||||
else:
|
||||
run_options["tools"] = self._tools_to_response_tools(chat_options.tools)
|
||||
|
||||
# model id
|
||||
if not run_options.get("model"):
|
||||
if not self.model_id:
|
||||
raise ValueError("model_id must be a non-empty string")
|
||||
run_options["model"] = self.model_id
|
||||
|
||||
# messages
|
||||
request_input = self._prepare_chat_messages_for_request(messages)
|
||||
if not request_input:
|
||||
raise ServiceInvalidRequestError("Messages are required for chat completions")
|
||||
run_options["input"] = request_input
|
||||
|
||||
# additional provider specific settings
|
||||
if additional_properties := run_options.pop("additional_properties", None):
|
||||
for key, value in additional_properties.items():
|
||||
if value is not None:
|
||||
run_options[key] = value
|
||||
run_options.pop("parallel_tool_calls", None)
|
||||
run_options.pop("tool_choice", None)
|
||||
# tool choice when `tool_choice` is a dict with single key `mode`, extract the mode value
|
||||
if (tool_choice := run_options.get("tool_choice")) and len(tool_choice.keys()) == 1:
|
||||
run_options["tool_choice"] = tool_choice["mode"]
|
||||
|
||||
# additional properties
|
||||
additional_options = {
|
||||
key: value for key, value in chat_options.additional_properties.items() if value is not None
|
||||
}
|
||||
if additional_options:
|
||||
run_options.update(additional_options)
|
||||
|
||||
# response format and text config (after additional_properties so user can pass text via additional_properties)
|
||||
response_format = chat_options.response_format
|
||||
text_config = run_options.pop("text", None)
|
||||
response_format, text_config = self._prepare_response_and_text_format(
|
||||
response_format=response_format, text_config=text_config
|
||||
)
|
||||
if text_config:
|
||||
run_options["text"] = text_config
|
||||
if response_format:
|
||||
run_options["text_format"] = response_format
|
||||
|
||||
return run_options
|
||||
|
||||
def _prepare_chat_messages_for_request(self, chat_messages: Sequence[ChatMessage]) -> list[dict[str, Any]]:
|
||||
def _get_current_conversation_id(self, chat_options: ChatOptions, **kwargs: Any) -> str | None:
|
||||
"""Get the current conversation ID from chat options or kwargs."""
|
||||
return chat_options.conversation_id or kwargs.get("conversation_id")
|
||||
|
||||
def _prepare_messages_for_openai(self, chat_messages: Sequence[ChatMessage]) -> list[dict[str, Any]]:
|
||||
"""Prepare the chat messages for a request.
|
||||
|
||||
Allowing customization of the key names for role/author, and optionally overriding the role.
|
||||
@@ -476,16 +489,16 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
and "fc_id" in content.additional_properties
|
||||
):
|
||||
call_id_to_id[content.call_id] = content.additional_properties["fc_id"]
|
||||
list_of_list = [self._openai_chat_message_parser(message, call_id_to_id) for message in chat_messages]
|
||||
list_of_list = [self._prepare_message_for_openai(message, call_id_to_id) for message in chat_messages]
|
||||
# Flatten the list of lists into a single list
|
||||
return list(chain.from_iterable(list_of_list))
|
||||
|
||||
def _openai_chat_message_parser(
|
||||
def _prepare_message_for_openai(
|
||||
self,
|
||||
message: ChatMessage,
|
||||
call_id_to_id: dict[str, str],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Parse a chat message into the openai format."""
|
||||
"""Prepare a chat message for the OpenAI Responses API format."""
|
||||
all_messages: list[dict[str, Any]] = []
|
||||
args: dict[str, Any] = {
|
||||
"role": message.role.value if isinstance(message.role, Role) else message.role,
|
||||
@@ -497,28 +510,28 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
continue
|
||||
case FunctionResultContent():
|
||||
new_args: dict[str, Any] = {}
|
||||
new_args.update(self._openai_content_parser(message.role, content, call_id_to_id))
|
||||
new_args.update(self._prepare_content_for_openai(message.role, content, call_id_to_id))
|
||||
all_messages.append(new_args)
|
||||
case FunctionCallContent():
|
||||
function_call = self._openai_content_parser(message.role, content, call_id_to_id)
|
||||
function_call = self._prepare_content_for_openai(message.role, content, call_id_to_id)
|
||||
all_messages.append(function_call) # type: ignore
|
||||
case FunctionApprovalResponseContent() | FunctionApprovalRequestContent():
|
||||
all_messages.append(self._openai_content_parser(message.role, content, call_id_to_id)) # type: ignore
|
||||
all_messages.append(self._prepare_content_for_openai(message.role, content, call_id_to_id)) # type: ignore
|
||||
case _:
|
||||
if "content" not in args:
|
||||
args["content"] = []
|
||||
args["content"].append(self._openai_content_parser(message.role, content, call_id_to_id)) # type: ignore
|
||||
args["content"].append(self._prepare_content_for_openai(message.role, content, call_id_to_id)) # type: ignore
|
||||
if "content" in args or "tool_calls" in args:
|
||||
all_messages.append(args)
|
||||
return all_messages
|
||||
|
||||
def _openai_content_parser(
|
||||
def _prepare_content_for_openai(
|
||||
self,
|
||||
role: Role,
|
||||
content: Contents,
|
||||
call_id_to_id: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
"""Parse contents into the openai format."""
|
||||
"""Prepare content for the OpenAI Responses API format."""
|
||||
match content:
|
||||
case TextContent():
|
||||
return {
|
||||
@@ -625,14 +638,13 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
logger.debug("Unsupported content type passed (type: %s)", type(content))
|
||||
return {}
|
||||
|
||||
# region Response creation methods
|
||||
|
||||
def _create_response_content(
|
||||
# region Parse methods
|
||||
def _parse_response_from_openai(
|
||||
self,
|
||||
response: OpenAIResponse | ParsedResponse[BaseModel],
|
||||
chat_options: ChatOptions,
|
||||
) -> "ChatResponse":
|
||||
"""Create a chat message content object from a choice."""
|
||||
"""Parse an OpenAI Responses API response into a ChatResponse."""
|
||||
structured_response: BaseModel | None = response.output_parsed if isinstance(response, ParsedResponse) else None # type: ignore[reportUnknownMemberType]
|
||||
|
||||
metadata: dict[str, Any] = response.metadata or {}
|
||||
@@ -826,11 +838,9 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
"raw_representation": response,
|
||||
}
|
||||
|
||||
conversation_id = self.get_conversation_id(response, chat_options.store) # type: ignore[reportArgumentType]
|
||||
|
||||
if conversation_id:
|
||||
if conversation_id := self._get_conversation_id(response, chat_options.store):
|
||||
args["conversation_id"] = conversation_id
|
||||
if response.usage and (usage_details := self._usage_details_from_openai(response.usage)):
|
||||
if response.usage and (usage_details := self._parse_usage_from_openai(response.usage)):
|
||||
args["usage_details"] = usage_details
|
||||
if structured_response:
|
||||
args["value"] = structured_response
|
||||
@@ -838,16 +848,17 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
args["response_format"] = chat_options.response_format
|
||||
return ChatResponse(**args)
|
||||
|
||||
def _create_streaming_response_content(
|
||||
def _parse_chunk_from_openai(
|
||||
self,
|
||||
event: OpenAIResponseStreamEvent,
|
||||
chat_options: ChatOptions,
|
||||
function_call_ids: dict[int, tuple[str, str]],
|
||||
) -> ChatResponseUpdate:
|
||||
"""Create a streaming chat message content object from a choice."""
|
||||
"""Parse an OpenAI Responses API streaming event into a ChatResponseUpdate."""
|
||||
metadata: dict[str, Any] = {}
|
||||
contents: list[Contents] = []
|
||||
conversation_id: str | None = None
|
||||
response_id: str | None = None
|
||||
model = self.model_id
|
||||
# TODO(peterychang): Add support for other content types
|
||||
match event.type:
|
||||
@@ -930,11 +941,18 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
case "response.reasoning_summary_text.done":
|
||||
contents.append(TextReasoningContent(text=event.text, raw_representation=event))
|
||||
metadata.update(self._get_metadata_from_response(event))
|
||||
case "response.created":
|
||||
response_id = event.response.id
|
||||
conversation_id = self._get_conversation_id(event.response, chat_options.store)
|
||||
case "response.in_progress":
|
||||
response_id = event.response.id
|
||||
conversation_id = self._get_conversation_id(event.response, chat_options.store)
|
||||
case "response.completed":
|
||||
conversation_id = self.get_conversation_id(event.response, chat_options.store)
|
||||
response_id = event.response.id
|
||||
conversation_id = self._get_conversation_id(event.response, chat_options.store)
|
||||
model = event.response.model
|
||||
if event.response.usage:
|
||||
usage = self._usage_details_from_openai(event.response.usage)
|
||||
usage = self._parse_usage_from_openai(event.response.usage)
|
||||
if usage:
|
||||
contents.append(UsageContent(details=usage, raw_representation=event))
|
||||
case "response.output_item.added":
|
||||
@@ -1096,13 +1114,14 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
return ChatResponseUpdate(
|
||||
contents=contents,
|
||||
conversation_id=conversation_id,
|
||||
response_id=response_id,
|
||||
role=Role.ASSISTANT,
|
||||
model_id=model,
|
||||
additional_properties=metadata,
|
||||
raw_representation=event,
|
||||
)
|
||||
|
||||
def _usage_details_from_openai(self, usage: ResponseUsage) -> UsageDetails | None:
|
||||
def _parse_usage_from_openai(self, usage: ResponseUsage) -> UsageDetails | None:
|
||||
details = UsageDetails(
|
||||
input_token_count=usage.input_tokens,
|
||||
output_token_count=usage.output_tokens,
|
||||
|
||||
@@ -160,16 +160,16 @@ class OpenAIBase(SerializationMixin):
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
async def initialize_client(self) -> None:
|
||||
async def _initialize_client(self) -> None:
|
||||
"""Initialize OpenAI client asynchronously.
|
||||
|
||||
Override in subclasses to initialize the OpenAI client asynchronously.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def ensure_client(self) -> AsyncOpenAI:
|
||||
async def _ensure_client(self) -> AsyncOpenAI:
|
||||
"""Ensure OpenAI client is initialized."""
|
||||
await self.initialize_client()
|
||||
await self._initialize_client()
|
||||
if self.client is None:
|
||||
raise ServiceInitializationError("OpenAI client is not initialized")
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -52,6 +52,7 @@ all = [
|
||||
"agent-framework-devui",
|
||||
"agent-framework-lab",
|
||||
"agent-framework-mem0",
|
||||
"agent-framework-ollama",
|
||||
"agent-framework-purview",
|
||||
"agent-framework-redis",
|
||||
]
|
||||
|
||||
@@ -193,7 +193,7 @@ async def test_cmc(
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
stream=False,
|
||||
messages=azure_chat_client._prepare_chat_history_for_request(chat_history), # type: ignore
|
||||
messages=azure_chat_client._prepare_messages_for_openai(chat_history), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ async def test_cmc_with_logit_bias(
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
messages=azure_chat_client._prepare_chat_history_for_request(chat_history), # type: ignore
|
||||
messages=azure_chat_client._prepare_messages_for_openai(chat_history), # type: ignore
|
||||
stream=False,
|
||||
logit_bias=token_bias,
|
||||
)
|
||||
@@ -241,7 +241,7 @@ async def test_cmc_with_stop(
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
messages=azure_chat_client._prepare_chat_history_for_request(chat_history), # type: ignore
|
||||
messages=azure_chat_client._prepare_messages_for_openai(chat_history), # type: ignore
|
||||
stream=False,
|
||||
stop=stop,
|
||||
)
|
||||
@@ -311,7 +311,7 @@ async def test_azure_on_your_data(
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
messages=azure_chat_client._prepare_chat_history_for_request(messages_out), # type: ignore
|
||||
messages=azure_chat_client._prepare_messages_for_openai(messages_out), # type: ignore
|
||||
stream=False,
|
||||
extra_body=expected_data_settings,
|
||||
)
|
||||
@@ -381,7 +381,7 @@ async def test_azure_on_your_data_string(
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
messages=azure_chat_client._prepare_chat_history_for_request(messages_out), # type: ignore
|
||||
messages=azure_chat_client._prepare_messages_for_openai(messages_out), # type: ignore
|
||||
stream=False,
|
||||
extra_body=expected_data_settings,
|
||||
)
|
||||
@@ -438,7 +438,7 @@ async def test_azure_on_your_data_fail(
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
messages=azure_chat_client._prepare_chat_history_for_request(messages_out), # type: ignore
|
||||
messages=azure_chat_client._prepare_messages_for_openai(messages_out), # type: ignore
|
||||
stream=False,
|
||||
extra_body=expected_data_settings,
|
||||
)
|
||||
@@ -584,7 +584,7 @@ async def test_get_streaming(
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
stream=True,
|
||||
messages=azure_chat_client._prepare_chat_history_for_request(chat_history), # type: ignore
|
||||
messages=azure_chat_client._prepare_messages_for_openai(chat_history), # type: ignore
|
||||
# NOTE: The `stream_options={"include_usage": True}` is explicitly enforced in
|
||||
# `OpenAIChatCompletionBase._inner_get_streaming_response`.
|
||||
# To ensure consistency, we align the arguments here accordingly.
|
||||
|
||||
@@ -24,14 +24,14 @@ from agent_framework import (
|
||||
)
|
||||
from agent_framework._mcp import (
|
||||
MCPTool,
|
||||
_ai_content_to_mcp_types,
|
||||
_chat_message_to_mcp_types,
|
||||
_get_input_model_from_mcp_prompt,
|
||||
_get_input_model_from_mcp_tool,
|
||||
_mcp_call_tool_result_to_ai_contents,
|
||||
_mcp_prompt_message_to_chat_message,
|
||||
_mcp_type_to_ai_content,
|
||||
_normalize_mcp_name,
|
||||
_parse_content_from_mcp,
|
||||
_parse_contents_from_mcp_tool_result,
|
||||
_parse_message_from_mcp,
|
||||
_prepare_content_for_mcp,
|
||||
_prepare_message_for_mcp,
|
||||
)
|
||||
from agent_framework.exceptions import ToolException, ToolExecutionException
|
||||
|
||||
@@ -60,7 +60,7 @@ def test_normalize_mcp_name():
|
||||
def test_mcp_prompt_message_to_ai_content():
|
||||
"""Test conversion from MCP prompt message to AI content."""
|
||||
mcp_message = types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hello, world!"))
|
||||
ai_content = _mcp_prompt_message_to_chat_message(mcp_message)
|
||||
ai_content = _parse_message_from_mcp(mcp_message)
|
||||
|
||||
assert isinstance(ai_content, ChatMessage)
|
||||
assert ai_content.role.value == "user"
|
||||
@@ -70,22 +70,26 @@ def test_mcp_prompt_message_to_ai_content():
|
||||
assert ai_content.raw_representation == mcp_message
|
||||
|
||||
|
||||
def test_mcp_call_tool_result_to_ai_contents():
|
||||
def test_parse_contents_from_mcp_tool_result():
|
||||
"""Test conversion from MCP tool result to AI contents."""
|
||||
mcp_result = types.CallToolResult(
|
||||
content=[
|
||||
types.TextContent(type="text", text="Result text"),
|
||||
types.ImageContent(type="image", data="data:image/png;base64,xyz", mimeType="image/png"),
|
||||
types.ImageContent(type="image", data="xyz", mimeType="image/png"),
|
||||
types.ImageContent(type="image", data=b"abc", mimeType="image/webp"),
|
||||
]
|
||||
)
|
||||
ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result)
|
||||
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
|
||||
|
||||
assert len(ai_contents) == 2
|
||||
assert len(ai_contents) == 3
|
||||
assert isinstance(ai_contents[0], TextContent)
|
||||
assert ai_contents[0].text == "Result text"
|
||||
assert isinstance(ai_contents[1], DataContent)
|
||||
assert ai_contents[1].uri == "data:image/png;base64,xyz"
|
||||
assert ai_contents[1].media_type == "image/png"
|
||||
assert isinstance(ai_contents[2], DataContent)
|
||||
assert ai_contents[2].uri == "data:image/webp;base64,abc"
|
||||
assert ai_contents[2].media_type == "image/webp"
|
||||
|
||||
|
||||
def test_mcp_call_tool_result_with_meta_error():
|
||||
@@ -96,7 +100,7 @@ def test_mcp_call_tool_result_with_meta_error():
|
||||
_meta={"isError": True, "errorCode": "TOOL_ERROR", "errorMessage": "Tool execution failed"},
|
||||
)
|
||||
|
||||
ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result)
|
||||
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
|
||||
|
||||
assert len(ai_contents) == 1
|
||||
assert isinstance(ai_contents[0], TextContent)
|
||||
@@ -127,7 +131,7 @@ def test_mcp_call_tool_result_with_meta_arbitrary_data():
|
||||
},
|
||||
)
|
||||
|
||||
ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result)
|
||||
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
|
||||
|
||||
assert len(ai_contents) == 1
|
||||
assert isinstance(ai_contents[0], TextContent)
|
||||
@@ -149,7 +153,7 @@ def test_mcp_call_tool_result_with_meta_merging_existing_properties():
|
||||
text_content = types.TextContent(type="text", text="Test content")
|
||||
mcp_result = types.CallToolResult(content=[text_content], _meta={"newField": "newValue", "isError": False})
|
||||
|
||||
ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result)
|
||||
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
|
||||
|
||||
assert len(ai_contents) == 1
|
||||
content = ai_contents[0]
|
||||
@@ -165,7 +169,7 @@ def test_mcp_call_tool_result_with_meta_none():
|
||||
mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="No meta test")])
|
||||
# No _meta field set
|
||||
|
||||
ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result)
|
||||
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
|
||||
|
||||
assert len(ai_contents) == 1
|
||||
assert isinstance(ai_contents[0], TextContent)
|
||||
@@ -183,11 +187,11 @@ def test_mcp_call_tool_result_regression_successful_workflow():
|
||||
mcp_result = types.CallToolResult(
|
||||
content=[
|
||||
types.TextContent(type="text", text="Success message"),
|
||||
types.ImageContent(type="image", data="data:image/jpeg;base64,abc123", mimeType="image/jpeg"),
|
||||
types.ImageContent(type="image", data="abc123", mimeType="image/jpeg"),
|
||||
]
|
||||
)
|
||||
|
||||
ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result)
|
||||
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
|
||||
|
||||
# Verify basic conversion still works correctly
|
||||
assert len(ai_contents) == 2
|
||||
@@ -209,7 +213,7 @@ def test_mcp_call_tool_result_regression_successful_workflow():
|
||||
def test_mcp_content_types_to_ai_content_text():
|
||||
"""Test conversion of MCP text content to AI content."""
|
||||
mcp_content = types.TextContent(type="text", text="Sample text")
|
||||
ai_content = _mcp_type_to_ai_content(mcp_content)[0]
|
||||
ai_content = _parse_content_from_mcp(mcp_content)[0]
|
||||
|
||||
assert isinstance(ai_content, TextContent)
|
||||
assert ai_content.text == "Sample text"
|
||||
@@ -218,8 +222,9 @@ def test_mcp_content_types_to_ai_content_text():
|
||||
|
||||
def test_mcp_content_types_to_ai_content_image():
|
||||
"""Test conversion of MCP image content to AI content."""
|
||||
mcp_content = types.ImageContent(type="image", data="data:image/jpeg;base64,abc", mimeType="image/jpeg")
|
||||
ai_content = _mcp_type_to_ai_content(mcp_content)[0]
|
||||
mcp_content = types.ImageContent(type="image", data="abc", mimeType="image/jpeg")
|
||||
mcp_content = types.ImageContent(type="image", data=b"abc", mimeType="image/jpeg")
|
||||
ai_content = _parse_content_from_mcp(mcp_content)[0]
|
||||
|
||||
assert isinstance(ai_content, DataContent)
|
||||
assert ai_content.uri == "data:image/jpeg;base64,abc"
|
||||
@@ -229,8 +234,8 @@ def test_mcp_content_types_to_ai_content_image():
|
||||
|
||||
def test_mcp_content_types_to_ai_content_audio():
|
||||
"""Test conversion of MCP audio content to AI content."""
|
||||
mcp_content = types.AudioContent(type="audio", data="data:audio/wav;base64,def", mimeType="audio/wav")
|
||||
ai_content = _mcp_type_to_ai_content(mcp_content)[0]
|
||||
mcp_content = types.AudioContent(type="audio", data="def", mimeType="audio/wav")
|
||||
ai_content = _parse_content_from_mcp(mcp_content)[0]
|
||||
|
||||
assert isinstance(ai_content, DataContent)
|
||||
assert ai_content.uri == "data:audio/wav;base64,def"
|
||||
@@ -246,7 +251,7 @@ def test_mcp_content_types_to_ai_content_resource_link():
|
||||
name="test_resource",
|
||||
mimeType="application/json",
|
||||
)
|
||||
ai_content = _mcp_type_to_ai_content(mcp_content)[0]
|
||||
ai_content = _parse_content_from_mcp(mcp_content)[0]
|
||||
|
||||
assert isinstance(ai_content, UriContent)
|
||||
assert ai_content.uri == "https://example.com/resource"
|
||||
@@ -262,7 +267,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_text():
|
||||
text="Embedded text content",
|
||||
)
|
||||
mcp_content = types.EmbeddedResource(type="resource", resource=text_resource)
|
||||
ai_content = _mcp_type_to_ai_content(mcp_content)[0]
|
||||
ai_content = _parse_content_from_mcp(mcp_content)[0]
|
||||
|
||||
assert isinstance(ai_content, TextContent)
|
||||
assert ai_content.text == "Embedded text content"
|
||||
@@ -278,7 +283,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_blob():
|
||||
blob="data:application/octet-stream;base64,dGVzdCBkYXRh",
|
||||
)
|
||||
mcp_content = types.EmbeddedResource(type="resource", resource=blob_resource)
|
||||
ai_content = _mcp_type_to_ai_content(mcp_content)[0]
|
||||
ai_content = _parse_content_from_mcp(mcp_content)[0]
|
||||
|
||||
assert isinstance(ai_content, DataContent)
|
||||
assert ai_content.uri == "data:application/octet-stream;base64,dGVzdCBkYXRh"
|
||||
@@ -289,7 +294,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_blob():
|
||||
def test_ai_content_to_mcp_content_types_text():
|
||||
"""Test conversion of AI text content to MCP content."""
|
||||
ai_content = TextContent(text="Sample text")
|
||||
mcp_content = _ai_content_to_mcp_types(ai_content)
|
||||
mcp_content = _prepare_content_for_mcp(ai_content)
|
||||
|
||||
assert isinstance(mcp_content, types.TextContent)
|
||||
assert mcp_content.type == "text"
|
||||
@@ -299,7 +304,7 @@ def test_ai_content_to_mcp_content_types_text():
|
||||
def test_ai_content_to_mcp_content_types_data_image():
|
||||
"""Test conversion of AI data content to MCP content."""
|
||||
ai_content = DataContent(uri="data:image/png;base64,xyz", media_type="image/png")
|
||||
mcp_content = _ai_content_to_mcp_types(ai_content)
|
||||
mcp_content = _prepare_content_for_mcp(ai_content)
|
||||
|
||||
assert isinstance(mcp_content, types.ImageContent)
|
||||
assert mcp_content.type == "image"
|
||||
@@ -310,7 +315,7 @@ def test_ai_content_to_mcp_content_types_data_image():
|
||||
def test_ai_content_to_mcp_content_types_data_audio():
|
||||
"""Test conversion of AI data content to MCP content."""
|
||||
ai_content = DataContent(uri="data:audio/mpeg;base64,xyz", media_type="audio/mpeg")
|
||||
mcp_content = _ai_content_to_mcp_types(ai_content)
|
||||
mcp_content = _prepare_content_for_mcp(ai_content)
|
||||
|
||||
assert isinstance(mcp_content, types.AudioContent)
|
||||
assert mcp_content.type == "audio"
|
||||
@@ -324,7 +329,7 @@ def test_ai_content_to_mcp_content_types_data_binary():
|
||||
uri="data:application/octet-stream;base64,xyz",
|
||||
media_type="application/octet-stream",
|
||||
)
|
||||
mcp_content = _ai_content_to_mcp_types(ai_content)
|
||||
mcp_content = _prepare_content_for_mcp(ai_content)
|
||||
|
||||
assert isinstance(mcp_content, types.EmbeddedResource)
|
||||
assert mcp_content.type == "resource"
|
||||
@@ -335,7 +340,7 @@ def test_ai_content_to_mcp_content_types_data_binary():
|
||||
def test_ai_content_to_mcp_content_types_uri():
|
||||
"""Test conversion of AI URI content to MCP content."""
|
||||
ai_content = UriContent(uri="https://example.com/resource", media_type="application/json")
|
||||
mcp_content = _ai_content_to_mcp_types(ai_content)
|
||||
mcp_content = _prepare_content_for_mcp(ai_content)
|
||||
|
||||
assert isinstance(mcp_content, types.ResourceLink)
|
||||
assert mcp_content.type == "resource_link"
|
||||
@@ -343,7 +348,7 @@ def test_ai_content_to_mcp_content_types_uri():
|
||||
assert mcp_content.mimeType == "application/json"
|
||||
|
||||
|
||||
def test_chat_message_to_mcp_types():
|
||||
def test_prepare_message_for_mcp():
|
||||
message = ChatMessage(
|
||||
role="user",
|
||||
contents=[
|
||||
@@ -351,7 +356,7 @@ def test_chat_message_to_mcp_types():
|
||||
DataContent(uri="data:image/png;base64,xyz", media_type="image/png"),
|
||||
],
|
||||
)
|
||||
mcp_contents = _chat_message_to_mcp_types(message)
|
||||
mcp_contents = _prepare_message_for_mcp(message)
|
||||
assert len(mcp_contents) == 2
|
||||
assert isinstance(mcp_contents[0], types.TextContent)
|
||||
assert isinstance(mcp_contents[1], types.ImageContent)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
from typing import Any
|
||||
from typing import Annotated, Any, Literal
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
@@ -14,7 +14,7 @@ from agent_framework import (
|
||||
ToolProtocol,
|
||||
ai_function,
|
||||
)
|
||||
from agent_framework._tools import _parse_inputs
|
||||
from agent_framework._tools import _parse_annotation, _parse_inputs
|
||||
from agent_framework.exceptions import ToolException
|
||||
from agent_framework.observability import OtelAttr
|
||||
|
||||
@@ -128,6 +128,95 @@ def test_ai_function_decorator_in_class():
|
||||
assert test_tool(1, 2) == 3
|
||||
|
||||
|
||||
def test_ai_function_with_literal_type_parameter():
|
||||
"""Test ai_function decorator with Literal type parameter (issue #2891)."""
|
||||
|
||||
@ai_function
|
||||
def search_flows(category: Literal["Data", "Security", "Network"], issue: str) -> str:
|
||||
"""Search flows by category."""
|
||||
return f"{category}: {issue}"
|
||||
|
||||
assert isinstance(search_flows, AIFunction)
|
||||
schema = search_flows.parameters()
|
||||
assert schema == {
|
||||
"properties": {
|
||||
"category": {"enum": ["Data", "Security", "Network"], "title": "Category", "type": "string"},
|
||||
"issue": {"title": "Issue", "type": "string"},
|
||||
},
|
||||
"required": ["category", "issue"],
|
||||
"title": "search_flows_input",
|
||||
"type": "object",
|
||||
}
|
||||
# Verify invocation works
|
||||
assert search_flows("Data", "test issue") == "Data: test issue"
|
||||
|
||||
|
||||
def test_ai_function_with_literal_type_in_class_method():
|
||||
"""Test ai_function decorator with Literal type parameter in a class method (issue #2891)."""
|
||||
|
||||
class MyTools:
|
||||
@ai_function
|
||||
def search_flows(self, category: Literal["Data", "Security", "Network"], issue: str) -> str:
|
||||
"""Search flows by category."""
|
||||
return f"{category}: {issue}"
|
||||
|
||||
tools = MyTools()
|
||||
search_tool = tools.search_flows
|
||||
assert isinstance(search_tool, AIFunction)
|
||||
schema = search_tool.parameters()
|
||||
assert schema == {
|
||||
"properties": {
|
||||
"category": {"enum": ["Data", "Security", "Network"], "title": "Category", "type": "string"},
|
||||
"issue": {"title": "Issue", "type": "string"},
|
||||
},
|
||||
"required": ["category", "issue"],
|
||||
"title": "search_flows_input",
|
||||
"type": "object",
|
||||
}
|
||||
# Verify invocation works
|
||||
assert search_tool("Security", "test issue") == "Security: test issue"
|
||||
|
||||
|
||||
def test_ai_function_with_literal_int_type():
|
||||
"""Test ai_function decorator with Literal int type parameter."""
|
||||
|
||||
@ai_function
|
||||
def set_priority(priority: Literal[1, 2, 3], task: str) -> str:
|
||||
"""Set priority for a task."""
|
||||
return f"Priority {priority}: {task}"
|
||||
|
||||
assert isinstance(set_priority, AIFunction)
|
||||
schema = set_priority.parameters()
|
||||
assert schema == {
|
||||
"properties": {
|
||||
"priority": {"enum": [1, 2, 3], "title": "Priority", "type": "integer"},
|
||||
"task": {"title": "Task", "type": "string"},
|
||||
},
|
||||
"required": ["priority", "task"],
|
||||
"title": "set_priority_input",
|
||||
"type": "object",
|
||||
}
|
||||
assert set_priority(1, "important task") == "Priority 1: important task"
|
||||
|
||||
|
||||
def test_ai_function_with_literal_and_annotated():
|
||||
"""Test ai_function decorator with Literal type combined with Annotated for description."""
|
||||
|
||||
@ai_function
|
||||
def categorize(
|
||||
category: Annotated[Literal["A", "B", "C"], "The category to assign"],
|
||||
name: str,
|
||||
) -> str:
|
||||
"""Categorize an item."""
|
||||
return f"{category}: {name}"
|
||||
|
||||
assert isinstance(categorize, AIFunction)
|
||||
schema = categorize.parameters()
|
||||
# Literal type inside Annotated should preserve enum values
|
||||
assert schema["properties"]["category"]["enum"] == ["A", "B", "C"]
|
||||
assert categorize("A", "test") == "A: test"
|
||||
|
||||
|
||||
async def test_ai_function_decorator_shared_state():
|
||||
"""Test that decorated methods maintain shared state across multiple calls and tool usage."""
|
||||
|
||||
@@ -1368,3 +1457,70 @@ async def test_ai_function_with_kwargs_injection():
|
||||
arguments=tool_with_kwargs.input_model(x=10),
|
||||
)
|
||||
assert result_default == "x=10, user=unknown"
|
||||
|
||||
|
||||
# region _parse_annotation tests
|
||||
|
||||
|
||||
def test_parse_annotation_with_literal_type():
|
||||
"""Test that _parse_annotation returns Literal types unchanged (issue #2891)."""
|
||||
from typing import get_args, get_origin
|
||||
|
||||
# Literal with string values
|
||||
literal_annotation = Literal["Data", "Security", "Network"]
|
||||
result = _parse_annotation(literal_annotation)
|
||||
assert result is literal_annotation
|
||||
assert get_origin(result) is Literal
|
||||
assert get_args(result) == ("Data", "Security", "Network")
|
||||
|
||||
|
||||
def test_parse_annotation_with_literal_int_type():
|
||||
"""Test that _parse_annotation returns Literal int types unchanged."""
|
||||
from typing import get_args, get_origin
|
||||
|
||||
literal_annotation = Literal[1, 2, 3]
|
||||
result = _parse_annotation(literal_annotation)
|
||||
assert result is literal_annotation
|
||||
assert get_origin(result) is Literal
|
||||
assert get_args(result) == (1, 2, 3)
|
||||
|
||||
|
||||
def test_parse_annotation_with_literal_bool_type():
|
||||
"""Test that _parse_annotation returns Literal bool types unchanged."""
|
||||
from typing import get_args, get_origin
|
||||
|
||||
literal_annotation = Literal[True, False]
|
||||
result = _parse_annotation(literal_annotation)
|
||||
assert result is literal_annotation
|
||||
assert get_origin(result) is Literal
|
||||
assert get_args(result) == (True, False)
|
||||
|
||||
|
||||
def test_parse_annotation_with_simple_types():
|
||||
"""Test that _parse_annotation returns simple types unchanged."""
|
||||
assert _parse_annotation(str) is str
|
||||
assert _parse_annotation(int) is int
|
||||
assert _parse_annotation(float) is float
|
||||
assert _parse_annotation(bool) is bool
|
||||
|
||||
|
||||
def test_parse_annotation_with_annotated_and_literal():
|
||||
"""Test that Annotated[Literal[...], description] works correctly."""
|
||||
from typing import get_args, get_origin
|
||||
|
||||
# When Literal is inside Annotated, it should still be preserved
|
||||
annotated_literal = Annotated[Literal["A", "B", "C"], "The category"]
|
||||
result = _parse_annotation(annotated_literal)
|
||||
|
||||
# The Annotated type should be preserved
|
||||
origin = get_origin(result)
|
||||
assert origin is Annotated
|
||||
|
||||
args = get_args(result)
|
||||
# First arg is the Literal type
|
||||
literal_type = args[0]
|
||||
assert get_origin(literal_type) is Literal
|
||||
assert get_args(literal_type) == ("A", "B", "C")
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -463,9 +463,9 @@ async def test_openai_assistants_client_process_stream_events_requires_action(mo
|
||||
"""Test _process_stream_events with thread.run.requires_action event."""
|
||||
chat_client = create_test_openai_assistants_client(mock_async_openai)
|
||||
|
||||
# Mock the _create_function_call_contents method to return test content
|
||||
# Mock the _parse_function_calls_from_assistants method to return test content
|
||||
test_function_content = FunctionCallContent(call_id="call-123", name="test_func", arguments={"arg": "value"})
|
||||
chat_client._create_function_call_contents = MagicMock(return_value=[test_function_content]) # type: ignore
|
||||
chat_client._parse_function_calls_from_assistants = MagicMock(return_value=[test_function_content]) # type: ignore
|
||||
|
||||
# Create a mock Run object
|
||||
mock_run = MagicMock(spec=Run)
|
||||
@@ -498,8 +498,8 @@ async def test_openai_assistants_client_process_stream_events_requires_action(mo
|
||||
assert update.contents[0] == test_function_content
|
||||
assert update.raw_representation == mock_run
|
||||
|
||||
# Verify _create_function_call_contents was called correctly
|
||||
chat_client._create_function_call_contents.assert_called_once_with(mock_run, None) # type: ignore
|
||||
# Verify _parse_function_calls_from_assistants was called correctly
|
||||
chat_client._parse_function_calls_from_assistants.assert_called_once_with(mock_run, None) # type: ignore
|
||||
|
||||
|
||||
async def test_openai_assistants_client_process_stream_events_run_step_created(mock_async_openai: MagicMock) -> None:
|
||||
@@ -585,8 +585,8 @@ async def test_openai_assistants_client_process_stream_events_run_completed_with
|
||||
assert update.raw_representation == mock_run
|
||||
|
||||
|
||||
def test_openai_assistants_client_create_function_call_contents_basic(mock_async_openai: MagicMock) -> None:
|
||||
"""Test _create_function_call_contents with a simple function call."""
|
||||
def test_openai_assistants_client_parse_function_calls_from_assistants_basic(mock_async_openai: MagicMock) -> None:
|
||||
"""Test _parse_function_calls_from_assistants with a simple function call."""
|
||||
|
||||
chat_client = create_test_openai_assistants_client(mock_async_openai)
|
||||
|
||||
@@ -605,7 +605,7 @@ def test_openai_assistants_client_create_function_call_contents_basic(mock_async
|
||||
|
||||
# Call the method
|
||||
response_id = "response_456"
|
||||
contents = chat_client._create_function_call_contents(mock_run, response_id) # type: ignore
|
||||
contents = chat_client._parse_function_calls_from_assistants(mock_run, response_id) # type: ignore
|
||||
|
||||
# Test that one function call content was created
|
||||
assert len(contents) == 1
|
||||
@@ -825,24 +825,24 @@ def test_openai_assistants_client_prepare_options_with_image_content(mock_async_
|
||||
assert message["content"][0]["image_url"]["url"] == "https://example.com/image.jpg"
|
||||
|
||||
|
||||
def test_openai_assistants_client_convert_function_results_to_tool_output_empty(mock_async_openai: MagicMock) -> None:
|
||||
"""Test _convert_function_results_to_tool_output with empty list."""
|
||||
def test_openai_assistants_client_prepare_tool_outputs_for_assistants_empty(mock_async_openai: MagicMock) -> None:
|
||||
"""Test _prepare_tool_outputs_for_assistants with empty list."""
|
||||
chat_client = create_test_openai_assistants_client(mock_async_openai)
|
||||
|
||||
run_id, tool_outputs = chat_client._convert_function_results_to_tool_output([]) # type: ignore
|
||||
run_id, tool_outputs = chat_client._prepare_tool_outputs_for_assistants([]) # type: ignore
|
||||
|
||||
assert run_id is None
|
||||
assert tool_outputs is None
|
||||
|
||||
|
||||
def test_openai_assistants_client_convert_function_results_to_tool_output_valid(mock_async_openai: MagicMock) -> None:
|
||||
"""Test _convert_function_results_to_tool_output with valid function results."""
|
||||
def test_openai_assistants_client_prepare_tool_outputs_for_assistants_valid(mock_async_openai: MagicMock) -> None:
|
||||
"""Test _prepare_tool_outputs_for_assistants with valid function results."""
|
||||
chat_client = create_test_openai_assistants_client(mock_async_openai)
|
||||
|
||||
call_id = json.dumps(["run-123", "call-456"])
|
||||
function_result = FunctionResultContent(call_id=call_id, result="Function executed successfully")
|
||||
|
||||
run_id, tool_outputs = chat_client._convert_function_results_to_tool_output([function_result]) # type: ignore
|
||||
run_id, tool_outputs = chat_client._prepare_tool_outputs_for_assistants([function_result]) # type: ignore
|
||||
|
||||
assert run_id == "run-123"
|
||||
assert tool_outputs is not None
|
||||
@@ -851,10 +851,10 @@ def test_openai_assistants_client_convert_function_results_to_tool_output_valid(
|
||||
assert tool_outputs[0].get("output") == "Function executed successfully"
|
||||
|
||||
|
||||
def test_openai_assistants_client_convert_function_results_to_tool_output_mismatched_run_ids(
|
||||
def test_openai_assistants_client_prepare_tool_outputs_for_assistants_mismatched_run_ids(
|
||||
mock_async_openai: MagicMock,
|
||||
) -> None:
|
||||
"""Test _convert_function_results_to_tool_output with mismatched run IDs."""
|
||||
"""Test _prepare_tool_outputs_for_assistants with mismatched run IDs."""
|
||||
chat_client = create_test_openai_assistants_client(mock_async_openai)
|
||||
|
||||
# Create function results with different run IDs
|
||||
@@ -863,7 +863,7 @@ def test_openai_assistants_client_convert_function_results_to_tool_output_mismat
|
||||
function_result1 = FunctionResultContent(call_id=call_id1, result="Result 1")
|
||||
function_result2 = FunctionResultContent(call_id=call_id2, result="Result 2")
|
||||
|
||||
run_id, tool_outputs = chat_client._convert_function_results_to_tool_output([function_result1, function_result2]) # type: ignore
|
||||
run_id, tool_outputs = chat_client._prepare_tool_outputs_for_assistants([function_result1, function_result2]) # type: ignore
|
||||
|
||||
# Should only process the first one since run IDs don't match
|
||||
assert run_id == "run-123"
|
||||
|
||||
@@ -182,12 +182,12 @@ def test_unsupported_tool_handling(openai_unit_test_env: dict[str, str]) -> None
|
||||
unsupported_tool.__class__.__name__ = "UnsupportedAITool"
|
||||
|
||||
# This should ignore the unsupported ToolProtocol and return empty list
|
||||
result = client._chat_to_tool_spec([unsupported_tool]) # type: ignore
|
||||
result = client._prepare_tools_for_openai([unsupported_tool]) # type: ignore
|
||||
assert result == []
|
||||
|
||||
# Also test with a non-ToolProtocol that should be converted to dict
|
||||
dict_tool = {"type": "function", "name": "test"}
|
||||
result = client._chat_to_tool_spec([dict_tool]) # type: ignore
|
||||
result = client._prepare_tools_for_openai([dict_tool]) # type: ignore
|
||||
assert result == [dict_tool]
|
||||
|
||||
|
||||
@@ -637,7 +637,7 @@ def test_chat_response_content_order_text_before_tool_calls(openai_unit_test_env
|
||||
)
|
||||
|
||||
client = OpenAIChatClient()
|
||||
response = client._create_chat_response(mock_response, ChatOptions())
|
||||
response = client._parse_response_from_openai(mock_response, ChatOptions())
|
||||
|
||||
# Verify we have both text and tool call content
|
||||
assert len(response.messages) == 1
|
||||
@@ -658,7 +658,7 @@ def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, s
|
||||
# Test with empty list (falsy but not None)
|
||||
message_with_empty_list = ChatMessage(role="tool", contents=[FunctionResultContent(call_id="call-123", result=[])])
|
||||
|
||||
openai_messages = client._openai_chat_message_parser(message_with_empty_list)
|
||||
openai_messages = client._prepare_message_for_openai(message_with_empty_list)
|
||||
assert len(openai_messages) == 1
|
||||
assert openai_messages[0]["content"] == "[]" # Empty list should be JSON serialized
|
||||
|
||||
@@ -667,14 +667,14 @@ def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, s
|
||||
role="tool", contents=[FunctionResultContent(call_id="call-456", result="")]
|
||||
)
|
||||
|
||||
openai_messages = client._openai_chat_message_parser(message_with_empty_string)
|
||||
openai_messages = client._prepare_message_for_openai(message_with_empty_string)
|
||||
assert len(openai_messages) == 1
|
||||
assert openai_messages[0]["content"] == "" # Empty string should be preserved
|
||||
|
||||
# Test with False (falsy but not None)
|
||||
message_with_false = ChatMessage(role="tool", contents=[FunctionResultContent(call_id="call-789", result=False)])
|
||||
|
||||
openai_messages = client._openai_chat_message_parser(message_with_false)
|
||||
openai_messages = client._prepare_message_for_openai(message_with_false)
|
||||
assert len(openai_messages) == 1
|
||||
assert openai_messages[0]["content"] == "false" # False should be JSON serialized
|
||||
|
||||
@@ -695,7 +695,7 @@ def test_function_result_exception_handling(openai_unit_test_env: dict[str, str]
|
||||
],
|
||||
)
|
||||
|
||||
openai_messages = client._openai_chat_message_parser(message_with_exception)
|
||||
openai_messages = client._prepare_message_for_openai(message_with_exception)
|
||||
assert len(openai_messages) == 1
|
||||
assert openai_messages[0]["content"] == "Error: Function failed."
|
||||
assert openai_messages[0]["tool_call_id"] == "call-123"
|
||||
@@ -708,8 +708,8 @@ def test_prepare_function_call_results_string_passthrough():
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
def test_openai_content_parser_data_content_image(openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test _openai_content_parser converts DataContent with image media type to OpenAI format."""
|
||||
def test_prepare_content_for_openai_data_content_image(openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test _prepare_content_for_openai converts DataContent with image media type to OpenAI format."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
# Test DataContent with image media type
|
||||
@@ -718,7 +718,7 @@ def test_openai_content_parser_data_content_image(openai_unit_test_env: dict[str
|
||||
media_type="image/png",
|
||||
)
|
||||
|
||||
result = client._openai_content_parser(image_data_content) # type: ignore
|
||||
result = client._prepare_content_for_openai(image_data_content) # type: ignore
|
||||
|
||||
# Should convert to OpenAI image_url format
|
||||
assert result["type"] == "image_url"
|
||||
@@ -727,7 +727,7 @@ def test_openai_content_parser_data_content_image(openai_unit_test_env: dict[str
|
||||
# Test DataContent with non-image media type should use default model_dump
|
||||
text_data_content = DataContent(uri="data:text/plain;base64,SGVsbG8gV29ybGQ=", media_type="text/plain")
|
||||
|
||||
result = client._openai_content_parser(text_data_content) # type: ignore
|
||||
result = client._prepare_content_for_openai(text_data_content) # type: ignore
|
||||
|
||||
# Should use default model_dump format
|
||||
assert result["type"] == "data"
|
||||
@@ -740,7 +740,7 @@ def test_openai_content_parser_data_content_image(openai_unit_test_env: dict[str
|
||||
media_type="audio/wav",
|
||||
)
|
||||
|
||||
result = client._openai_content_parser(audio_data_content) # type: ignore
|
||||
result = client._prepare_content_for_openai(audio_data_content) # type: ignore
|
||||
|
||||
# Should convert to OpenAI input_audio format
|
||||
assert result["type"] == "input_audio"
|
||||
@@ -751,7 +751,7 @@ def test_openai_content_parser_data_content_image(openai_unit_test_env: dict[str
|
||||
# Test DataContent with MP3 audio
|
||||
mp3_data_content = DataContent(uri="data:audio/mp3;base64,//uQAAAAWGluZwAAAA8AAAACAAACcQ==", media_type="audio/mp3")
|
||||
|
||||
result = client._openai_content_parser(mp3_data_content) # type: ignore
|
||||
result = client._prepare_content_for_openai(mp3_data_content) # type: ignore
|
||||
|
||||
# Should convert to OpenAI input_audio format with mp3
|
||||
assert result["type"] == "input_audio"
|
||||
@@ -760,8 +760,8 @@ def test_openai_content_parser_data_content_image(openai_unit_test_env: dict[str
|
||||
assert result["input_audio"]["format"] == "mp3"
|
||||
|
||||
|
||||
def test_openai_content_parser_document_file_mapping(openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test _openai_content_parser converts document files (PDF, DOCX, etc.) to OpenAI file format."""
|
||||
def test_prepare_content_for_openai_document_file_mapping(openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test _prepare_content_for_openai converts document files (PDF, DOCX, etc.) to OpenAI file format."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
# Test PDF without filename - should omit filename in OpenAI payload
|
||||
@@ -770,7 +770,7 @@ def test_openai_content_parser_document_file_mapping(openai_unit_test_env: dict[
|
||||
media_type="application/pdf",
|
||||
)
|
||||
|
||||
result = client._openai_content_parser(pdf_data_content) # type: ignore
|
||||
result = client._prepare_content_for_openai(pdf_data_content) # type: ignore
|
||||
|
||||
# Should convert to OpenAI file format without filename
|
||||
assert result["type"] == "file"
|
||||
@@ -787,7 +787,7 @@ def test_openai_content_parser_document_file_mapping(openai_unit_test_env: dict[
|
||||
additional_properties={"filename": "report.pdf"},
|
||||
)
|
||||
|
||||
result = client._openai_content_parser(pdf_with_filename) # type: ignore
|
||||
result = client._prepare_content_for_openai(pdf_with_filename) # type: ignore
|
||||
|
||||
# Should use custom filename
|
||||
assert result["type"] == "file"
|
||||
@@ -820,7 +820,7 @@ def test_openai_content_parser_document_file_mapping(openai_unit_test_env: dict[
|
||||
media_type=case["media_type"],
|
||||
)
|
||||
|
||||
result = client._openai_content_parser(doc_content) # type: ignore
|
||||
result = client._prepare_content_for_openai(doc_content) # type: ignore
|
||||
|
||||
# All application/* types should now be mapped to file format
|
||||
assert result["type"] == "file"
|
||||
@@ -834,7 +834,7 @@ def test_openai_content_parser_document_file_mapping(openai_unit_test_env: dict[
|
||||
additional_properties={"filename": case["filename"]},
|
||||
)
|
||||
|
||||
result = client._openai_content_parser(doc_with_filename) # type: ignore
|
||||
result = client._prepare_content_for_openai(doc_with_filename) # type: ignore
|
||||
|
||||
# Should now use file format with filename
|
||||
assert result["type"] == "file"
|
||||
@@ -848,7 +848,7 @@ def test_openai_content_parser_document_file_mapping(openai_unit_test_env: dict[
|
||||
additional_properties={},
|
||||
)
|
||||
|
||||
result = client._openai_content_parser(pdf_empty_props) # type: ignore
|
||||
result = client._prepare_content_for_openai(pdf_empty_props) # type: ignore
|
||||
|
||||
assert result["type"] == "file"
|
||||
assert "filename" not in result["file"]
|
||||
@@ -860,7 +860,7 @@ def test_openai_content_parser_document_file_mapping(openai_unit_test_env: dict[
|
||||
additional_properties={"filename": None},
|
||||
)
|
||||
|
||||
result = client._openai_content_parser(pdf_none_filename) # type: ignore
|
||||
result = client._prepare_content_for_openai(pdf_none_filename) # type: ignore
|
||||
|
||||
assert result["type"] == "file"
|
||||
assert "filename" not in result["file"] # None filename should be omitted
|
||||
|
||||
@@ -76,7 +76,7 @@ async def test_cmc(
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
|
||||
stream=False,
|
||||
messages=openai_chat_completion._prepare_chat_history_for_request(chat_history), # type: ignore
|
||||
messages=openai_chat_completion._prepare_messages_for_openai(chat_history), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ async def test_cmc_chat_options(
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
|
||||
stream=False,
|
||||
messages=openai_chat_completion._prepare_chat_history_for_request(chat_history), # type: ignore
|
||||
messages=openai_chat_completion._prepare_messages_for_openai(chat_history), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ async def test_cmc_no_fcc_in_response(
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
|
||||
stream=False,
|
||||
messages=openai_chat_completion._prepare_chat_history_for_request(orig_chat_history), # type: ignore
|
||||
messages=openai_chat_completion._prepare_messages_for_openai(orig_chat_history), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ async def test_scmc_chat_options(
|
||||
model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
messages=openai_chat_completion._prepare_chat_history_for_request(chat_history), # type: ignore
|
||||
messages=openai_chat_completion._prepare_messages_for_openai(chat_history), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ async def test_cmc_additional_properties(
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
|
||||
stream=False,
|
||||
messages=openai_chat_completion._prepare_chat_history_for_request(chat_history), # type: ignore
|
||||
messages=openai_chat_completion._prepare_messages_for_openai(chat_history), # type: ignore
|
||||
reasoning_effort="low",
|
||||
)
|
||||
|
||||
@@ -246,7 +246,7 @@ async def test_get_streaming(
|
||||
model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
messages=openai_chat_completion._prepare_chat_history_for_request(orig_chat_history), # type: ignore
|
||||
messages=openai_chat_completion._prepare_messages_for_openai(orig_chat_history), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@@ -285,7 +285,7 @@ async def test_get_streaming_singular(
|
||||
model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
messages=openai_chat_completion._prepare_chat_history_for_request(orig_chat_history), # type: ignore
|
||||
messages=openai_chat_completion._prepare_messages_for_openai(orig_chat_history), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@@ -349,7 +349,7 @@ async def test_get_streaming_no_fcc_in_response(
|
||||
model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
messages=openai_chat_completion._prepare_chat_history_for_request(orig_chat_history), # type: ignore
|
||||
messages=openai_chat_completion._prepare_messages_for_openai(orig_chat_history), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@@ -399,7 +399,7 @@ def test_chat_response_created_at_uses_utc(openai_unit_test_env: dict[str, str])
|
||||
)
|
||||
|
||||
client = OpenAIChatClient()
|
||||
response = client._create_chat_response(mock_response, ChatOptions())
|
||||
response = client._parse_response_from_openai(mock_response, ChatOptions())
|
||||
|
||||
# Verify that created_at is correctly formatted as UTC
|
||||
assert response.created_at is not None
|
||||
@@ -431,7 +431,7 @@ def test_chat_response_update_created_at_uses_utc(openai_unit_test_env: dict[str
|
||||
)
|
||||
|
||||
client = OpenAIChatClient()
|
||||
response_update = client._create_chat_response_update(mock_chunk)
|
||||
response_update = client._parse_response_update_from_openai(mock_chunk)
|
||||
|
||||
# Verify that created_at is correctly formatted as UTC
|
||||
assert response_update.created_at is not None
|
||||
|
||||
@@ -368,16 +368,43 @@ async def test_response_format_parse_path() -> None:
|
||||
mock_parsed_response.output_parsed = None
|
||||
mock_parsed_response.usage = None
|
||||
mock_parsed_response.finish_reason = None
|
||||
mock_parsed_response.conversation = None # No conversation object
|
||||
|
||||
with patch.object(client.client.responses, "parse", return_value=mock_parsed_response):
|
||||
response = await client.get_response(
|
||||
messages=[ChatMessage(role="user", text="Test message")], response_format=OutputStruct, store=True
|
||||
)
|
||||
|
||||
assert response.response_id == "parsed_response_123"
|
||||
assert response.conversation_id == "parsed_response_123"
|
||||
assert response.model_id == "test-model"
|
||||
|
||||
|
||||
async def test_response_format_parse_path_with_conversation_id() -> None:
|
||||
"""Test get_response response_format parsing path with set conversation ID."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Mock successful parse response
|
||||
mock_parsed_response = MagicMock()
|
||||
mock_parsed_response.id = "parsed_response_123"
|
||||
mock_parsed_response.text = "Parsed response"
|
||||
mock_parsed_response.model = "test-model"
|
||||
mock_parsed_response.created_at = 1000000000
|
||||
mock_parsed_response.metadata = {}
|
||||
mock_parsed_response.output_parsed = None
|
||||
mock_parsed_response.usage = None
|
||||
mock_parsed_response.finish_reason = None
|
||||
mock_parsed_response.conversation = MagicMock()
|
||||
mock_parsed_response.conversation.id = "conversation_456"
|
||||
|
||||
with patch.object(client.client.responses, "parse", return_value=mock_parsed_response):
|
||||
response = await client.get_response(
|
||||
messages=[ChatMessage(role="user", text="Test message")], response_format=OutputStruct, store=True
|
||||
)
|
||||
assert response.response_id == "parsed_response_123"
|
||||
assert response.conversation_id == "conversation_456"
|
||||
assert response.model_id == "test-model"
|
||||
|
||||
|
||||
async def test_bad_request_error_non_content_filter() -> None:
|
||||
"""Test get_response BadRequestError without content_filter."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
@@ -454,7 +481,7 @@ async def test_get_streaming_response_with_all_parameters() -> None:
|
||||
|
||||
|
||||
def test_response_content_creation_with_annotations() -> None:
|
||||
"""Test _create_response_content with different annotation types."""
|
||||
"""Test _parse_response_from_openai with different annotation types."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Create a mock response with annotated text content
|
||||
@@ -485,7 +512,7 @@ def test_response_content_creation_with_annotations() -> None:
|
||||
mock_response.output = [mock_message_item]
|
||||
|
||||
with patch.object(client, "_get_metadata_from_response", return_value={}):
|
||||
response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
response = client._parse_response_from_openai(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
|
||||
assert len(response.messages[0].contents) >= 1
|
||||
assert isinstance(response.messages[0].contents[0], TextContent)
|
||||
@@ -494,7 +521,7 @@ def test_response_content_creation_with_annotations() -> None:
|
||||
|
||||
|
||||
def test_response_content_creation_with_refusal() -> None:
|
||||
"""Test _create_response_content with refusal content."""
|
||||
"""Test _parse_response_from_openai with refusal content."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Create a mock response with refusal content
|
||||
@@ -516,7 +543,7 @@ def test_response_content_creation_with_refusal() -> None:
|
||||
|
||||
mock_response.output = [mock_message_item]
|
||||
|
||||
response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
response = client._parse_response_from_openai(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
|
||||
assert len(response.messages[0].contents) == 1
|
||||
assert isinstance(response.messages[0].contents[0], TextContent)
|
||||
@@ -524,7 +551,7 @@ def test_response_content_creation_with_refusal() -> None:
|
||||
|
||||
|
||||
def test_response_content_creation_with_reasoning() -> None:
|
||||
"""Test _create_response_content with reasoning content."""
|
||||
"""Test _parse_response_from_openai with reasoning content."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Create a mock response with reasoning content
|
||||
@@ -546,7 +573,7 @@ def test_response_content_creation_with_reasoning() -> None:
|
||||
|
||||
mock_response.output = [mock_reasoning_item]
|
||||
|
||||
response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
response = client._parse_response_from_openai(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
|
||||
assert len(response.messages[0].contents) == 2
|
||||
assert isinstance(response.messages[0].contents[0], TextReasoningContent)
|
||||
@@ -554,7 +581,7 @@ def test_response_content_creation_with_reasoning() -> None:
|
||||
|
||||
|
||||
def test_response_content_creation_with_code_interpreter() -> None:
|
||||
"""Test _create_response_content with code interpreter outputs."""
|
||||
"""Test _parse_response_from_openai with code interpreter outputs."""
|
||||
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
@@ -582,7 +609,7 @@ def test_response_content_creation_with_code_interpreter() -> None:
|
||||
|
||||
mock_response.output = [mock_code_interpreter_item]
|
||||
|
||||
response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
response = client._parse_response_from_openai(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
|
||||
assert len(response.messages[0].contents) == 2
|
||||
assert isinstance(response.messages[0].contents[0], TextContent)
|
||||
@@ -593,7 +620,7 @@ def test_response_content_creation_with_code_interpreter() -> None:
|
||||
|
||||
|
||||
def test_response_content_creation_with_function_call() -> None:
|
||||
"""Test _create_response_content with function call content."""
|
||||
"""Test _parse_response_from_openai with function call content."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Create a mock response with function call
|
||||
@@ -614,7 +641,7 @@ def test_response_content_creation_with_function_call() -> None:
|
||||
|
||||
mock_response.output = [mock_function_call_item]
|
||||
|
||||
response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
response = client._parse_response_from_openai(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
|
||||
assert len(response.messages[0].contents) == 1
|
||||
assert isinstance(response.messages[0].contents[0], FunctionCallContent)
|
||||
@@ -624,7 +651,7 @@ def test_response_content_creation_with_function_call() -> None:
|
||||
assert function_call.arguments == '{"location": "Seattle"}'
|
||||
|
||||
|
||||
def test_tools_to_response_tools_with_hosted_mcp() -> None:
|
||||
def test_prepare_tools_for_openai_with_hosted_mcp() -> None:
|
||||
"""Test that HostedMCPTool is converted to the correct response tool dict."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
@@ -638,7 +665,7 @@ def test_tools_to_response_tools_with_hosted_mcp() -> None:
|
||||
additional_properties={"custom": "value"},
|
||||
)
|
||||
|
||||
resp_tools = client._tools_to_response_tools([tool])
|
||||
resp_tools = client._prepare_tools_for_openai([tool])
|
||||
assert isinstance(resp_tools, list)
|
||||
assert len(resp_tools) == 1
|
||||
mcp = resp_tools[0]
|
||||
@@ -654,7 +681,7 @@ def test_tools_to_response_tools_with_hosted_mcp() -> None:
|
||||
assert "require_approval" in mcp
|
||||
|
||||
|
||||
def test_create_response_content_with_mcp_approval_request() -> None:
|
||||
def test_parse_response_from_openai_with_mcp_approval_request() -> None:
|
||||
"""Test that a non-streaming mcp_approval_request is parsed into FunctionApprovalRequestContent."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
@@ -675,7 +702,7 @@ def test_create_response_content_with_mcp_approval_request() -> None:
|
||||
|
||||
mock_response.output = [mock_item]
|
||||
|
||||
response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
response = client._parse_response_from_openai(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
|
||||
assert isinstance(response.messages[0].contents[0], FunctionApprovalRequestContent)
|
||||
req = response.messages[0].contents[0]
|
||||
@@ -716,7 +743,7 @@ def test_responses_client_created_at_uses_utc(openai_unit_test_env: dict[str, st
|
||||
mock_response.output = [mock_message_item]
|
||||
|
||||
with patch.object(client, "_get_metadata_from_response", return_value={}):
|
||||
response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
response = client._parse_response_from_openai(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
|
||||
# Verify that created_at is correctly formatted as UTC
|
||||
assert response.created_at is not None
|
||||
@@ -730,7 +757,7 @@ def test_responses_client_created_at_uses_utc(openai_unit_test_env: dict[str, st
|
||||
)
|
||||
|
||||
|
||||
def test_tools_to_response_tools_with_raw_image_generation() -> None:
|
||||
def test_prepare_tools_for_openai_with_raw_image_generation() -> None:
|
||||
"""Test that raw image_generation tool dict is handled correctly with parameter mapping."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
@@ -744,7 +771,7 @@ def test_tools_to_response_tools_with_raw_image_generation() -> None:
|
||||
"background": "transparent",
|
||||
}
|
||||
|
||||
resp_tools = client._tools_to_response_tools([tool])
|
||||
resp_tools = client._prepare_tools_for_openai([tool])
|
||||
assert isinstance(resp_tools, list)
|
||||
assert len(resp_tools) == 1
|
||||
|
||||
@@ -759,7 +786,7 @@ def test_tools_to_response_tools_with_raw_image_generation() -> None:
|
||||
assert image_tool["output_compression"] == 75
|
||||
|
||||
|
||||
def test_tools_to_response_tools_with_raw_image_generation_openai_responses_params() -> None:
|
||||
def test_prepare_tools_for_openai_with_raw_image_generation_openai_responses_params() -> None:
|
||||
"""Test raw image_generation tool with OpenAI-specific parameters."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
@@ -773,7 +800,7 @@ def test_tools_to_response_tools_with_raw_image_generation_openai_responses_para
|
||||
"partial_images": 2, # Should be integer 0-3
|
||||
}
|
||||
|
||||
resp_tools = client._tools_to_response_tools([tool])
|
||||
resp_tools = client._prepare_tools_for_openai([tool])
|
||||
assert isinstance(resp_tools, list)
|
||||
assert len(resp_tools) == 1
|
||||
|
||||
@@ -791,14 +818,14 @@ def test_tools_to_response_tools_with_raw_image_generation_openai_responses_para
|
||||
assert tool_dict["partial_images"] == 2
|
||||
|
||||
|
||||
def test_tools_to_response_tools_with_raw_image_generation_minimal() -> None:
|
||||
def test_prepare_tools_for_openai_with_raw_image_generation_minimal() -> None:
|
||||
"""Test raw image_generation tool with minimal configuration."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test with minimal parameters (just type)
|
||||
tool = {"type": "image_generation"}
|
||||
|
||||
resp_tools = client._tools_to_response_tools([tool])
|
||||
resp_tools = client._prepare_tools_for_openai([tool])
|
||||
assert isinstance(resp_tools, list)
|
||||
assert len(resp_tools) == 1
|
||||
|
||||
@@ -809,7 +836,7 @@ def test_tools_to_response_tools_with_raw_image_generation_minimal() -> None:
|
||||
assert len(image_tool) == 1
|
||||
|
||||
|
||||
def test_create_streaming_response_content_with_mcp_approval_request() -> None:
|
||||
def test_parse_chunk_from_openai_with_mcp_approval_request() -> None:
|
||||
"""Test that a streaming mcp_approval_request event is parsed into FunctionApprovalRequestContent."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
@@ -825,7 +852,7 @@ def test_create_streaming_response_content_with_mcp_approval_request() -> None:
|
||||
mock_item.server_label = "My_MCP"
|
||||
mock_event.item = mock_item
|
||||
|
||||
update = client._create_streaming_response_content(mock_event, chat_options, function_call_ids)
|
||||
update = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids)
|
||||
assert any(isinstance(c, FunctionApprovalRequestContent) for c in update.contents)
|
||||
fa = next(c for c in update.contents if isinstance(c, FunctionApprovalRequestContent))
|
||||
assert fa.id == "approval-stream-1"
|
||||
@@ -901,7 +928,7 @@ async def test_end_to_end_mcp_approval_flow(span_exporter) -> None:
|
||||
|
||||
|
||||
def test_usage_details_basic() -> None:
|
||||
"""Test _usage_details_from_openai without cached or reasoning tokens."""
|
||||
"""Test _parse_usage_from_openai without cached or reasoning tokens."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
mock_usage = MagicMock()
|
||||
@@ -911,7 +938,7 @@ def test_usage_details_basic() -> None:
|
||||
mock_usage.input_tokens_details = None
|
||||
mock_usage.output_tokens_details = None
|
||||
|
||||
details = client._usage_details_from_openai(mock_usage) # type: ignore
|
||||
details = client._parse_usage_from_openai(mock_usage) # type: ignore
|
||||
assert details is not None
|
||||
assert details.input_token_count == 100
|
||||
assert details.output_token_count == 50
|
||||
@@ -919,7 +946,7 @@ def test_usage_details_basic() -> None:
|
||||
|
||||
|
||||
def test_usage_details_with_cached_tokens() -> None:
|
||||
"""Test _usage_details_from_openai with cached input tokens."""
|
||||
"""Test _parse_usage_from_openai with cached input tokens."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
mock_usage = MagicMock()
|
||||
@@ -930,14 +957,14 @@ def test_usage_details_with_cached_tokens() -> None:
|
||||
mock_usage.input_tokens_details.cached_tokens = 25
|
||||
mock_usage.output_tokens_details = None
|
||||
|
||||
details = client._usage_details_from_openai(mock_usage) # type: ignore
|
||||
details = client._parse_usage_from_openai(mock_usage) # type: ignore
|
||||
assert details is not None
|
||||
assert details.input_token_count == 200
|
||||
assert details.additional_counts["openai.cached_input_tokens"] == 25
|
||||
|
||||
|
||||
def test_usage_details_with_reasoning_tokens() -> None:
|
||||
"""Test _usage_details_from_openai with reasoning tokens."""
|
||||
"""Test _parse_usage_from_openai with reasoning tokens."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
mock_usage = MagicMock()
|
||||
@@ -948,7 +975,7 @@ def test_usage_details_with_reasoning_tokens() -> None:
|
||||
mock_usage.output_tokens_details = MagicMock()
|
||||
mock_usage.output_tokens_details.reasoning_tokens = 30
|
||||
|
||||
details = client._usage_details_from_openai(mock_usage) # type: ignore
|
||||
details = client._parse_usage_from_openai(mock_usage) # type: ignore
|
||||
assert details is not None
|
||||
assert details.output_token_count == 80
|
||||
assert details.additional_counts["openai.reasoning_tokens"] == 30
|
||||
@@ -975,7 +1002,7 @@ def test_get_metadata_from_response() -> None:
|
||||
|
||||
|
||||
def test_streaming_response_basic_structure() -> None:
|
||||
"""Test that _create_streaming_response_content returns proper structure."""
|
||||
"""Test that _parse_chunk_from_openai returns proper structure."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions(store=True)
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
@@ -983,7 +1010,7 @@ def test_streaming_response_basic_structure() -> None:
|
||||
# Test with a basic mock event to ensure the method returns proper structure
|
||||
mock_event = MagicMock()
|
||||
|
||||
response = client._create_streaming_response_content(mock_event, chat_options, function_call_ids) # type: ignore
|
||||
response = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids) # type: ignore
|
||||
|
||||
# Should get a valid ChatResponseUpdate structure
|
||||
assert isinstance(response, ChatResponseUpdate)
|
||||
@@ -993,6 +1020,44 @@ def test_streaming_response_basic_structure() -> None:
|
||||
assert response.raw_representation is mock_event
|
||||
|
||||
|
||||
def test_streaming_response_created_type() -> None:
|
||||
"""Test streaming response with created type"""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.created"
|
||||
mock_event.response = MagicMock()
|
||||
mock_event.response.id = "resp_1234"
|
||||
mock_event.response.conversation = MagicMock()
|
||||
mock_event.response.conversation.id = "conv_5678"
|
||||
|
||||
response = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids)
|
||||
|
||||
assert response.response_id == "resp_1234"
|
||||
assert response.conversation_id == "conv_5678"
|
||||
|
||||
|
||||
def test_streaming_response_in_progress_type() -> None:
|
||||
"""Test streaming response with in_progress type"""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.in_progress"
|
||||
mock_event.response = MagicMock()
|
||||
mock_event.response.id = "resp_1234"
|
||||
mock_event.response.conversation = MagicMock()
|
||||
mock_event.response.conversation.id = "conv_5678"
|
||||
|
||||
response = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids)
|
||||
|
||||
assert response.response_id == "resp_1234"
|
||||
assert response.conversation_id == "conv_5678"
|
||||
|
||||
|
||||
def test_streaming_annotation_added_with_file_path() -> None:
|
||||
"""Test streaming annotation added event with file_path type extracts HostedFileContent."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
@@ -1008,7 +1073,7 @@ def test_streaming_annotation_added_with_file_path() -> None:
|
||||
"index": 42,
|
||||
}
|
||||
|
||||
response = client._create_streaming_response_content(mock_event, chat_options, function_call_ids)
|
||||
response = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids)
|
||||
|
||||
assert len(response.contents) == 1
|
||||
content = response.contents[0]
|
||||
@@ -1035,7 +1100,7 @@ def test_streaming_annotation_added_with_file_citation() -> None:
|
||||
"index": 15,
|
||||
}
|
||||
|
||||
response = client._create_streaming_response_content(mock_event, chat_options, function_call_ids)
|
||||
response = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids)
|
||||
|
||||
assert len(response.contents) == 1
|
||||
content = response.contents[0]
|
||||
@@ -1064,7 +1129,7 @@ def test_streaming_annotation_added_with_container_file_citation() -> None:
|
||||
"end_index": 50,
|
||||
}
|
||||
|
||||
response = client._create_streaming_response_content(mock_event, chat_options, function_call_ids)
|
||||
response = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids)
|
||||
|
||||
assert len(response.contents) == 1
|
||||
content = response.contents[0]
|
||||
@@ -1091,7 +1156,7 @@ def test_streaming_annotation_added_with_unknown_type() -> None:
|
||||
"url": "https://example.com",
|
||||
}
|
||||
|
||||
response = client._create_streaming_response_content(mock_event, chat_options, function_call_ids)
|
||||
response = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids)
|
||||
|
||||
# url_citation should not produce HostedFileContent
|
||||
assert len(response.contents) == 0
|
||||
@@ -1137,8 +1202,8 @@ def test_get_streaming_response_with_response_format() -> None:
|
||||
asyncio.run(run_streaming())
|
||||
|
||||
|
||||
def test_openai_content_parser_image_content() -> None:
|
||||
"""Test _openai_content_parser with image content variations."""
|
||||
def test_prepare_content_for_openai_image_content() -> None:
|
||||
"""Test _prepare_content_for_openai with image content variations."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test image content with detail parameter and file_id
|
||||
@@ -1147,7 +1212,7 @@ def test_openai_content_parser_image_content() -> None:
|
||||
media_type="image/jpeg",
|
||||
additional_properties={"detail": "high", "file_id": "file_123"},
|
||||
)
|
||||
result = client._openai_content_parser(Role.USER, image_content_with_detail, {}) # type: ignore
|
||||
result = client._prepare_content_for_openai(Role.USER, image_content_with_detail, {}) # type: ignore
|
||||
assert result["type"] == "input_image"
|
||||
assert result["image_url"] == "https://example.com/image.jpg"
|
||||
assert result["detail"] == "high"
|
||||
@@ -1155,47 +1220,47 @@ def test_openai_content_parser_image_content() -> None:
|
||||
|
||||
# Test image content without additional properties (defaults)
|
||||
image_content_basic = UriContent(uri="https://example.com/basic.png", media_type="image/png")
|
||||
result = client._openai_content_parser(Role.USER, image_content_basic, {}) # type: ignore
|
||||
result = client._prepare_content_for_openai(Role.USER, image_content_basic, {}) # type: ignore
|
||||
assert result["type"] == "input_image"
|
||||
assert result["detail"] == "auto"
|
||||
assert result["file_id"] is None
|
||||
|
||||
|
||||
def test_openai_content_parser_audio_content() -> None:
|
||||
"""Test _openai_content_parser with audio content variations."""
|
||||
def test_prepare_content_for_openai_audio_content() -> None:
|
||||
"""Test _prepare_content_for_openai with audio content variations."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test WAV audio content
|
||||
wav_content = UriContent(uri="data:audio/wav;base64,abc123", media_type="audio/wav")
|
||||
result = client._openai_content_parser(Role.USER, wav_content, {}) # type: ignore
|
||||
result = client._prepare_content_for_openai(Role.USER, wav_content, {}) # type: ignore
|
||||
assert result["type"] == "input_audio"
|
||||
assert result["input_audio"]["data"] == "data:audio/wav;base64,abc123"
|
||||
assert result["input_audio"]["format"] == "wav"
|
||||
|
||||
# Test MP3 audio content
|
||||
mp3_content = UriContent(uri="data:audio/mp3;base64,def456", media_type="audio/mp3")
|
||||
result = client._openai_content_parser(Role.USER, mp3_content, {}) # type: ignore
|
||||
result = client._prepare_content_for_openai(Role.USER, mp3_content, {}) # type: ignore
|
||||
assert result["type"] == "input_audio"
|
||||
assert result["input_audio"]["format"] == "mp3"
|
||||
|
||||
|
||||
def test_openai_content_parser_unsupported_content() -> None:
|
||||
"""Test _openai_content_parser with unsupported content types."""
|
||||
def test_prepare_content_for_openai_unsupported_content() -> None:
|
||||
"""Test _prepare_content_for_openai with unsupported content types."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test unsupported audio format
|
||||
unsupported_audio = UriContent(uri="data:audio/ogg;base64,ghi789", media_type="audio/ogg")
|
||||
result = client._openai_content_parser(Role.USER, unsupported_audio, {}) # type: ignore
|
||||
result = client._prepare_content_for_openai(Role.USER, unsupported_audio, {}) # type: ignore
|
||||
assert result == {}
|
||||
|
||||
# Test non-media content
|
||||
text_uri_content = UriContent(uri="https://example.com/document.txt", media_type="text/plain")
|
||||
result = client._openai_content_parser(Role.USER, text_uri_content, {}) # type: ignore
|
||||
result = client._prepare_content_for_openai(Role.USER, text_uri_content, {}) # type: ignore
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_create_streaming_response_content_code_interpreter() -> None:
|
||||
"""Test _create_streaming_response_content with code_interpreter_call."""
|
||||
def test_parse_chunk_from_openai_code_interpreter() -> None:
|
||||
"""Test _parse_chunk_from_openai with code_interpreter_call."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
@@ -1211,15 +1276,15 @@ def test_create_streaming_response_content_code_interpreter() -> None:
|
||||
mock_item_image.code = None
|
||||
mock_event_image.item = mock_item_image
|
||||
|
||||
result = client._create_streaming_response_content(mock_event_image, chat_options, function_call_ids) # type: ignore
|
||||
result = client._parse_chunk_from_openai(mock_event_image, chat_options, function_call_ids) # type: ignore
|
||||
assert len(result.contents) == 1
|
||||
assert isinstance(result.contents[0], UriContent)
|
||||
assert result.contents[0].uri == "https://example.com/plot.png"
|
||||
assert result.contents[0].media_type == "image"
|
||||
|
||||
|
||||
def test_create_streaming_response_content_reasoning() -> None:
|
||||
"""Test _create_streaming_response_content with reasoning content."""
|
||||
def test_parse_chunk_from_openai_reasoning() -> None:
|
||||
"""Test _parse_chunk_from_openai with reasoning content."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
@@ -1234,7 +1299,7 @@ def test_create_streaming_response_content_reasoning() -> None:
|
||||
mock_item_reasoning.summary = ["Problem analysis summary"]
|
||||
mock_event_reasoning.item = mock_item_reasoning
|
||||
|
||||
result = client._create_streaming_response_content(mock_event_reasoning, chat_options, function_call_ids) # type: ignore
|
||||
result = client._parse_chunk_from_openai(mock_event_reasoning, chat_options, function_call_ids) # type: ignore
|
||||
assert len(result.contents) == 1
|
||||
assert isinstance(result.contents[0], TextReasoningContent)
|
||||
assert result.contents[0].text == "Analyzing the problem step by step..."
|
||||
@@ -1242,8 +1307,8 @@ def test_create_streaming_response_content_reasoning() -> None:
|
||||
assert result.contents[0].additional_properties["summary"] == "Problem analysis summary"
|
||||
|
||||
|
||||
def test_openai_content_parser_text_reasoning_comprehensive() -> None:
|
||||
"""Test _openai_content_parser with TextReasoningContent all additional properties."""
|
||||
def test_prepare_content_for_openai_text_reasoning_comprehensive() -> None:
|
||||
"""Test _prepare_content_for_openai with TextReasoningContent all additional properties."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test TextReasoningContent with all additional properties
|
||||
@@ -1255,7 +1320,7 @@ def test_openai_content_parser_text_reasoning_comprehensive() -> None:
|
||||
"encrypted_content": "secure_data_456",
|
||||
},
|
||||
)
|
||||
result = client._openai_content_parser(Role.ASSISTANT, comprehensive_reasoning, {}) # type: ignore
|
||||
result = client._prepare_content_for_openai(Role.ASSISTANT, comprehensive_reasoning, {}) # type: ignore
|
||||
assert result["type"] == "reasoning"
|
||||
assert result["summary"]["text"] == "Comprehensive reasoning summary"
|
||||
assert result["status"] == "in_progress"
|
||||
@@ -1280,7 +1345,7 @@ def test_streaming_reasoning_text_delta_event() -> None:
|
||||
)
|
||||
|
||||
with patch.object(client, "_get_metadata_from_response", return_value={}) as mock_metadata:
|
||||
response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore
|
||||
response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore
|
||||
|
||||
assert len(response.contents) == 1
|
||||
assert isinstance(response.contents[0], TextReasoningContent)
|
||||
@@ -1305,7 +1370,7 @@ def test_streaming_reasoning_text_done_event() -> None:
|
||||
)
|
||||
|
||||
with patch.object(client, "_get_metadata_from_response", return_value={"test": "data"}) as mock_metadata:
|
||||
response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore
|
||||
response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore
|
||||
|
||||
assert len(response.contents) == 1
|
||||
assert isinstance(response.contents[0], TextReasoningContent)
|
||||
@@ -1331,7 +1396,7 @@ def test_streaming_reasoning_summary_text_delta_event() -> None:
|
||||
)
|
||||
|
||||
with patch.object(client, "_get_metadata_from_response", return_value={}) as mock_metadata:
|
||||
response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore
|
||||
response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore
|
||||
|
||||
assert len(response.contents) == 1
|
||||
assert isinstance(response.contents[0], TextReasoningContent)
|
||||
@@ -1356,7 +1421,7 @@ def test_streaming_reasoning_summary_text_done_event() -> None:
|
||||
)
|
||||
|
||||
with patch.object(client, "_get_metadata_from_response", return_value={"custom": "meta"}) as mock_metadata:
|
||||
response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore
|
||||
response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore
|
||||
|
||||
assert len(response.contents) == 1
|
||||
assert isinstance(response.contents[0], TextReasoningContent)
|
||||
@@ -1392,8 +1457,8 @@ def test_streaming_reasoning_events_preserve_metadata() -> None:
|
||||
)
|
||||
|
||||
with patch.object(client, "_get_metadata_from_response", return_value={"test": "metadata"}):
|
||||
text_response = client._create_streaming_response_content(text_event, chat_options, function_call_ids) # type: ignore
|
||||
reasoning_response = client._create_streaming_response_content(reasoning_event, chat_options, function_call_ids) # type: ignore
|
||||
text_response = client._parse_chunk_from_openai(text_event, chat_options, function_call_ids) # type: ignore
|
||||
reasoning_response = client._parse_chunk_from_openai(reasoning_event, chat_options, function_call_ids) # type: ignore
|
||||
|
||||
# Both should preserve metadata
|
||||
assert text_response.additional_properties == {"test": "metadata"}
|
||||
@@ -1404,7 +1469,7 @@ def test_streaming_reasoning_events_preserve_metadata() -> None:
|
||||
assert isinstance(reasoning_response.contents[0], TextReasoningContent)
|
||||
|
||||
|
||||
def test_create_response_content_image_generation_raw_base64():
|
||||
def test_parse_response_from_openai_image_generation_raw_base64():
|
||||
"""Test image generation response parsing with raw base64 string."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
@@ -1428,7 +1493,7 @@ def test_create_response_content_image_generation_raw_base64():
|
||||
mock_response.output = [mock_item]
|
||||
|
||||
with patch.object(client, "_get_metadata_from_response", return_value={}):
|
||||
response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
response = client._parse_response_from_openai(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
|
||||
# Verify the response contains DataContent with proper URI and media_type
|
||||
assert len(response.messages[0].contents) == 1
|
||||
@@ -1438,7 +1503,7 @@ def test_create_response_content_image_generation_raw_base64():
|
||||
assert content.media_type == "image/png"
|
||||
|
||||
|
||||
def test_create_response_content_image_generation_existing_data_uri():
|
||||
def test_parse_response_from_openai_image_generation_existing_data_uri():
|
||||
"""Test image generation response parsing with existing data URI."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
@@ -1461,7 +1526,7 @@ def test_create_response_content_image_generation_existing_data_uri():
|
||||
mock_response.output = [mock_item]
|
||||
|
||||
with patch.object(client, "_get_metadata_from_response", return_value={}):
|
||||
response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
response = client._parse_response_from_openai(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
|
||||
# Verify the response contains DataContent with proper media_type parsed from URI
|
||||
assert len(response.messages[0].contents) == 1
|
||||
@@ -1471,7 +1536,7 @@ def test_create_response_content_image_generation_existing_data_uri():
|
||||
assert content.media_type == "image/webp"
|
||||
|
||||
|
||||
def test_create_response_content_image_generation_format_detection():
|
||||
def test_parse_response_from_openai_image_generation_format_detection():
|
||||
"""Test different image format detection from base64 data."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
@@ -1493,7 +1558,7 @@ def test_create_response_content_image_generation_format_detection():
|
||||
mock_response_jpeg.output = [mock_item_jpeg]
|
||||
|
||||
with patch.object(client, "_get_metadata_from_response", return_value={}):
|
||||
response_jpeg = client._create_response_content(mock_response_jpeg, chat_options=ChatOptions()) # type: ignore
|
||||
response_jpeg = client._parse_response_from_openai(mock_response_jpeg, chat_options=ChatOptions()) # type: ignore
|
||||
content_jpeg = response_jpeg.messages[0].contents[0]
|
||||
assert isinstance(content_jpeg, DataContent)
|
||||
assert content_jpeg.media_type == "image/jpeg"
|
||||
@@ -1517,14 +1582,14 @@ def test_create_response_content_image_generation_format_detection():
|
||||
mock_response_webp.output = [mock_item_webp]
|
||||
|
||||
with patch.object(client, "_get_metadata_from_response", return_value={}):
|
||||
response_webp = client._create_response_content(mock_response_webp, chat_options=ChatOptions()) # type: ignore
|
||||
response_webp = client._parse_response_from_openai(mock_response_webp, chat_options=ChatOptions()) # type: ignore
|
||||
content_webp = response_webp.messages[0].contents[0]
|
||||
assert isinstance(content_webp, DataContent)
|
||||
assert content_webp.media_type == "image/webp"
|
||||
assert "data:image/webp;base64," in content_webp.uri
|
||||
|
||||
|
||||
def test_create_response_content_image_generation_fallback():
|
||||
def test_parse_response_from_openai_image_generation_fallback():
|
||||
"""Test image generation with invalid base64 falls back to PNG."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
@@ -1547,7 +1612,7 @@ def test_create_response_content_image_generation_fallback():
|
||||
mock_response.output = [mock_item]
|
||||
|
||||
with patch.object(client, "_get_metadata_from_response", return_value={}):
|
||||
response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
response = client._parse_response_from_openai(mock_response, chat_options=ChatOptions()) # type: ignore
|
||||
|
||||
# Verify it falls back to PNG format for unrecognized binary data
|
||||
assert len(response.messages[0].contents) == 1
|
||||
@@ -1563,21 +1628,21 @@ async def test_prepare_options_store_parameter_handling() -> None:
|
||||
|
||||
test_conversation_id = "test-conversation-123"
|
||||
chat_options = ChatOptions(store=True, conversation_id=test_conversation_id)
|
||||
options = await client.prepare_options(messages, chat_options)
|
||||
options = await client._prepare_options(messages, chat_options) # type: ignore
|
||||
assert options["store"] is True
|
||||
assert options["previous_response_id"] == test_conversation_id
|
||||
|
||||
chat_options = ChatOptions(store=False, conversation_id="")
|
||||
options = await client.prepare_options(messages, chat_options)
|
||||
options = await client._prepare_options(messages, chat_options) # type: ignore
|
||||
assert options["store"] is False
|
||||
|
||||
chat_options = ChatOptions(store=None, conversation_id=None)
|
||||
options = await client.prepare_options(messages, chat_options)
|
||||
options = await client._prepare_options(messages, chat_options) # type: ignore
|
||||
assert "store" not in options
|
||||
assert "previous_response_id" not in options
|
||||
|
||||
chat_options = ChatOptions()
|
||||
options = await client.prepare_options(messages, chat_options)
|
||||
options = await client._prepare_options(messages, chat_options) # type: ignore
|
||||
assert "store" not in options
|
||||
assert "previous_response_id" not in options
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import (
|
||||
AgentProtocol,
|
||||
AgentRunResponse,
|
||||
AgentRunResponseUpdate,
|
||||
AgentRunUpdateEvent,
|
||||
@@ -422,6 +424,48 @@ class TestWorkflowAgent:
|
||||
assert isinstance(updates[2].raw_representation, CustomData)
|
||||
assert updates[2].raw_representation.value == 42
|
||||
|
||||
async def test_workflow_as_agent_yield_output_with_list_of_chat_messages(self) -> None:
|
||||
"""Test that yield_output with list[ChatMessage] extracts contents from all messages.
|
||||
|
||||
Note: TextContent items are coalesced by _finalize_response, so multiple text contents
|
||||
become a single merged TextContent in the final response.
|
||||
"""
|
||||
|
||||
@executor
|
||||
async def list_yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
|
||||
# Yield a list of ChatMessages (as SequentialBuilder does)
|
||||
msg_list = [
|
||||
ChatMessage(role=Role.USER, contents=[TextContent(text="first message")]),
|
||||
ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text="second message")]),
|
||||
ChatMessage(
|
||||
role=Role.ASSISTANT,
|
||||
contents=[TextContent(text="third"), TextContent(text="fourth")],
|
||||
),
|
||||
]
|
||||
await ctx.yield_output(msg_list)
|
||||
|
||||
workflow = WorkflowBuilder().set_start_executor(list_yielding_executor).build()
|
||||
agent = workflow.as_agent("list-msg-agent")
|
||||
|
||||
# Verify streaming returns the update with all 4 contents before coalescing
|
||||
updates: list[AgentRunResponseUpdate] = []
|
||||
async for update in agent.run_stream("test"):
|
||||
updates.append(update)
|
||||
|
||||
assert len(updates) == 1
|
||||
assert len(updates[0].contents) == 4
|
||||
texts = [c.text for c in updates[0].contents if isinstance(c, TextContent)]
|
||||
assert texts == ["first message", "second message", "third", "fourth"]
|
||||
|
||||
# Verify run() coalesces text contents (expected behavior)
|
||||
result = await agent.run("test")
|
||||
|
||||
assert isinstance(result, AgentRunResponse)
|
||||
assert len(result.messages) == 1
|
||||
# TextContent items are coalesced into one
|
||||
assert len(result.messages[0].contents) == 1
|
||||
assert result.messages[0].text == "first messagesecond messagethirdfourth"
|
||||
|
||||
async def test_thread_conversation_history_included_in_workflow_run(self) -> None:
|
||||
"""Test that conversation history from thread is included when running WorkflowAgent.
|
||||
|
||||
@@ -521,6 +565,142 @@ class TestWorkflowAgent:
|
||||
checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
|
||||
assert len(checkpoints) > 0, "Checkpoints should have been created when checkpoint_storage is provided"
|
||||
|
||||
async def test_agent_executor_output_response_false_filters_streaming_events(self):
|
||||
"""Test that AgentExecutor with output_response=False does not surface streaming events."""
|
||||
|
||||
class MockAgent(AgentProtocol):
|
||||
"""Mock agent for testing."""
|
||||
|
||||
def __init__(self, name: str, response_text: str) -> None:
|
||||
self._name = name
|
||||
self._response_text = response_text
|
||||
self._description: str | None = None
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def description(self) -> str | None:
|
||||
return self._description
|
||||
|
||||
def get_new_thread(self) -> AgentThread:
|
||||
return AgentThread()
|
||||
|
||||
async def run(self, messages: Any, *, thread: AgentThread | None = None, **kwargs: Any) -> AgentRunResponse:
|
||||
return AgentRunResponse(
|
||||
messages=[ChatMessage(role=Role.ASSISTANT, text=self._response_text)],
|
||||
text=self._response_text,
|
||||
)
|
||||
|
||||
async def run_stream(
|
||||
self, messages: Any, *, thread: AgentThread | None = None, **kwargs: Any
|
||||
) -> AsyncIterable[AgentRunResponseUpdate]:
|
||||
for word in self._response_text.split():
|
||||
yield AgentRunResponseUpdate(
|
||||
contents=[TextContent(text=word + " ")],
|
||||
role=Role.ASSISTANT,
|
||||
author_name=self._name,
|
||||
)
|
||||
|
||||
@executor
|
||||
async def start_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
|
||||
from agent_framework import AgentExecutorRequest
|
||||
|
||||
await ctx.yield_output("Start output")
|
||||
await ctx.send_message(AgentExecutorRequest(messages=messages, should_respond=True))
|
||||
|
||||
# Build workflow: start -> agent1 (no output) -> agent2 (output_response=True)
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.register_executor(lambda: start_executor, "start")
|
||||
.register_agent(lambda: MockAgent("agent1", "Agent1 output - should NOT appear"), "agent1")
|
||||
.register_agent(
|
||||
lambda: MockAgent("agent2", "Agent2 output - SHOULD appear"), "agent2", output_response=True
|
||||
)
|
||||
.set_start_executor("start")
|
||||
.add_edge("start", "agent1")
|
||||
.add_edge("agent1", "agent2")
|
||||
.build()
|
||||
)
|
||||
|
||||
agent = WorkflowAgent(workflow=workflow, name="Test Agent")
|
||||
result = await agent.run("Test input")
|
||||
|
||||
# Collect all message texts
|
||||
texts = [msg.text for msg in result.messages if msg.text]
|
||||
|
||||
# Start output should appear (from yield_output)
|
||||
assert any("Start output" in t for t in texts), "Start output should appear"
|
||||
|
||||
# Agent1 output should NOT appear (output_response=False)
|
||||
assert not any("Agent1" in t for t in texts), "Agent1 output should NOT appear"
|
||||
|
||||
# Agent2 output should appear (output_response=True)
|
||||
assert any("Agent2" in t for t in texts), "Agent2 output should appear"
|
||||
|
||||
async def test_agent_executor_output_response_no_duplicate_from_workflow_output_event(self):
|
||||
"""Test that AgentExecutor with output_response=True does not duplicate content."""
|
||||
|
||||
class MockAgent(AgentProtocol):
|
||||
"""Mock agent for testing."""
|
||||
|
||||
def __init__(self, name: str, response_text: str) -> None:
|
||||
self._name = name
|
||||
self._response_text = response_text
|
||||
self._description: str | None = None
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def description(self) -> str | None:
|
||||
return self._description
|
||||
|
||||
def get_new_thread(self) -> AgentThread:
|
||||
return AgentThread()
|
||||
|
||||
async def run(self, messages: Any, *, thread: AgentThread | None = None, **kwargs: Any) -> AgentRunResponse:
|
||||
return AgentRunResponse(
|
||||
messages=[ChatMessage(role=Role.ASSISTANT, text=self._response_text)],
|
||||
text=self._response_text,
|
||||
)
|
||||
|
||||
async def run_stream(
|
||||
self, messages: Any, *, thread: AgentThread | None = None, **kwargs: Any
|
||||
) -> AsyncIterable[AgentRunResponseUpdate]:
|
||||
yield AgentRunResponseUpdate(
|
||||
contents=[TextContent(text=self._response_text)],
|
||||
role=Role.ASSISTANT,
|
||||
author_name=self._name,
|
||||
)
|
||||
|
||||
@executor
|
||||
async def start_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
|
||||
from agent_framework import AgentExecutorRequest
|
||||
|
||||
await ctx.send_message(AgentExecutorRequest(messages=messages, should_respond=True))
|
||||
|
||||
# Build workflow with single agent that has output_response=True
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.register_executor(lambda: start_executor, "start")
|
||||
.register_agent(lambda: MockAgent("agent", "Unique response text"), "agent", output_response=True)
|
||||
.set_start_executor("start")
|
||||
.add_edge("start", "agent")
|
||||
.build()
|
||||
)
|
||||
|
||||
agent = WorkflowAgent(workflow=workflow, name="Test Agent")
|
||||
result = await agent.run("Test input")
|
||||
|
||||
# Count occurrences of the unique response text
|
||||
unique_text_count = sum(1 for msg in result.messages if msg.text and "Unique response text" in msg.text)
|
||||
|
||||
# Should appear exactly once (not duplicated from both streaming and WorkflowOutputEvent)
|
||||
assert unique_text_count == 1, f"Response should appear exactly once, but appeared {unique_text_count} times"
|
||||
|
||||
|
||||
class TestWorkflowAgentMergeUpdates:
|
||||
"""Test cases specifically for the WorkflowAgent.merge_updates static method."""
|
||||
|
||||
@@ -245,7 +245,8 @@ def test_register_multiple_executors():
|
||||
|
||||
# Build workflow with edges using registered names
|
||||
workflow = (
|
||||
builder.set_start_executor("ExecutorA")
|
||||
builder
|
||||
.set_start_executor("ExecutorA")
|
||||
.add_edge("ExecutorA", "ExecutorB")
|
||||
.add_edge("ExecutorB", "ExecutorC")
|
||||
.build()
|
||||
@@ -426,7 +427,8 @@ def test_register_with_fan_in_edges():
|
||||
# Add fan-in edges using registered names
|
||||
# Both Source1 and Source2 need to be reachable, so connect Source1 to Source2
|
||||
workflow = (
|
||||
builder.set_start_executor("Source1")
|
||||
builder
|
||||
.set_start_executor("Source1")
|
||||
.add_edge("Source1", "Source2")
|
||||
.add_fan_in_edges(["Source1", "Source2"], "Aggregator")
|
||||
.build()
|
||||
|
||||
@@ -490,3 +490,266 @@ async def test_magentic_kwargs_stored_in_shared_state() -> None:
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region WorkflowAgent (as_agent) kwargs Tests
|
||||
|
||||
|
||||
async def test_workflow_as_agent_run_propagates_kwargs_to_underlying_agent() -> None:
|
||||
"""Test that kwargs passed to workflow_agent.run() flow through to the underlying agents."""
|
||||
agent = _KwargsCapturingAgent(name="inner_agent")
|
||||
workflow = SequentialBuilder().participants([agent]).build()
|
||||
workflow_agent = workflow.as_agent(name="TestWorkflowAgent")
|
||||
|
||||
custom_data = {"endpoint": "https://api.example.com", "version": "v1"}
|
||||
user_token = {"user_name": "alice", "access_level": "admin"}
|
||||
|
||||
_ = await workflow_agent.run(
|
||||
"test message",
|
||||
custom_data=custom_data,
|
||||
user_token=user_token,
|
||||
)
|
||||
|
||||
# Verify inner agent received kwargs
|
||||
assert len(agent.captured_kwargs) >= 1, "Inner agent should have been invoked at least once"
|
||||
received = agent.captured_kwargs[0]
|
||||
assert "custom_data" in received, "Inner agent should receive custom_data kwarg"
|
||||
assert "user_token" in received, "Inner agent should receive user_token kwarg"
|
||||
assert received["custom_data"] == custom_data
|
||||
assert received["user_token"] == user_token
|
||||
|
||||
|
||||
async def test_workflow_as_agent_run_stream_propagates_kwargs_to_underlying_agent() -> None:
|
||||
"""Test that kwargs passed to workflow_agent.run_stream() flow through to the underlying agents."""
|
||||
agent = _KwargsCapturingAgent(name="inner_agent")
|
||||
workflow = SequentialBuilder().participants([agent]).build()
|
||||
workflow_agent = workflow.as_agent(name="TestWorkflowAgent")
|
||||
|
||||
custom_data = {"session_id": "xyz123"}
|
||||
api_token = "secret-token"
|
||||
|
||||
async for _ in workflow_agent.run_stream(
|
||||
"test message",
|
||||
custom_data=custom_data,
|
||||
api_token=api_token,
|
||||
):
|
||||
pass
|
||||
|
||||
# Verify inner agent received kwargs
|
||||
assert len(agent.captured_kwargs) >= 1, "Inner agent should have been invoked at least once"
|
||||
received = agent.captured_kwargs[0]
|
||||
assert "custom_data" in received, "Inner agent should receive custom_data kwarg"
|
||||
assert "api_token" in received, "Inner agent should receive api_token kwarg"
|
||||
assert received["custom_data"] == custom_data
|
||||
assert received["api_token"] == api_token
|
||||
|
||||
|
||||
async def test_workflow_as_agent_propagates_kwargs_to_multiple_agents() -> None:
|
||||
"""Test that kwargs flow to all agents when using workflow.as_agent()."""
|
||||
agent1 = _KwargsCapturingAgent(name="agent1")
|
||||
agent2 = _KwargsCapturingAgent(name="agent2")
|
||||
workflow = SequentialBuilder().participants([agent1, agent2]).build()
|
||||
workflow_agent = workflow.as_agent(name="MultiAgentWorkflow")
|
||||
|
||||
custom_data = {"batch_id": "batch-001"}
|
||||
|
||||
_ = await workflow_agent.run("test message", custom_data=custom_data)
|
||||
|
||||
# Both agents should have received kwargs
|
||||
assert len(agent1.captured_kwargs) >= 1, "First agent should be invoked"
|
||||
assert len(agent2.captured_kwargs) >= 1, "Second agent should be invoked"
|
||||
assert agent1.captured_kwargs[0].get("custom_data") == custom_data
|
||||
assert agent2.captured_kwargs[0].get("custom_data") == custom_data
|
||||
|
||||
|
||||
async def test_workflow_as_agent_kwargs_with_none_values() -> None:
|
||||
"""Test that kwargs with None values are passed through correctly via as_agent()."""
|
||||
agent = _KwargsCapturingAgent(name="none_test_agent")
|
||||
workflow = SequentialBuilder().participants([agent]).build()
|
||||
workflow_agent = workflow.as_agent(name="NoneTestWorkflow")
|
||||
|
||||
_ = await workflow_agent.run("test", optional_param=None, other_param="value")
|
||||
|
||||
assert len(agent.captured_kwargs) >= 1
|
||||
received = agent.captured_kwargs[0]
|
||||
assert "optional_param" in received
|
||||
assert received["optional_param"] is None
|
||||
assert received["other_param"] == "value"
|
||||
|
||||
|
||||
async def test_workflow_as_agent_kwargs_with_complex_nested_data() -> None:
|
||||
"""Test that complex nested data structures flow through correctly via as_agent()."""
|
||||
agent = _KwargsCapturingAgent(name="nested_agent")
|
||||
workflow = SequentialBuilder().participants([agent]).build()
|
||||
workflow_agent = workflow.as_agent(name="NestedDataWorkflow")
|
||||
|
||||
complex_data = {
|
||||
"level1": {
|
||||
"level2": {
|
||||
"level3": ["a", "b", "c"],
|
||||
"number": 42,
|
||||
},
|
||||
"list": [1, 2, {"nested": True}],
|
||||
},
|
||||
}
|
||||
|
||||
_ = await workflow_agent.run("test", complex_data=complex_data)
|
||||
|
||||
assert len(agent.captured_kwargs) >= 1
|
||||
received = agent.captured_kwargs[0]
|
||||
assert received.get("complex_data") == complex_data
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region SubWorkflow (WorkflowExecutor) Tests
|
||||
|
||||
|
||||
async def test_subworkflow_kwargs_propagation() -> None:
|
||||
"""Test that kwargs are propagated to subworkflows.
|
||||
|
||||
Verifies kwargs passed to parent workflow.run_stream() flow through to agents
|
||||
in subworkflows wrapped by WorkflowExecutor.
|
||||
"""
|
||||
from agent_framework._workflows._workflow_executor import WorkflowExecutor
|
||||
|
||||
# Create an agent inside the subworkflow that captures kwargs
|
||||
inner_agent = _KwargsCapturingAgent(name="inner_agent")
|
||||
|
||||
# Build the inner (sub) workflow with the agent
|
||||
inner_workflow = SequentialBuilder().participants([inner_agent]).build()
|
||||
|
||||
# Wrap the inner workflow in a WorkflowExecutor so it can be used as a subworkflow
|
||||
subworkflow_executor = WorkflowExecutor(workflow=inner_workflow, id="subworkflow_executor")
|
||||
|
||||
# Build the outer (parent) workflow containing the subworkflow
|
||||
outer_workflow = SequentialBuilder().participants([subworkflow_executor]).build()
|
||||
|
||||
# Define kwargs that should propagate to subworkflow
|
||||
custom_data = {"api_key": "secret123", "endpoint": "https://api.example.com"}
|
||||
user_token = {"user_name": "alice", "access_level": "admin"}
|
||||
|
||||
# Run the outer workflow with kwargs
|
||||
async for event in outer_workflow.run_stream(
|
||||
"test message for subworkflow",
|
||||
custom_data=custom_data,
|
||||
user_token=user_token,
|
||||
):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
# Verify that the inner agent was called
|
||||
assert len(inner_agent.captured_kwargs) >= 1, "Inner agent in subworkflow should have been invoked"
|
||||
|
||||
received_kwargs = inner_agent.captured_kwargs[0]
|
||||
|
||||
# Verify kwargs were propagated from parent workflow to subworkflow agent
|
||||
assert "custom_data" in received_kwargs, (
|
||||
f"Subworkflow agent should receive 'custom_data' kwarg. Received keys: {list(received_kwargs.keys())}"
|
||||
)
|
||||
assert "user_token" in received_kwargs, (
|
||||
f"Subworkflow agent should receive 'user_token' kwarg. Received keys: {list(received_kwargs.keys())}"
|
||||
)
|
||||
assert received_kwargs.get("custom_data") == custom_data, (
|
||||
f"Expected custom_data={custom_data}, got {received_kwargs.get('custom_data')}"
|
||||
)
|
||||
assert received_kwargs.get("user_token") == user_token, (
|
||||
f"Expected user_token={user_token}, got {received_kwargs.get('user_token')}"
|
||||
)
|
||||
|
||||
|
||||
async def test_subworkflow_kwargs_accessible_via_shared_state() -> None:
|
||||
"""Test that kwargs are accessible via SharedState within subworkflow.
|
||||
|
||||
Verifies that WORKFLOW_RUN_KWARGS_KEY is populated in the subworkflow's SharedState
|
||||
with kwargs from the parent workflow.
|
||||
"""
|
||||
from agent_framework import Executor, WorkflowContext, handler
|
||||
from agent_framework._workflows._workflow_executor import WorkflowExecutor
|
||||
|
||||
captured_kwargs_from_state: list[dict[str, Any]] = []
|
||||
|
||||
class _SharedStateReader(Executor):
|
||||
"""Executor that reads kwargs from SharedState for verification."""
|
||||
|
||||
@handler
|
||||
async def read_kwargs(self, msgs: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
|
||||
kwargs_from_state = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
|
||||
captured_kwargs_from_state.append(kwargs_from_state or {})
|
||||
await ctx.send_message(msgs)
|
||||
|
||||
# Build inner workflow with SharedState reader
|
||||
state_reader = _SharedStateReader(id="state_reader")
|
||||
inner_workflow = SequentialBuilder().participants([state_reader]).build()
|
||||
|
||||
# Wrap as subworkflow
|
||||
subworkflow_executor = WorkflowExecutor(workflow=inner_workflow, id="subworkflow")
|
||||
|
||||
# Build outer workflow
|
||||
outer_workflow = SequentialBuilder().participants([subworkflow_executor]).build()
|
||||
|
||||
# Run with kwargs
|
||||
async for event in outer_workflow.run_stream(
|
||||
"test",
|
||||
my_custom_kwarg="should_be_propagated",
|
||||
another_kwarg=42,
|
||||
):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
# Verify the state reader was invoked
|
||||
assert len(captured_kwargs_from_state) >= 1, "SharedState reader should have been invoked"
|
||||
|
||||
kwargs_in_subworkflow = captured_kwargs_from_state[0]
|
||||
|
||||
assert kwargs_in_subworkflow.get("my_custom_kwarg") == "should_be_propagated", (
|
||||
f"Expected 'my_custom_kwarg' in subworkflow SharedState, got: {kwargs_in_subworkflow}"
|
||||
)
|
||||
assert kwargs_in_subworkflow.get("another_kwarg") == 42, (
|
||||
f"Expected 'another_kwarg'=42 in subworkflow SharedState, got: {kwargs_in_subworkflow}"
|
||||
)
|
||||
|
||||
|
||||
async def test_nested_subworkflow_kwargs_propagation() -> None:
|
||||
"""Test kwargs propagation through multiple levels of nested subworkflows.
|
||||
|
||||
Verifies kwargs flow through 3 levels:
|
||||
- Outer workflow
|
||||
- Middle subworkflow (WorkflowExecutor)
|
||||
- Inner subworkflow (WorkflowExecutor) with agent
|
||||
"""
|
||||
from agent_framework._workflows._workflow_executor import WorkflowExecutor
|
||||
|
||||
# Innermost agent
|
||||
inner_agent = _KwargsCapturingAgent(name="deeply_nested_agent")
|
||||
|
||||
# Build inner workflow
|
||||
inner_workflow = SequentialBuilder().participants([inner_agent]).build()
|
||||
inner_executor = WorkflowExecutor(workflow=inner_workflow, id="inner_executor")
|
||||
|
||||
# Build middle workflow containing inner
|
||||
middle_workflow = SequentialBuilder().participants([inner_executor]).build()
|
||||
middle_executor = WorkflowExecutor(workflow=middle_workflow, id="middle_executor")
|
||||
|
||||
# Build outer workflow containing middle
|
||||
outer_workflow = SequentialBuilder().participants([middle_executor]).build()
|
||||
|
||||
# Run with kwargs
|
||||
async for event in outer_workflow.run_stream(
|
||||
"deeply nested test",
|
||||
deep_kwarg="should_reach_inner",
|
||||
):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
# Verify inner agent was called
|
||||
assert len(inner_agent.captured_kwargs) >= 1, "Deeply nested agent should be invoked"
|
||||
|
||||
received = inner_agent.captured_kwargs[0]
|
||||
assert received.get("deep_kwarg") == "should_reach_inner", (
|
||||
f"Deeply nested agent should receive 'deep_kwarg'. Got: {received}"
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user