Compare commits

..
Author SHA1 Message Date
Eduard van ValkenburgandGitHub 4b8a545589 Python: add powerfx safe mode (#3028)
* add powerfx safe mode

* improved docstring and aligned env_file loading

* ensured test uses reset
2025-12-23 20:12:50 +00:00
Dmytro StrukandGitHub 5ab47596ff Python: Updated package versions (#3024)
* Updated package versions

* Updated changelog
2025-12-23 16:04:53 +00:00
Eduard van ValkenburgandGitHub a32702cf38 Python: latency improvements (#3014)
* latency improvements

* fixed mypy, added coding standards and instructions

* slight logic improvement
2025-12-23 16:04:34 +00:00
8b743af217 Fix typo in README.md about agent definitions (#2634)
* Fix typo in README.md about agent definitions

* Update agent-samples/README.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-22 14:39:52 +00:00
Chris GillumandGitHub 0e152a0e33 .NET: [Durable Agents] Reliable streaming sample (#2942)
* .NET: [Durable Agents] Reliable streaming sample

* Add automated validation for new sample

* Address Copilot PR feedback
2025-12-19 23:43:36 +00:00
3b77192ad0 Python: Introducing support for Bedrock-hosted models (Anthropic, Cohere, etc.) (#2610)
* Pushing the bedrock related changes to the new branch after addressing the review comments

* 2524 Addressed the second round review comments

* 2524 Addressed few more minor comments on the PR

* resolving the merge conflict

* 2524 resolved the uv.lock conflicts

* 2524 addressed more comments

* 2524 removed the print statement to fix the checks failure

* 2524 resolved the CI failure issues

* 2524 fixing the CI breaks

* 2524 Addressed the review comment

* 2524 resolved conflict

---------

Co-authored-by: Sunil Dutta <sunil.dutta@penske.com>
Co-authored-by: budgetboardingai <apurva.sharma31@gmail.com>
2025-12-19 18:35:53 +00:00
Hao LuoandGitHub defe0f1a89 Python: Added response.created and response.in_progress event process to OpenAIBaseResponseClient (#2975)
* added response.created and response.in_progress to include response.id

* better doc string

* added tests for the new streaming event types
2025-12-19 17:50:15 +00:00
SuperKenVeryandGitHub 85d70f01f6 Python: Preserve reasoning blocks with OpenRouter (#2950)
* Preserve reasoning blocks with OpenRouter

* Put encrypted reasoning in TextReasoningContent

* Remove unneccessary change

* Fix docs

* Support streaming

* Fix handling None in TextReasoningContent.text
2025-12-19 17:03:19 +00:00
Giles OdigweandGitHub 6930c0f0b6 Python: Added GitHub MCP sample with PAT (#2967)
* added github mcp sample with PAT

* addressed copilot fixes

* env fix
2025-12-19 16:46:12 +00:00
Dmytro StrukandGitHub d83cf93f07 Updated package versions (#2978) 2025-12-19 16:16:49 +00:00
Eduard van ValkenburgandGitHub 8783ac58f1 Python: Introducing Foundry Local Chat Clients (#2915)
* redo foundry local chat client

* fix mypy and spelling

* better docstring, updated sample

* fixed tests and added tests

* small sample update
2025-12-19 16:05:55 +00:00
Evan MattsonandGitHub e15eab7da6 Python: Bump Py version to 1.0.0b251218 for a release. Update CHANGELOG (#2968)
* Bump Py version to 1.0.0b251218 for a release. Update CHANGELOG

* update lock

* Fix formatting

* Fix ChatKit typing
2025-12-19 01:31:57 +00:00
Jacob ViauandGitHub 19a9e13788 .NET: Use GrpcEntityRunner instead of TaskEntityDispatcher (#2759)
* Use GrpcEntityRunner instead of TaskEntityDispatcher

* Pin to Durable worker 1.11.0

* Set the invocation result

* Update all Durable packages

* Update changelog, rename dispatcher to encondedEntityRequest
2025-12-19 00:55:33 +00:00
78 changed files with 4039 additions and 536 deletions
@@ -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: |
+1 -1
View File
@@ -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 -1
View File
@@ -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/).
+8 -6
View File
@@ -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" />
+1
View File
@@ -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" />
+3 -3
View File
@@ -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"
}
}
+1
View File
@@ -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))
@@ -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,
@@ -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 };
+5
View File
@@ -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
View File
@@ -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
+402
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -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"]
+1 -1
View File
@@ -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"
+8 -1
View File
@@ -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"
+21
View File
@@ -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
+19
View File
@@ -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
+90
View File
@@ -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)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
+51 -22
View File
@@ -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,
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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"
+21
View File
@@ -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
+9
View File
@@ -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,
)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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 -9
View File
@@ -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'] }]
+1
View File
@@ -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())
+98 -51
View File
@@ -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]]