mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b8a545589 | ||
|
|
5ab47596ff | ||
|
|
a32702cf38 | ||
|
|
8b743af217 | ||
|
|
0e152a0e33 | ||
|
|
3b77192ad0 | ||
|
|
defe0f1a89 | ||
|
|
85d70f01f6 | ||
|
|
6930c0f0b6 | ||
|
|
d83cf93f07 | ||
|
|
8783ac58f1 | ||
|
|
e15eab7da6 | ||
|
|
19a9e13788 |
@@ -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/).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+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 };
|
||||
|
||||
|
||||
@@ -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
-375
@@ -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,51 +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")
|
||||
```
|
||||
|
||||
# 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.
|
||||
|
||||
## 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.
|
||||
@@ -179,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:
|
||||
@@ -497,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:
|
||||
@@ -555,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`
|
||||
@@ -621,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -2224,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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -34,6 +34,7 @@ from .._types import (
|
||||
FunctionResultContent,
|
||||
Role,
|
||||
TextContent,
|
||||
TextReasoningContent,
|
||||
UriContent,
|
||||
UsageContent,
|
||||
UsageDetails,
|
||||
@@ -234,6 +235,8 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
contents.append(text_content)
|
||||
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,
|
||||
@@ -271,6 +274,8 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
|
||||
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,
|
||||
@@ -394,6 +399,10 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
}
|
||||
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]:
|
||||
@@ -405,6 +414,8 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
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"] = []
|
||||
|
||||
@@ -858,6 +858,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
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:
|
||||
@@ -940,7 +941,14 @@ 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":
|
||||
response_id = event.response.id
|
||||
conversation_id = self._get_conversation_id(event.response, chat_options.store)
|
||||
model = event.response.model
|
||||
if event.response.usage:
|
||||
@@ -1106,6 +1114,7 @@ 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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -374,11 +374,37 @@ async def test_response_format_parse_path() -> None:
|
||||
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")
|
||||
@@ -994,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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -37,6 +37,7 @@ from ._models import (
|
||||
RemoteConnection,
|
||||
Tool,
|
||||
WebSearchTool,
|
||||
_safe_mode_context,
|
||||
agent_schema_dispatch,
|
||||
)
|
||||
|
||||
@@ -118,7 +119,9 @@ class AgentFactory:
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
additional_mappings: Mapping[str, ProviderTypeMapping] | None = None,
|
||||
default_provider: str = "AzureAIClient",
|
||||
env_file: str | None = None,
|
||||
safe_mode: bool = True,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
"""Create the agent factory, with bindings.
|
||||
|
||||
@@ -151,7 +154,15 @@ class AgentFactory:
|
||||
that accepts the model.id value.
|
||||
default_provider: The default provider used when model.provider is not specified,
|
||||
default is "AzureAIClient".
|
||||
env_file: An optional path to a .env file to load environment variables from.
|
||||
safe_mode: Whether to run in safe mode, default is True.
|
||||
When safe_mode is True, environment variables are not accessible in the powerfx expressions.
|
||||
You can still use environment variables, but through the constructors of the classes.
|
||||
Which means you must make sure you are using the standard env variable names of the classes
|
||||
you are using and not custom ones and remove the powerfx statements that start with `=Env.`.
|
||||
Only when you trust the source of your yaml files, you can set safe_mode to False
|
||||
via the AgentFactory constructor.
|
||||
env_file_path: The path to the .env file to load environment variables from.
|
||||
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
|
||||
"""
|
||||
self.chat_client = chat_client
|
||||
self.bindings = bindings
|
||||
@@ -159,7 +170,8 @@ class AgentFactory:
|
||||
self.client_kwargs = client_kwargs or {}
|
||||
self.additional_mappings = additional_mappings or {}
|
||||
self.default_provider: str = default_provider
|
||||
load_dotenv(dotenv_path=env_file)
|
||||
self.safe_mode = safe_mode
|
||||
load_dotenv(dotenv_path=env_file_path, encoding=env_file_encoding)
|
||||
|
||||
def create_agent_from_yaml_path(self, yaml_path: str | Path) -> ChatAgent:
|
||||
"""Create a ChatAgent from a YAML file path.
|
||||
@@ -215,6 +227,8 @@ class AgentFactory:
|
||||
ModuleNotFoundError: If the required module for the provider type cannot be imported.
|
||||
AttributeError: If the required class for the provider type cannot be found in the module.
|
||||
"""
|
||||
# Set safe_mode context before parsing YAML to control PowerFx environment variable access
|
||||
_safe_mode_context.set(self.safe_mode)
|
||||
prompt_agent = agent_schema_dispatch(yaml.safe_load(yaml_str))
|
||||
if not isinstance(prompt_agent, PromptAgent):
|
||||
raise DeclarativeLoaderError("Only yaml definitions for a PromptAgent are supported for agent creation.")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import MutableMapping
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Literal, TypeVar, Union
|
||||
|
||||
from agent_framework import get_logger
|
||||
@@ -21,6 +22,11 @@ else:
|
||||
|
||||
logger = get_logger("agent_framework.declarative")
|
||||
|
||||
# Context variable for safe_mode setting.
|
||||
# When True (default), environment variables are NOT accessible in PowerFx expressions.
|
||||
# When False, environment variables CAN be accessed via Env symbol in PowerFx.
|
||||
_safe_mode_context: ContextVar[bool] = ContextVar("safe_mode", default=True)
|
||||
|
||||
|
||||
@overload
|
||||
def _try_powerfx_eval(value: None, log_value: bool = True) -> None: ...
|
||||
@@ -49,6 +55,9 @@ def _try_powerfx_eval(value: str | None, log_value: bool = True) -> str | None:
|
||||
)
|
||||
return value
|
||||
try:
|
||||
safe_mode = _safe_mode_context.get()
|
||||
if safe_mode:
|
||||
return engine.eval(value[1:])
|
||||
return engine.eval(value[1:], symbols={"Env": dict(os.environ)})
|
||||
except Exception as exc:
|
||||
if log_value:
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Declarative specification support 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"
|
||||
|
||||
@@ -454,3 +454,140 @@ def test_agent_schema_dispatch_agent_samples(yaml_file: Path, agent_samples_dir:
|
||||
result = agent_schema_dispatch(yaml.safe_load(content))
|
||||
# Result can be None for unknown kinds, but should not raise exceptions
|
||||
assert result is not None, f"agent_schema_dispatch returned None for {yaml_file.relative_to(agent_samples_dir)}"
|
||||
|
||||
|
||||
class TestAgentFactorySafeMode:
|
||||
"""Tests for AgentFactory safe_mode parameter."""
|
||||
|
||||
def test_agent_factory_safe_mode_default_is_true(self):
|
||||
"""Test that safe_mode is True by default."""
|
||||
from agent_framework_declarative._loader import AgentFactory
|
||||
|
||||
factory = AgentFactory()
|
||||
assert factory.safe_mode is True
|
||||
|
||||
def test_agent_factory_safe_mode_can_be_set_false(self):
|
||||
"""Test that safe_mode can be explicitly set to False."""
|
||||
from agent_framework_declarative._loader import AgentFactory
|
||||
|
||||
factory = AgentFactory(safe_mode=False)
|
||||
assert factory.safe_mode is False
|
||||
|
||||
def test_agent_factory_safe_mode_blocks_env_in_yaml(self, monkeypatch):
|
||||
"""Test that safe_mode=True blocks environment variable access in YAML parsing."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from agent_framework_declarative._loader import AgentFactory
|
||||
|
||||
monkeypatch.setenv("TEST_MODEL_ID", "gpt-4-from-env")
|
||||
|
||||
# Create a mock chat client to avoid needing real provider
|
||||
mock_client = MagicMock()
|
||||
|
||||
yaml_content = """
|
||||
kind: Prompt
|
||||
name: test-agent
|
||||
description: =Env.TEST_DESCRIPTION
|
||||
instructions: Hello world
|
||||
"""
|
||||
monkeypatch.setenv("TEST_DESCRIPTION", "Description from env")
|
||||
|
||||
# With safe_mode=True (default), Env access should fail and return original value
|
||||
factory = AgentFactory(chat_client=mock_client, safe_mode=True)
|
||||
agent = factory.create_agent_from_yaml(yaml_content)
|
||||
|
||||
# The description should NOT be resolved from env (PowerFx fails, returns original)
|
||||
assert agent.description == "=Env.TEST_DESCRIPTION"
|
||||
|
||||
def test_agent_factory_safe_mode_false_allows_env_in_yaml(self, monkeypatch):
|
||||
"""Test that safe_mode=False allows environment variable access in YAML parsing."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from agent_framework_declarative._loader import AgentFactory
|
||||
|
||||
monkeypatch.setenv("TEST_DESCRIPTION", "Description from env")
|
||||
|
||||
# Create a mock chat client to avoid needing real provider
|
||||
mock_client = MagicMock()
|
||||
|
||||
yaml_content = """
|
||||
kind: Prompt
|
||||
name: test-agent
|
||||
description: =Env.TEST_DESCRIPTION
|
||||
instructions: Hello world
|
||||
"""
|
||||
|
||||
# With safe_mode=False, Env access should work
|
||||
factory = AgentFactory(chat_client=mock_client, safe_mode=False)
|
||||
agent = factory.create_agent_from_yaml(yaml_content)
|
||||
|
||||
# The description should be resolved from env
|
||||
assert agent.description == "Description from env"
|
||||
|
||||
def test_agent_factory_safe_mode_with_api_key_connection(self, monkeypatch):
|
||||
"""Test safe_mode with API key connection containing env variable."""
|
||||
from agent_framework_declarative._models import _safe_mode_context
|
||||
|
||||
monkeypatch.setenv("MY_API_KEY", "secret-key-123")
|
||||
|
||||
yaml_content = """
|
||||
kind: Prompt
|
||||
name: test-agent
|
||||
description: Test agent
|
||||
instructions: Hello
|
||||
model:
|
||||
id: gpt-4
|
||||
provider: OpenAI
|
||||
apiType: Chat
|
||||
connection:
|
||||
kind: key
|
||||
apiKey: =Env.MY_API_KEY
|
||||
"""
|
||||
|
||||
# Manually trigger the YAML parsing to check the context is set correctly
|
||||
import yaml as yaml_module
|
||||
|
||||
from agent_framework_declarative._models import agent_schema_dispatch
|
||||
|
||||
token = _safe_mode_context.set(True) # Ensure we're in safe mode
|
||||
try:
|
||||
result = agent_schema_dispatch(yaml_module.safe_load(yaml_content))
|
||||
|
||||
# The API key should NOT be resolved (still has the PowerFx expression)
|
||||
assert result.model.connection.apiKey == "=Env.MY_API_KEY"
|
||||
finally:
|
||||
_safe_mode_context.reset(token)
|
||||
|
||||
def test_agent_factory_safe_mode_false_resolves_api_key(self, monkeypatch):
|
||||
"""Test safe_mode=False resolves API key from environment."""
|
||||
from agent_framework_declarative._models import _safe_mode_context
|
||||
|
||||
monkeypatch.setenv("MY_API_KEY", "secret-key-123")
|
||||
|
||||
yaml_content = """
|
||||
kind: Prompt
|
||||
name: test-agent
|
||||
description: Test agent
|
||||
instructions: Hello
|
||||
model:
|
||||
id: gpt-4
|
||||
provider: OpenAI
|
||||
apiType: Chat
|
||||
connection:
|
||||
kind: key
|
||||
apiKey: =Env.MY_API_KEY
|
||||
"""
|
||||
|
||||
# With safe_mode=False, the API key should be resolved
|
||||
import yaml as yaml_module
|
||||
|
||||
from agent_framework_declarative._models import agent_schema_dispatch
|
||||
|
||||
token = _safe_mode_context.set(False) # Disable safe mode
|
||||
try:
|
||||
result = agent_schema_dispatch(yaml_module.safe_load(yaml_content))
|
||||
|
||||
# The API key should be resolved from environment
|
||||
assert result.model.connection.apiKey == "secret-key-123"
|
||||
finally:
|
||||
_safe_mode_context.reset(token)
|
||||
|
||||
@@ -41,6 +41,7 @@ from agent_framework_declarative._models import (
|
||||
Template,
|
||||
ToolResource,
|
||||
WebSearchTool,
|
||||
_safe_mode_context,
|
||||
_try_powerfx_eval,
|
||||
)
|
||||
|
||||
@@ -874,35 +875,50 @@ class TestTryPowerfxEval:
|
||||
monkeypatch.setenv("API_KEY", "secret123")
|
||||
monkeypatch.setenv("PORT", "8080")
|
||||
|
||||
# Test basic env access
|
||||
assert _try_powerfx_eval("=Env.TEST_VAR") == "test_value"
|
||||
assert _try_powerfx_eval("=Env.API_KEY") == "secret123"
|
||||
assert _try_powerfx_eval("=Env.PORT") == "8080"
|
||||
# Set safe_mode=False to allow environment variable access
|
||||
token = _safe_mode_context.set(False)
|
||||
try:
|
||||
# Test basic env access
|
||||
assert _try_powerfx_eval("=Env.TEST_VAR") == "test_value"
|
||||
assert _try_powerfx_eval("=Env.API_KEY") == "secret123"
|
||||
assert _try_powerfx_eval("=Env.PORT") == "8080"
|
||||
finally:
|
||||
_safe_mode_context.reset(token)
|
||||
|
||||
def test_env_variable_with_string_concatenation(self, monkeypatch):
|
||||
"""Test env variables with string concatenation operator."""
|
||||
monkeypatch.setenv("BASE_URL", "https://api.example.com")
|
||||
monkeypatch.setenv("API_VERSION", "v1")
|
||||
|
||||
# Test concatenation with &
|
||||
result = _try_powerfx_eval('=Env.BASE_URL & "/" & Env.API_VERSION')
|
||||
assert result == "https://api.example.com/v1"
|
||||
# Set safe_mode=False to allow environment variable access
|
||||
token = _safe_mode_context.set(False)
|
||||
try:
|
||||
# Test concatenation with &
|
||||
result = _try_powerfx_eval('=Env.BASE_URL & "/" & Env.API_VERSION')
|
||||
assert result == "https://api.example.com/v1"
|
||||
|
||||
# Test concatenation with literals
|
||||
result = _try_powerfx_eval('="API Key: " & Env.API_VERSION')
|
||||
assert result == "API Key: v1"
|
||||
# Test concatenation with literals
|
||||
result = _try_powerfx_eval('="API Key: " & Env.API_VERSION')
|
||||
assert result == "API Key: v1"
|
||||
finally:
|
||||
_safe_mode_context.reset(token)
|
||||
|
||||
def test_string_comparison_operators(self, monkeypatch):
|
||||
"""Test PowerFx string comparison operators."""
|
||||
monkeypatch.setenv("ENV_MODE", "production")
|
||||
|
||||
# Equal to - returns bool
|
||||
assert _try_powerfx_eval('=Env.ENV_MODE = "production"') is True
|
||||
assert _try_powerfx_eval('=Env.ENV_MODE = "development"') is False
|
||||
# Set safe_mode=False to allow environment variable access
|
||||
token = _safe_mode_context.set(False)
|
||||
try:
|
||||
# Equal to - returns bool
|
||||
assert _try_powerfx_eval('=Env.ENV_MODE = "production"') is True
|
||||
assert _try_powerfx_eval('=Env.ENV_MODE = "development"') is False
|
||||
|
||||
# Not equal to - returns bool
|
||||
assert _try_powerfx_eval('=Env.ENV_MODE <> "development"') is True
|
||||
assert _try_powerfx_eval('=Env.ENV_MODE <> "production"') is False
|
||||
# Not equal to - returns bool
|
||||
assert _try_powerfx_eval('=Env.ENV_MODE <> "development"') is True
|
||||
assert _try_powerfx_eval('=Env.ENV_MODE <> "production"') is False
|
||||
finally:
|
||||
_safe_mode_context.reset(token)
|
||||
|
||||
def test_string_in_operator(self):
|
||||
"""Test PowerFx 'in' operator for substring testing (case-insensitive)."""
|
||||
@@ -958,11 +974,54 @@ class TestTryPowerfxEval:
|
||||
monkeypatch.setenv("URL_WITH_QUERY", "https://example.com?param=value")
|
||||
monkeypatch.setenv("PATH_WITH_SPACES", "C:\\Program Files\\App")
|
||||
|
||||
result = _try_powerfx_eval("=Env.URL_WITH_QUERY")
|
||||
assert result == "https://example.com?param=value"
|
||||
# Set safe_mode=False to allow environment variable access
|
||||
token = _safe_mode_context.set(False)
|
||||
try:
|
||||
result = _try_powerfx_eval("=Env.URL_WITH_QUERY")
|
||||
assert result == "https://example.com?param=value"
|
||||
|
||||
result = _try_powerfx_eval("=Env.PATH_WITH_SPACES")
|
||||
assert result == "C:\\Program Files\\App"
|
||||
result = _try_powerfx_eval("=Env.PATH_WITH_SPACES")
|
||||
assert result == "C:\\Program Files\\App"
|
||||
finally:
|
||||
_safe_mode_context.reset(token)
|
||||
|
||||
def test_safe_mode_blocks_env_access(self, monkeypatch):
|
||||
"""Test that safe_mode=True (default) blocks environment variable access."""
|
||||
monkeypatch.setenv("SECRET_VAR", "secret_value")
|
||||
|
||||
# Set safe_mode=True (default)
|
||||
token = _safe_mode_context.set(True)
|
||||
try:
|
||||
# When safe_mode=True, Env is not available and the expression fails,
|
||||
# returning the original value
|
||||
result = _try_powerfx_eval("=Env.SECRET_VAR")
|
||||
assert result == "=Env.SECRET_VAR"
|
||||
finally:
|
||||
_safe_mode_context.reset(token)
|
||||
|
||||
def test_safe_mode_context_isolation(self, monkeypatch):
|
||||
"""Test that safe_mode context variable properly isolates env access."""
|
||||
monkeypatch.setenv("TEST_VAR", "test_value")
|
||||
|
||||
# First, set safe_mode=True - should NOT allow env access
|
||||
token = _safe_mode_context.set(True)
|
||||
try:
|
||||
result_safe = _try_powerfx_eval("=Env.TEST_VAR")
|
||||
assert result_safe == "=Env.TEST_VAR"
|
||||
|
||||
# Then, set safe_mode=False - should allow env access
|
||||
token2 = _safe_mode_context.set(False)
|
||||
try:
|
||||
result_unsafe = _try_powerfx_eval("=Env.TEST_VAR")
|
||||
assert result_unsafe == "test_value"
|
||||
finally:
|
||||
_safe_mode_context.reset(token2)
|
||||
|
||||
# After reset, should block again
|
||||
result_safe_again = _try_powerfx_eval("=Env.TEST_VAR")
|
||||
assert result_safe_again == "=Env.TEST_VAR"
|
||||
finally:
|
||||
_safe_mode_context.reset(token)
|
||||
|
||||
|
||||
class TestAgentManifest:
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
|
||||
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://github.com/microsoft/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,9 @@
|
||||
# Get Started with Microsoft Agent Framework Foundry Local
|
||||
|
||||
Please install this package as the extra for `agent-framework`:
|
||||
|
||||
```bash
|
||||
pip install agent-framework-foundry-local --pre
|
||||
```
|
||||
|
||||
and see the [README](https://github.com/microsoft/agent-framework/tree/main/python/README.md) for more information.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._foundry_local_client import FoundryLocalClient
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
__all__ = [
|
||||
"FoundryLocalClient",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,160 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from agent_framework import use_chat_middleware, use_function_invocation
|
||||
from agent_framework._pydantic import AFBaseSettings
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from agent_framework.observability import use_instrumentation
|
||||
from agent_framework.openai._chat_client import OpenAIBaseChatClient
|
||||
from foundry_local import FoundryLocalManager
|
||||
from foundry_local.models import DeviceType
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
__all__ = [
|
||||
"FoundryLocalClient",
|
||||
]
|
||||
|
||||
|
||||
class FoundryLocalSettings(AFBaseSettings):
|
||||
"""Foundry local model settings.
|
||||
|
||||
The settings are first loaded from environment variables with the prefix 'FOUNDRY_LOCAL_'.
|
||||
If the environment variables are not found, the settings can be loaded from a .env file
|
||||
with the encoding 'utf-8'. If the settings are not found in the .env file, the settings
|
||||
are ignored; however, validation will fail alerting that the settings are missing.
|
||||
|
||||
Attributes:
|
||||
model_id: The name of the model deployment to use.
|
||||
(Env var FOUNDRY_LOCAL_MODEL_ID)
|
||||
Parameters:
|
||||
env_file_path: If provided, the .env settings are read from this file path location.
|
||||
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
|
||||
"""
|
||||
|
||||
env_prefix: ClassVar[str] = "FOUNDRY_LOCAL_"
|
||||
|
||||
model_id: str
|
||||
|
||||
|
||||
@use_function_invocation
|
||||
@use_instrumentation
|
||||
@use_chat_middleware
|
||||
class FoundryLocalClient(OpenAIBaseChatClient):
|
||||
"""Foundry Local Chat completion class."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_id: str | None = None,
|
||||
*,
|
||||
bootstrap: bool = True,
|
||||
timeout: float | None = None,
|
||||
prepare_model: bool = True,
|
||||
device: DeviceType | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str = "utf-8",
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize a FoundryLocalClient.
|
||||
|
||||
Keyword Args:
|
||||
model_id: The Foundry Local model ID or alias to use. If not provided,
|
||||
it will be loaded from the FoundryLocalSettings.
|
||||
bootstrap: Whether to start the Foundry Local service if not already running.
|
||||
Default is True.
|
||||
timeout: Optional timeout for requests to Foundry Local.
|
||||
This timeout is applied to any call to the Foundry Local service.
|
||||
prepare_model: Whether to download the model into the cache, and load the model into
|
||||
the inferencing service upon initialization. Default is True.
|
||||
If false, the first call to generate a completion will load the model,
|
||||
and might take a long time.
|
||||
device: The device type to use for model inference.
|
||||
The device is used to select the appropriate model variant.
|
||||
If not provided, the default device for your system will be used.
|
||||
The values are in the foundry_local.models.DeviceType enum.
|
||||
env_file_path: If provided, the .env settings are read from this file path location.
|
||||
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
|
||||
kwargs: Additional keyword arguments, are passed to the OpenAIBaseChatClient.
|
||||
This can include middleware and additional properties.
|
||||
|
||||
Examples:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Create a FoundryLocalClient with a specific model ID:
|
||||
from agent_framework_foundry_local import FoundryLocalClient
|
||||
|
||||
client = FoundryLocalClient(model_id="phi-4-mini")
|
||||
|
||||
agent = client.create_agent(
|
||||
name="LocalAgent",
|
||||
instructions="You are a helpful agent.",
|
||||
tools=get_weather,
|
||||
)
|
||||
response = await agent.run("What's the weather like in Seattle?")
|
||||
|
||||
# Or you can set the model id in the environment:
|
||||
os.environ["FOUNDRY_LOCAL_MODEL_ID"] = "phi-4-mini"
|
||||
client = FoundryLocalClient()
|
||||
|
||||
# A FoundryLocalManager is created and if set, the service is started.
|
||||
# The FoundryLocalManager is available via the `manager` property.
|
||||
# For instance to find out which models are available:
|
||||
for model in client.manager.list_catalog_models():
|
||||
print(f"- {model.alias} for {model.task} - id={model.id}")
|
||||
|
||||
# Other options include specifying the device type:
|
||||
from foundry_local.models import DeviceType
|
||||
|
||||
client = FoundryLocalClient(
|
||||
model_id="phi-4-mini",
|
||||
device=DeviceType.GPU,
|
||||
)
|
||||
# and choosing if the model should be prepared on initialization:
|
||||
client = FoundryLocalClient(
|
||||
model_id="phi-4-mini",
|
||||
prepare_model=False,
|
||||
)
|
||||
# Beware, in this case the first request to generate a completion
|
||||
# will take a long time as the model is loaded then.
|
||||
# Alternatively, you could call the `download_model` and `load_model` methods
|
||||
# on the `manager` property manually.
|
||||
client.manager.download_model(alias_or_model_id="phi-4-mini", device=DeviceType.CPU)
|
||||
client.manager.load_model(alias_or_model_id="phi-4-mini", device=DeviceType.CPU)
|
||||
|
||||
# You can also use the CLI:
|
||||
`foundry model load phi-4-mini --device Auto`
|
||||
|
||||
Raises:
|
||||
ServiceInitializationError: If the specified model ID or alias is not found.
|
||||
Sometimes a model might be available but if you have specified a device
|
||||
type that is not supported by the model, it will not be found.
|
||||
|
||||
"""
|
||||
settings = FoundryLocalSettings(
|
||||
model_id=model_id, # type: ignore
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
manager = FoundryLocalManager(bootstrap=bootstrap, timeout=timeout)
|
||||
model_info = manager.get_model_info(
|
||||
alias_or_model_id=settings.model_id,
|
||||
device=device,
|
||||
)
|
||||
if model_info is None:
|
||||
message = (
|
||||
f"Model with ID or alias '{settings.model_id}:{device.value}' not found in Foundry Local."
|
||||
if device
|
||||
else f"Model with ID or alias '{settings.model_id}' for your current device not found in Foundry Local."
|
||||
)
|
||||
raise ServiceInitializationError(message)
|
||||
if prepare_model:
|
||||
manager.download_model(alias_or_model_id=model_info.id, device=device)
|
||||
manager.load_model(alias_or_model_id=model_info.id, device=device)
|
||||
|
||||
super().__init__(
|
||||
model_id=model_info.id,
|
||||
client=AsyncOpenAI(base_url=manager.endpoint, api_key=manager.api_key),
|
||||
**kwargs,
|
||||
)
|
||||
self.manager = manager
|
||||
@@ -0,0 +1,87 @@
|
||||
[project]
|
||||
name = "agent-framework-foundry-local"
|
||||
description = "Foundry Local integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
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"
|
||||
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",
|
||||
"foundry-local-sdk>=0.5.1,<1",
|
||||
]
|
||||
|
||||
[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"
|
||||
exclude = ['tests']
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_foundry_local"]
|
||||
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_foundry_local"
|
||||
test = "pytest --cov=agent_framework_foundry_local --cov-report=term-missing:skip-covered tests"
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
@@ -0,0 +1,78 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# ruff: noqa
|
||||
|
||||
import asyncio
|
||||
from random import randint
|
||||
from typing import TYPE_CHECKING, Annotated
|
||||
|
||||
from agent_framework_foundry_local import FoundryLocalClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import ChatAgent
|
||||
|
||||
"""
|
||||
This sample demonstrates basic usage of the FoundryLocalClient.
|
||||
Shows both streaming and non-streaming responses with function tools.
|
||||
|
||||
Running this sample the first time will be slow, as the model needs to be
|
||||
downloaded and initialized.
|
||||
|
||||
Also, not every model supports function calling, so be sure to check the
|
||||
model capabilities in the Foundry catalog, or pick one from the list printed
|
||||
when running this sample.
|
||||
"""
|
||||
|
||||
|
||||
def get_weather(
|
||||
location: Annotated[str, "The location to get the weather for."],
|
||||
) -> str:
|
||||
"""Get the weather for a given location."""
|
||||
conditions = ["sunny", "cloudy", "rainy", "stormy"]
|
||||
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
|
||||
|
||||
|
||||
async def non_streaming_example(agent: "ChatAgent") -> None:
|
||||
"""Example of non-streaming response (get the complete result at once)."""
|
||||
print("=== Non-streaming Response Example ===")
|
||||
|
||||
query = "What's the weather like in Seattle?"
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(query)
|
||||
print(f"Agent: {result}\n")
|
||||
|
||||
|
||||
async def streaming_example(agent: "ChatAgent") -> None:
|
||||
"""Example of streaming response (get results as they are generated)."""
|
||||
print("=== Streaming Response Example ===")
|
||||
|
||||
query = "What's the weather like in Amsterdam?"
|
||||
print(f"User: {query}")
|
||||
print("Agent: ", end="", flush=True)
|
||||
async for chunk in agent.run_stream(query):
|
||||
if chunk.text:
|
||||
print(chunk.text, end="", flush=True)
|
||||
print("\n")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
print("=== Basic Foundry Local Client Agent Example ===")
|
||||
|
||||
client = FoundryLocalClient(model_id="phi-4-mini")
|
||||
print(f"Client Model ID: {client.model_id}\n")
|
||||
print("Other available models (tool calling supported only):")
|
||||
for model in client.manager.list_catalog_models():
|
||||
if model.supports_tool_calling:
|
||||
print(
|
||||
f"- {model.alias} for {model.task} - id={model.id} - {(model.file_size_mb / 1000):.2f} GB - {model.license}"
|
||||
)
|
||||
agent = client.create_agent(
|
||||
name="LocalAgent",
|
||||
instructions="You are a helpful agent.",
|
||||
tools=get_weather,
|
||||
)
|
||||
await non_streaming_example(agent)
|
||||
await streaming_example(agent)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,55 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pytest import fixture
|
||||
|
||||
|
||||
@fixture
|
||||
def exclude_list(request: Any) -> list[str]:
|
||||
"""Fixture that returns a list of environment variables to exclude."""
|
||||
return request.param if hasattr(request, "param") else []
|
||||
|
||||
|
||||
@fixture
|
||||
def override_env_param_dict(request: Any) -> dict[str, str]:
|
||||
"""Fixture that returns a dict of environment variables to override."""
|
||||
return request.param if hasattr(request, "param") else {}
|
||||
|
||||
|
||||
@fixture()
|
||||
def foundry_local_unit_test_env(monkeypatch: Any, exclude_list: list[str], override_env_param_dict: dict[str, str]):
|
||||
"""Fixture to set environment variables for FoundryLocalSettings."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"FOUNDRY_LOCAL_MODEL_ID": "test-model-id",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key in exclude_list:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
continue
|
||||
monkeypatch.setenv(key, value)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_foundry_local_manager() -> MagicMock:
|
||||
"""Fixture that provides a mock FoundryLocalManager."""
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.endpoint = "http://localhost:5272/v1"
|
||||
mock_manager.api_key = "test-api-key"
|
||||
|
||||
mock_model_info = MagicMock()
|
||||
mock_model_info.id = "test-model-id"
|
||||
mock_manager.get_model_info.return_value = mock_model_info
|
||||
|
||||
return mock_manager
|
||||
@@ -0,0 +1,198 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import ChatClientProtocol
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from agent_framework_foundry_local import FoundryLocalClient
|
||||
from agent_framework_foundry_local._foundry_local_client import FoundryLocalSettings
|
||||
|
||||
# Settings Tests
|
||||
|
||||
|
||||
def test_foundry_local_settings_init_from_env(foundry_local_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test FoundryLocalSettings initialization from environment variables."""
|
||||
settings = FoundryLocalSettings(env_file_path="test.env")
|
||||
|
||||
assert settings.model_id == foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL_ID"]
|
||||
|
||||
|
||||
def test_foundry_local_settings_init_with_explicit_values() -> None:
|
||||
"""Test FoundryLocalSettings initialization with explicit values."""
|
||||
settings = FoundryLocalSettings(model_id="custom-model-id", env_file_path="test.env")
|
||||
|
||||
assert settings.model_id == "custom-model-id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["FOUNDRY_LOCAL_MODEL_ID"]], indirect=True)
|
||||
def test_foundry_local_settings_missing_model_id(foundry_local_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test FoundryLocalSettings when model_id is missing raises ValidationError."""
|
||||
with pytest.raises(ValidationError):
|
||||
FoundryLocalSettings(env_file_path="test.env")
|
||||
|
||||
|
||||
def test_foundry_local_settings_explicit_overrides_env(foundry_local_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test that explicit values override environment variables."""
|
||||
settings = FoundryLocalSettings(model_id="override-model-id", env_file_path="test.env")
|
||||
|
||||
assert settings.model_id == "override-model-id"
|
||||
assert settings.model_id != foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL_ID"]
|
||||
|
||||
|
||||
# Client Initialization Tests
|
||||
|
||||
|
||||
def test_foundry_local_client_init(mock_foundry_local_manager: MagicMock) -> None:
|
||||
"""Test FoundryLocalClient initialization with mocked manager."""
|
||||
with patch(
|
||||
"agent_framework_foundry_local._foundry_local_client.FoundryLocalManager",
|
||||
return_value=mock_foundry_local_manager,
|
||||
):
|
||||
client = FoundryLocalClient(model_id="test-model-id", env_file_path="test.env")
|
||||
|
||||
assert client.model_id == "test-model-id"
|
||||
assert client.manager is mock_foundry_local_manager
|
||||
assert isinstance(client, ChatClientProtocol)
|
||||
|
||||
|
||||
def test_foundry_local_client_init_with_bootstrap_false(mock_foundry_local_manager: MagicMock) -> None:
|
||||
"""Test FoundryLocalClient initialization with bootstrap=False."""
|
||||
with patch(
|
||||
"agent_framework_foundry_local._foundry_local_client.FoundryLocalManager",
|
||||
return_value=mock_foundry_local_manager,
|
||||
) as mock_manager_class:
|
||||
FoundryLocalClient(model_id="test-model-id", bootstrap=False, env_file_path="test.env")
|
||||
|
||||
mock_manager_class.assert_called_once_with(
|
||||
bootstrap=False,
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
|
||||
def test_foundry_local_client_init_with_timeout(mock_foundry_local_manager: MagicMock) -> None:
|
||||
"""Test FoundryLocalClient initialization with custom timeout."""
|
||||
with patch(
|
||||
"agent_framework_foundry_local._foundry_local_client.FoundryLocalManager",
|
||||
return_value=mock_foundry_local_manager,
|
||||
) as mock_manager_class:
|
||||
FoundryLocalClient(model_id="test-model-id", timeout=60.0, env_file_path="test.env")
|
||||
|
||||
mock_manager_class.assert_called_once_with(
|
||||
bootstrap=True,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
|
||||
def test_foundry_local_client_init_model_not_found(mock_foundry_local_manager: MagicMock) -> None:
|
||||
"""Test FoundryLocalClient initialization when model is not found."""
|
||||
mock_foundry_local_manager.get_model_info.return_value = None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent_framework_foundry_local._foundry_local_client.FoundryLocalManager",
|
||||
return_value=mock_foundry_local_manager,
|
||||
),
|
||||
pytest.raises(ServiceInitializationError, match="not found in Foundry Local"),
|
||||
):
|
||||
FoundryLocalClient(model_id="unknown-model", env_file_path="test.env")
|
||||
|
||||
|
||||
def test_foundry_local_client_uses_model_info_id(mock_foundry_local_manager: MagicMock) -> None:
|
||||
"""Test that client uses the model ID from model_info, not the alias."""
|
||||
mock_model_info = MagicMock()
|
||||
mock_model_info.id = "resolved-model-id"
|
||||
mock_foundry_local_manager.get_model_info.return_value = mock_model_info
|
||||
|
||||
with patch(
|
||||
"agent_framework_foundry_local._foundry_local_client.FoundryLocalManager",
|
||||
return_value=mock_foundry_local_manager,
|
||||
):
|
||||
client = FoundryLocalClient(model_id="model-alias", env_file_path="test.env")
|
||||
|
||||
assert client.model_id == "resolved-model-id"
|
||||
|
||||
|
||||
def test_foundry_local_client_init_from_env(
|
||||
foundry_local_unit_test_env: dict[str, str], mock_foundry_local_manager: MagicMock
|
||||
) -> None:
|
||||
"""Test FoundryLocalClient initialization using environment variables."""
|
||||
with patch(
|
||||
"agent_framework_foundry_local._foundry_local_client.FoundryLocalManager",
|
||||
return_value=mock_foundry_local_manager,
|
||||
):
|
||||
client = FoundryLocalClient(env_file_path="test.env")
|
||||
|
||||
assert client.model_id == foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL_ID"]
|
||||
|
||||
|
||||
def test_foundry_local_client_init_with_device(mock_foundry_local_manager: MagicMock) -> None:
|
||||
"""Test FoundryLocalClient initialization with device parameter."""
|
||||
from foundry_local.models import DeviceType
|
||||
|
||||
with patch(
|
||||
"agent_framework_foundry_local._foundry_local_client.FoundryLocalManager",
|
||||
return_value=mock_foundry_local_manager,
|
||||
):
|
||||
FoundryLocalClient(model_id="test-model-id", device=DeviceType.CPU, env_file_path="test.env")
|
||||
|
||||
mock_foundry_local_manager.get_model_info.assert_called_once_with(
|
||||
alias_or_model_id="test-model-id",
|
||||
device=DeviceType.CPU,
|
||||
)
|
||||
mock_foundry_local_manager.download_model.assert_called_once_with(
|
||||
alias_or_model_id="test-model-id",
|
||||
device=DeviceType.CPU,
|
||||
)
|
||||
mock_foundry_local_manager.load_model.assert_called_once_with(
|
||||
alias_or_model_id="test-model-id",
|
||||
device=DeviceType.CPU,
|
||||
)
|
||||
|
||||
|
||||
def test_foundry_local_client_init_model_not_found_with_device(mock_foundry_local_manager: MagicMock) -> None:
|
||||
"""Test FoundryLocalClient error message includes device when model not found with device specified."""
|
||||
from foundry_local.models import DeviceType
|
||||
|
||||
mock_foundry_local_manager.get_model_info.return_value = None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent_framework_foundry_local._foundry_local_client.FoundryLocalManager",
|
||||
return_value=mock_foundry_local_manager,
|
||||
),
|
||||
pytest.raises(ServiceInitializationError, match="unknown-model:GPU.*not found"),
|
||||
):
|
||||
FoundryLocalClient(model_id="unknown-model", device=DeviceType.GPU, env_file_path="test.env")
|
||||
|
||||
|
||||
def test_foundry_local_client_init_with_prepare_model_false(mock_foundry_local_manager: MagicMock) -> None:
|
||||
"""Test FoundryLocalClient initialization with prepare_model=False skips download and load."""
|
||||
with patch(
|
||||
"agent_framework_foundry_local._foundry_local_client.FoundryLocalManager",
|
||||
return_value=mock_foundry_local_manager,
|
||||
):
|
||||
FoundryLocalClient(model_id="test-model-id", prepare_model=False, env_file_path="test.env")
|
||||
|
||||
mock_foundry_local_manager.download_model.assert_not_called()
|
||||
mock_foundry_local_manager.load_model.assert_not_called()
|
||||
|
||||
|
||||
def test_foundry_local_client_init_calls_download_and_load(mock_foundry_local_manager: MagicMock) -> None:
|
||||
"""Test FoundryLocalClient initialization calls download_model and load_model by default."""
|
||||
with patch(
|
||||
"agent_framework_foundry_local._foundry_local_client.FoundryLocalManager",
|
||||
return_value=mock_foundry_local_manager,
|
||||
):
|
||||
FoundryLocalClient(model_id="test-model-id", env_file_path="test.env")
|
||||
|
||||
mock_foundry_local_manager.download_model.assert_called_once_with(
|
||||
alias_or_model_id="test-model-id",
|
||||
device=None,
|
||||
)
|
||||
mock_foundry_local_manager.load_model.assert_called_once_with(
|
||||
alias_or_model_id="test-model-id",
|
||||
device=None,
|
||||
)
|
||||
@@ -4,7 +4,7 @@ description = "Experimental modules 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 = "Mem0 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"
|
||||
|
||||
@@ -239,7 +239,8 @@ class OllamaChatClient(BaseChatClient):
|
||||
|
||||
def _format_assistant_message(self, message: ChatMessage) -> list[OllamaMessage]:
|
||||
text_content = message.text
|
||||
reasoning_contents = "".join(c.text for c in message.contents if isinstance(c, TextReasoningContent))
|
||||
# Ollama shouldn't have encrypted reasoning, so we just process text.
|
||||
reasoning_contents = "".join((c.text or "") for c in message.contents if isinstance(c, TextReasoningContent))
|
||||
|
||||
assistant_message = OllamaMessage(role="assistant", content=text_content, thinking=reasoning_contents)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Ollama 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://learn.microsoft.com/en-us/agent-framework/"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f
|
||||
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://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Redis 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 = "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"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core[all]==1.0.0b251216",
|
||||
"agent-framework-core[all]==1.0.0b251223",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -90,10 +90,12 @@ agent-framework-azure-ai-search = { workspace = true }
|
||||
agent-framework-anthropic = { workspace = true }
|
||||
agent-framework-azure-ai = { workspace = true }
|
||||
agent-framework-azurefunctions = { workspace = true }
|
||||
agent-framework-bedrock = { workspace = true }
|
||||
agent-framework-chatkit = { workspace = true }
|
||||
agent-framework-copilotstudio = { workspace = true }
|
||||
agent-framework-declarative = { workspace = true }
|
||||
agent-framework-devui = { workspace = true }
|
||||
agent-framework-foundry-local = { workspace = true }
|
||||
agent-framework-lab = { workspace = true }
|
||||
agent-framework-mem0 = { workspace = true }
|
||||
agent-framework-ollama = { workspace = true }
|
||||
@@ -265,13 +267,6 @@ pytest --import-mode=importlib
|
||||
packages/**/tests
|
||||
"""
|
||||
|
||||
[tool.poe.tasks.azure-ai-tests]
|
||||
cmd = """
|
||||
pytest --import-mode=importlib
|
||||
-n logical --dist loadfile --dist worksteal
|
||||
packages/azure-ai/tests
|
||||
"""
|
||||
|
||||
[tool.poe.tasks.venv]
|
||||
cmd = "uv venv --clear --python $python"
|
||||
args = [{ name = "python", default = "3.13", options = ['-p', '--python'] }]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""This sample has moved to python/packages/bedrock/samples/bedrock_sample.py."""
|
||||
@@ -30,7 +30,7 @@ async def reasoning_example() -> None:
|
||||
print(f"User: {query}")
|
||||
# Enable Reasoning on per request level
|
||||
result = await agent.run(query)
|
||||
reasoning = "".join(c.text for c in result.messages[-1].contents if isinstance(c, TextReasoningContent))
|
||||
reasoning = "".join((c.text or "") for c in result.messages[-1].contents if isinstance(c, TextReasoningContent))
|
||||
print(f"Reasoning: {reasoning}")
|
||||
print(f"Answer: {result}\n")
|
||||
|
||||
|
||||
@@ -12,8 +12,12 @@ The Model Context Protocol (MCP) is an open standard for connecting AI agents to
|
||||
|--------|------|-------------|
|
||||
| **Agent as MCP Server** | [`agent_as_mcp_server.py`](agent_as_mcp_server.py) | Shows how to expose an Agent Framework agent as an MCP server that other AI applications can connect to |
|
||||
| **API Key Authentication** | [`mcp_api_key_auth.py`](mcp_api_key_auth.py) | Demonstrates API key authentication with MCP servers |
|
||||
| **GitHub Integration with PAT** | [`mcp_github_pat.py`](mcp_github_pat.py) | Demonstrates connecting to GitHub's MCP server using Personal Access Token (PAT) authentication |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `OPENAI_API_KEY` environment variable
|
||||
- `OPENAI_RESPONSES_MODEL_ID` environment variable
|
||||
|
||||
For `mcp_github_pat.py`:
|
||||
- `GITHUB_PAT` - Your GitHub Personal Access Token (create at https://github.com/settings/tokens)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import ChatAgent, HostedMCPTool
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
from dotenv import load_dotenv
|
||||
|
||||
"""
|
||||
MCP GitHub Integration with Personal Access Token (PAT)
|
||||
|
||||
This example demonstrates how to connect to GitHub's remote MCP server using a Personal Access
|
||||
Token (PAT) for authentication. The agent can use GitHub operations like searching repositories,
|
||||
reading files, creating issues, and more depending on how you scope your token.
|
||||
|
||||
Prerequisites:
|
||||
1. A GitHub Personal Access Token with appropriate scopes
|
||||
- Create one at: https://github.com/settings/tokens
|
||||
- For read-only operations, you can use more restrictive scopes
|
||||
2. Environment variables:
|
||||
- GITHUB_PAT: Your GitHub Personal Access Token (required)
|
||||
- OPENAI_API_KEY: Your OpenAI API key (required)
|
||||
- OPENAI_RESPONSES_MODEL_ID: Your OpenAI model ID (required)
|
||||
"""
|
||||
|
||||
|
||||
async def github_mcp_example() -> None:
|
||||
"""Example of using GitHub MCP server with PAT authentication."""
|
||||
# 1. Load environment variables from .env file if present
|
||||
load_dotenv()
|
||||
|
||||
# 2. Get configuration from environment
|
||||
github_pat = os.getenv("GITHUB_PAT")
|
||||
if not github_pat:
|
||||
raise ValueError(
|
||||
"GITHUB_PAT environment variable must be set. Create a token at https://github.com/settings/tokens"
|
||||
)
|
||||
|
||||
# 3. Create authentication headers with GitHub PAT
|
||||
auth_headers = {
|
||||
"Authorization": f"Bearer {github_pat}",
|
||||
}
|
||||
|
||||
# 4. Create MCP tool with authentication
|
||||
# HostedMCPTool manages the connection to the MCP server and makes its tools available
|
||||
# Set approval_mode="never_require" to allow the MCP tool to execute without approval
|
||||
github_mcp_tool = HostedMCPTool(
|
||||
name="GitHub",
|
||||
description="Tool for interacting with GitHub.",
|
||||
url="https://api.githubcopilot.com/mcp/",
|
||||
headers=auth_headers,
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
# 5. Create agent with the GitHub MCP tool
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIResponsesClient(),
|
||||
name="GitHubAgent",
|
||||
instructions=(
|
||||
"You are a helpful assistant that can help users interact with GitHub. "
|
||||
"You can search for repositories, read file contents, check issues, and more. "
|
||||
"Always be clear about what operations you're performing."
|
||||
),
|
||||
tools=github_mcp_tool,
|
||||
) as agent:
|
||||
# Example 1: Get authenticated user information
|
||||
query1 = "What is my GitHub username and tell me about my account?"
|
||||
print(f"\nUser: {query1}")
|
||||
result1 = await agent.run(query1)
|
||||
print(f"Agent: {result1.text}")
|
||||
|
||||
# Example 2: List my repositories
|
||||
query2 = "List all the repositories I own on GitHub"
|
||||
print(f"\nUser: {query2}")
|
||||
result2 = await agent.run(query2)
|
||||
print(f"Agent: {result2.text}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(github_mcp_example())
|
||||
Generated
+98
-51
@@ -33,11 +33,13 @@ members = [
|
||||
"agent-framework-azure-ai",
|
||||
"agent-framework-azure-ai-search",
|
||||
"agent-framework-azurefunctions",
|
||||
"agent-framework-bedrock",
|
||||
"agent-framework-chatkit",
|
||||
"agent-framework-copilotstudio",
|
||||
"agent-framework-core",
|
||||
"agent-framework-declarative",
|
||||
"agent-framework-devui",
|
||||
"agent-framework-foundry-local",
|
||||
"agent-framework-lab",
|
||||
"agent-framework-mem0",
|
||||
"agent-framework-ollama",
|
||||
@@ -90,7 +92,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -157,7 +159,7 @@ docs = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-a2a"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
source = { editable = "packages/a2a" }
|
||||
dependencies = [
|
||||
{ name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -172,7 +174,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
source = { editable = "packages/ag-ui" }
|
||||
dependencies = [
|
||||
{ name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -202,7 +204,7 @@ provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-anthropic"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
source = { editable = "packages/anthropic" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -217,7 +219,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-azure-ai"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
source = { editable = "packages/azure-ai" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -236,7 +238,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-azure-ai-search"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
source = { editable = "packages/azure-ai-search" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -251,7 +253,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-azurefunctions"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
source = { editable = "packages/azurefunctions" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -274,9 +276,26 @@ requires-dist = [
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "types-python-dateutil", specifier = ">=2.9.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-bedrock"
|
||||
version = "1.0.0b251120"
|
||||
source = { editable = "packages/bedrock" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "boto3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-framework-core", editable = "packages/core" },
|
||||
{ name = "boto3", specifier = ">=1.35.0,<2.0.0" },
|
||||
{ name = "botocore", specifier = ">=1.35.0,<2.0.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-chatkit"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
source = { editable = "packages/chatkit" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -291,7 +310,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-copilotstudio"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
source = { editable = "packages/copilotstudio" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -306,7 +325,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-core"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
source = { editable = "packages/core" }
|
||||
dependencies = [
|
||||
{ name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -372,7 +391,7 @@ provides-extras = ["all"]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-declarative"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
source = { editable = "packages/declarative" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -397,7 +416,7 @@ dev = [{ name = "types-pyyaml" }]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-devui"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
source = { editable = "packages/devui" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -429,9 +448,24 @@ requires-dist = [
|
||||
]
|
||||
provides-extras = ["dev", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-foundry-local"
|
||||
version = "1.0.0b251223"
|
||||
source = { editable = "packages/foundry_local" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "foundry-local-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-framework-core", editable = "packages/core" },
|
||||
{ name = "foundry-local-sdk", specifier = ">=0.5.1,<1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-lab"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
source = { editable = "packages/lab" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -522,7 +556,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-mem0"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
source = { editable = "packages/mem0" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -537,7 +571,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-ollama"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
source = { editable = "packages/ollama" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -552,7 +586,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-purview"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
source = { editable = "packages/purview" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -569,7 +603,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-redis"
|
||||
version = "1.0.0b251216"
|
||||
version = "1.0.0b251223"
|
||||
source = { editable = "packages/redis" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -1343,7 +1377,7 @@ name = "clr-loader"
|
||||
version = "0.2.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/54/c2/da52aaf19424e3f0abec003d08dd1ccae52c88a3b41e31151a03bed18488/clr_loader-0.2.9.tar.gz", hash = "sha256:6af3d582c3de55ce9e9e676d2b3dbf6bc680c4ea8f76c58786739a5bdcf6b52d", size = 84829, upload-time = "2025-12-05T16:57:12.466Z" }
|
||||
wheels = [
|
||||
@@ -1822,7 +1856,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
|
||||
{ name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
@@ -1840,7 +1874,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.124.4"
|
||||
version = "0.125.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -1848,9 +1882,9 @@ dependencies = [
|
||||
{ name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/21/ade3ff6745a82ea8ad88552b4139d27941549e4f19125879f848ac8f3c3d/fastapi-0.124.4.tar.gz", hash = "sha256:0e9422e8d6b797515f33f500309f6e1c98ee4e85563ba0f2debb282df6343763", size = 378460, upload-time = "2025-12-12T15:00:43.891Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/17/71/2df15009fb4bdd522a069d2fbca6007c6c5487fce5cb965be00fc335f1d1/fastapi-0.125.0.tar.gz", hash = "sha256:16b532691a33e2c5dee1dac32feb31dc6eb41a3dd4ff29a95f9487cb21c054c0", size = 370550, upload-time = "2025-12-17T21:41:44.15Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/57/aa70121b5008f44031be645a61a7c4abc24e0e888ad3fc8fda916f4d188e/fastapi-0.124.4-py3-none-any.whl", hash = "sha256:6d1e703698443ccb89e50abe4893f3c84d9d6689c0cf1ca4fad6d3c15cf69f15", size = 113281, upload-time = "2025-12-12T15:00:42.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/2f/ff2fcc98f500713368d8b650e1bbc4a0b3ebcdd3e050dcdaad5f5a13fd7e/fastapi-0.125.0-py3-none-any.whl", hash = "sha256:2570ec4f3aecf5cca8f0428aed2398b774fcdfee6c2116f86e80513f2f86a7a1", size = 112888, upload-time = "2025-12-17T21:41:41.286Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2039,6 +2073,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foundry-local-sdk"
|
||||
version = "0.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/6b/76a7fe8f9f4c52cc84eaa1cd1b66acddf993496d55d6ea587bf0d0854d1c/foundry_local_sdk-0.5.1-py3-none-any.whl", hash = "sha256:f3639a3666bc3a94410004a91671338910ac2e1b8094b1587cc4db0f4a7df07e", size = 14003, upload-time = "2025-11-21T05:39:58.099Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "frozenlist"
|
||||
version = "1.8.0"
|
||||
@@ -2942,7 +2989,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langfuse"
|
||||
version = "3.10.7"
|
||||
version = "3.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -2956,9 +3003,9 @@ dependencies = [
|
||||
{ name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/62/f46319500aff363bedf5dbbcb3afa0fdd5788c6faf901eee8fce27f9643c/langfuse-3.10.7.tar.gz", hash = "sha256:64eaec6923e6c61baa62b18516f5f37c011d55caa409b2214c1819fe01cd1056", size = 223808, upload-time = "2025-12-16T15:36:55.959Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/6d/f381dd6f89f95dccbe194dcf3203a964a8fb6e17a681356da6d85e5de5aa/langfuse-3.11.0.tar.gz", hash = "sha256:61b0a5e67512c7521a113bbcd2536b566b5517ab0e9933899de5214ad3851364", size = 224002, upload-time = "2025-12-17T10:29:29.517Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/10/67fd830dfd4ab33f66a60d6b61395601b478dec78e15065d4d6fb4d74610/langfuse-3.10.7-py3-none-any.whl", hash = "sha256:206dabd786ca64c403b5552488515ff08d4b2d55cebf00255ed1ae3c59794d17", size = 399345, upload-time = "2025-12-16T15:36:54.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/06/0946bca555a0e8f74393d08d4d37c0b1aab21cbc5109ec96074f1a7690ec/langfuse-3.11.0-py3-none-any.whl", hash = "sha256:a11322d49c674ea9ca7951dd02b0e8b73b5cfd158c94d8d0f220546529b8986f", size = 399616, upload-time = "2025-12-17T10:29:27.681Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3902,7 +3949,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openai-chatkit"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -3911,9 +3958,9 @@ dependencies = [
|
||||
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c5/89/bf2f094997c8e5cad5334e8a02e05fc458823e65fb2675f45b56b6d1ab73/openai_chatkit-1.4.0.tar.gz", hash = "sha256:e2527dffc3794a05596ad75efa66bdc4efb4ded5a77a013a55496cc989bcf2e6", size = 55269, upload-time = "2025-11-25T21:02:58.503Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/44/f6dc99c00343bc4b2e3a618d0f4d90ede105c297a2dc82e1eb8e39658a52/openai_chatkit-1.4.1.tar.gz", hash = "sha256:871212dce80b4c774dbb10e2c2ee11ecd13a2c8d86e95c791110a1f6c860138d", size = 57954, upload-time = "2025-12-18T23:44:05.117Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/90/bf/68d42561dd8a674b6f8541d879dd165b5ac4d81fcf1027462e154de66a4f/openai_chatkit-1.4.0-py3-none-any.whl", hash = "sha256:35d00ca8398908bd70d63e2284adcd836641cc11746f68d7cfa91d276e3dad3d", size = 39077, upload-time = "2025-11-25T21:02:57.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/d7/2ba7198ebfa2a31b35b5253827f15290479b4377d4518b78173621467af4/openai_chatkit-1.4.1-py3-none-any.whl", hash = "sha256:b9e4d3c8ba708ad66a3ba577a04c1e154b1a27ab454ee312c90d886b7c61f34c", size = 41150, upload-time = "2025-12-18T23:44:03.784Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4475,8 +4522,8 @@ name = "powerfx"
|
||||
version = "0.0.33"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pythonnet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
|
||||
{ name = "pythonnet", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/41/8f95f72f4f3b7ea54357c449bf5bd94813b6321dec31db9ffcbf578e2fa3/powerfx-0.0.33.tar.gz", hash = "sha256:85e8330bef8a7a207c3e010aa232df0ae38825e94d590c73daf3a3f44115cb09", size = 3236647, upload-time = "2025-11-20T19:31:09.414Z" }
|
||||
wheels = [
|
||||
@@ -5145,7 +5192,7 @@ name = "pythonnet"
|
||||
version = "3.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "clr-loader", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "clr-loader", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" }
|
||||
wheels = [
|
||||
@@ -5598,28 +5645,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.14.9"
|
||||
version = "0.14.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/1b/ab712a9d5044435be8e9a2beb17cbfa4c241aa9b5e4413febac2a8b79ef2/ruff-0.14.9.tar.gz", hash = "sha256:35f85b25dd586381c0cc053f48826109384c81c00ad7ef1bd977bfcc28119d5b", size = 5809165, upload-time = "2025-12-11T21:39:47.381Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/1c/d1b1bba22cffec02351c78ab9ed4f7d7391876e12720298448b29b7229c1/ruff-0.14.9-py3-none-linux_armv6l.whl", hash = "sha256:f1ec5de1ce150ca6e43691f4a9ef5c04574ad9ca35c8b3b0e18877314aba7e75", size = 13576541, upload-time = "2025-12-11T21:39:14.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/ab/ffe580e6ea1fca67f6337b0af59fc7e683344a43642d2d55d251ff83ceae/ruff-0.14.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ed9d7417a299fc6030b4f26333bf1117ed82a61ea91238558c0268c14e00d0c2", size = 13779363, upload-time = "2025-12-11T21:39:20.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/f8/2be49047f929d6965401855461e697ab185e1a6a683d914c5c19c7962d9e/ruff-0.14.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d5dc3473c3f0e4a1008d0ef1d75cee24a48e254c8bed3a7afdd2b4392657ed2c", size = 12925292, upload-time = "2025-12-11T21:39:38.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/e9/08840ff5127916bb989c86f18924fd568938b06f58b60e206176f327c0fe/ruff-0.14.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84bf7c698fc8f3cb8278830fb6b5a47f9bcc1ed8cb4f689b9dd02698fa840697", size = 13362894, upload-time = "2025-12-11T21:39:02.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/1c/5b4e8e7750613ef43390bb58658eaf1d862c0cc3352d139cd718a2cea164/ruff-0.14.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aa733093d1f9d88a5d98988d8834ef5d6f9828d03743bf5e338bf980a19fce27", size = 13311482, upload-time = "2025-12-11T21:39:17.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/3a/459dce7a8cb35ba1ea3e9c88f19077667a7977234f3b5ab197fad240b404/ruff-0.14.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a1cfb04eda979b20c8c19550c8b5f498df64ff8da151283311ce3199e8b3648", size = 14016100, upload-time = "2025-12-11T21:39:41.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/31/f064f4ec32524f9956a0890fc6a944e5cf06c63c554e39957d208c0ffc45/ruff-0.14.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1e5cb521e5ccf0008bd74d5595a4580313844a42b9103b7388eca5a12c970743", size = 15477729, upload-time = "2025-12-11T21:39:23.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/6d/f364252aad36ccd443494bc5f02e41bf677f964b58902a17c0b16c53d890/ruff-0.14.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd429a8926be6bba4befa8cdcf3f4dd2591c413ea5066b1e99155ed245ae42bb", size = 15122386, upload-time = "2025-12-11T21:39:33.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/02/e848787912d16209aba2799a4d5a1775660b6a3d0ab3944a4ccc13e64a02/ruff-0.14.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab208c1b7a492e37caeaf290b1378148f75e13c2225af5d44628b95fd7834273", size = 14497124, upload-time = "2025-12-11T21:38:59.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/51/0489a6a5595b7760b5dbac0dd82852b510326e7d88d51dbffcd2e07e3ff3/ruff-0.14.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72034534e5b11e8a593f517b2f2f2b273eb68a30978c6a2d40473ad0aaa4cb4a", size = 14195343, upload-time = "2025-12-11T21:39:44.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/53/3bb8d2fa73e4c2f80acc65213ee0830fa0c49c6479313f7a68a00f39e208/ruff-0.14.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:712ff04f44663f1b90a1195f51525836e3413c8a773574a7b7775554269c30ed", size = 14346425, upload-time = "2025-12-11T21:39:05.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/04/bdb1d0ab876372da3e983896481760867fc84f969c5c09d428e8f01b557f/ruff-0.14.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a111fee1db6f1d5d5810245295527cda1d367c5aa8f42e0fca9a78ede9b4498b", size = 13258768, upload-time = "2025-12-11T21:39:08.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/d9/8bf8e1e41a311afd2abc8ad12be1b6c6c8b925506d9069b67bb5e9a04af3/ruff-0.14.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8769efc71558fecc25eb295ddec7d1030d41a51e9dcf127cbd63ec517f22d567", size = 13326939, upload-time = "2025-12-11T21:39:53.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/56/a213fa9edb6dd849f1cfbc236206ead10913693c72a67fb7ddc1833bf95d/ruff-0.14.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:347e3bf16197e8a2de17940cd75fd6491e25c0aa7edf7d61aa03f146a1aa885a", size = 13578888, upload-time = "2025-12-11T21:39:35.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/09/6a4a67ffa4abae6bf44c972a4521337ffce9cbc7808faadede754ef7a79c/ruff-0.14.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7715d14e5bccf5b660f54516558aa94781d3eb0838f8e706fb60e3ff6eff03a8", size = 14314473, upload-time = "2025-12-11T21:39:50.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/0d/15cc82da5d83f27a3c6b04f3a232d61bc8c50d38a6cd8da79228e5f8b8d6/ruff-0.14.9-py3-none-win32.whl", hash = "sha256:df0937f30aaabe83da172adaf8937003ff28172f59ca9f17883b4213783df197", size = 13202651, upload-time = "2025-12-11T21:39:26.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/f7/c78b060388eefe0304d9d42e68fab8cffd049128ec466456cef9b8d4f06f/ruff-0.14.9-py3-none-win_amd64.whl", hash = "sha256:c0b53a10e61df15a42ed711ec0bda0c582039cf6c754c49c020084c55b5b0bc2", size = 14702079, upload-time = "2025-12-11T21:39:11.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/09/7a9520315decd2334afa65ed258fed438f070e31f05a2e43dd480a5e5911/ruff-0.14.9-py3-none-win_arm64.whl", hash = "sha256:8e821c366517a074046d92f0e9213ed1c13dbc5b37a7fc20b07f79b64d62cc84", size = 13744730, upload-time = "2025-12-11T21:39:29.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user