Compare commits

..
Author SHA1 Message Date
Evan MattsonandGitHub d81b579111 Python: Bump ag-ui package to 1.0.0b251105 for a release. Update changelog. (#1922)
* Bump ag-ui package to 1.0.0b251105 for a release. Update changelog.

* Fix authors and license-files
2025-11-05 08:30:52 +00:00
Evan MattsonandGitHub 35a8565495 Python: AG-UI protocol support (#1826)
* Add AG-UI integration

* Fix tests. PR feedback

* Cleanup

* PR Feedback

* Improve README and getting started experience

* Fix links
2025-11-05 05:25:24 +00:00
0c862e97a6 Python: feat: Add ChatKit integration with a sample application (#1273)
* feat: Add ChatKit integration with a new frontend application

- Created a new frontend application using React and Vite for the ChatKit integration.
- Added essential files including package.json, vite.config.ts, and Tailwind CSS configuration.
- Implemented core components: App, Home, ChatKitPanel, ThemeToggle, and hooks for color scheme management.
- Established SQLite-based store implementation for ChatKit data persistence in store.py.
- Integrated theme toggling functionality for light and dark modes.
- Set up ESLint and TypeScript configurations for better development experience.

* git ignore

* fix mypy

* add mising file

* minimal frontend for chatkit sample

* update ignore files

* version

* set python version lowerbound on chatkit

* update project settings for chatkit

* update setup

* update setup

* update setup

* update setup

* weather widget

* add select city widget sample

* remove widget helper

* update chatkit to include file attachments and cover more thread item types

* update readme with mermaid diagram

* update diagram

* update instructions

* update chatkit dependency

* fix converter imports

* move to demos/

* move to demos/ -- rename references

* support multiple session instead of using global variable in sample

* support chunk streaming

* fix tests

* Update python/samples/demos/chatkit-integration/store.py

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>

* use local host

---------

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2025-11-05 02:11:40 +00:00
Peter IbekweandGitHub bbde248839 .NET: Add unit tests for CreateConversation executor (#1915)
* Add unit tests for create conversation executor

* Update indentation and comment typo.
2025-11-05 01:40:10 +00:00
Tao ChenandGitHub 552f7c781d .NET: Make sure Workflow activities are as expected (#1903)
* Make sure Workflow activities are as expected

* misc

* Copliot comments

* Fix unit tests

* Improve test stability

* Fix unit tests

* Fix formatting
2025-11-05 00:37:31 +00:00
Evan MattsonandGitHub 2499262f30 Add orchestration samples link (#1914) 2025-11-05 00:30:56 +00:00
f415959d33 .NET: Add Writer-Critic Iterative Refinement Workflow Sample (#1790)
* Adding Sample for writer-critic workflow implemented using Worfklow, custom executors, agents, switch, custom states, different entry points for the executors.

* Update dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs

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

* Update dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs

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

* using now structured output, with streaming for UX responsiveness.

* improved comments and order, so comments directly precede what they're describing

* fixing issue with internal class that the analyzer doesn't recognize that CriticDecision is instantiated, just indirectly via JSON deserialization

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-11-04 23:50:41 +00:00
92 changed files with 13416 additions and 168 deletions
+9 -1
View File
@@ -203,4 +203,12 @@ agents.md
# AI
.claude/
WARP.md
WARP.md
# Frontend
**/frontend/node_modules/
**/frontend/.vite/
**/frontend/dist/
# Database files
*.db
+1
View File
@@ -143,6 +143,7 @@
<Project Path="samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/05_MultiModelService.csproj" />
<Project Path="samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/06_SubWorkflows.csproj" />
<Project Path="samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/07_MixedWorkflowAgentsAndExecutors.csproj" />
<Project Path="samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj" />
</Folder>
<Folder Name="/Samples/Catalog/">
<Project Path="samples/Catalog/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
@@ -19,6 +19,7 @@ Please begin with the [Foundational](./_Foundational) samples in order. These th
| [Multi-Service Workflows](./_Foundational/05_MultiModelService) | Shows using multiple AI services in the same workflow |
| [Sub-Workflows](./_Foundational/06_SubWorkflows) | Demonstrates composing workflows hierarchically by embedding workflows as executors |
| [Mixed Workflow with Agents and Executors](./_Foundational/07_MixedWorkflowAgentsAndExecutors) | Shows how to mix agents and executors with adapter pattern for type conversion and protocol handling |
| [Writer-Critic Workflow](./_Foundational/08_WriterCriticWorkflow) | Demonstrates iterative refinement with quality gates, max iteration safety, multiple message handlers, and conditional routing for feedback loops |
Once completed, please proceed to other samples listed below.
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>WriterCriticWorkflow</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
</Project>
@@ -0,0 +1,409 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
namespace WriterCriticWorkflow;
/// <summary>
/// This sample demonstrates an iterative refinement workflow between Writer and Critic agents.
///
/// The workflow implements a content creation and review loop that:
/// 1. Writer creates initial content based on the user's request
/// 2. Critic reviews the content and provides feedback using structured output
/// 3. If approved: Summary executor presents the final content
/// 4. If rejected: Writer revises based on feedback (loops back)
/// 5. Continues until approval or max iterations (3) is reached
///
/// This pattern is useful when you need:
/// - Iterative content improvement through feedback loops
/// - Quality gates with reviewer approval
/// - Maximum iteration limits to prevent infinite loops
/// - Conditional workflow routing based on agent decisions
/// - Structured output for reliable decision-making
///
/// Key Learning: Workflows can implement loops with conditional edges, shared state,
/// and structured output for robust agent decision-making.
/// </summary>
/// <remarks>
/// Pre-requisites:
/// - Previous foundational samples should be completed first.
/// - An Azure OpenAI chat completion deployment must be configured.
/// </remarks>
public static class Program
{
public const int MaxIterations = 3;
private static async Task Main()
{
Console.WriteLine("\n=== Writer-Critic Iteration Workflow ===\n");
Console.WriteLine($"Writer and Critic will iterate up to {MaxIterations} times until approval.\n");
// Set up the Azure OpenAI client
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient();
// Create executors for content creation and review
WriterExecutor writer = new(chatClient);
CriticExecutor critic = new(chatClient);
SummaryExecutor summary = new(chatClient);
// Build the workflow with conditional routing based on critic's decision
WorkflowBuilder workflowBuilder = new WorkflowBuilder(writer)
.AddEdge(writer, critic)
.AddSwitch(critic, sw => sw
.AddCase<CriticDecision>(cd => cd?.Approved == true, summary)
.AddCase<CriticDecision>(cd => cd?.Approved == false, writer))
.WithOutputFrom(summary);
// Execute the workflow with a sample task
// The workflow loops back to Writer if content is rejected,
// or proceeds to Summary if approved. State tracking ensures we don't loop forever.
Console.WriteLine(new string('=', 80));
Console.WriteLine("TASK: Write a short blog post about AI ethics (200 words)");
Console.WriteLine(new string('=', 80) + "\n");
const string InitialTask = "Write a 200-word blog post about AI ethics. Make it thoughtful and engaging.";
Workflow workflow = workflowBuilder.Build();
await ExecuteWorkflowAsync(workflow, InitialTask);
Console.WriteLine("\nâś… Sample Complete: Writer-Critic iteration demonstrates conditional workflow loops\n");
Console.WriteLine("Key Concepts Demonstrated:");
Console.WriteLine(" âś“ Iterative refinement loop with conditional routing");
Console.WriteLine(" âś“ Shared workflow state for iteration tracking");
Console.WriteLine($" âś“ Max iteration cap ({MaxIterations}) for safety");
Console.WriteLine(" âś“ Multiple message handlers in a single executor");
Console.WriteLine(" âś“ Streaming support with structured output\n");
}
private static async Task ExecuteWorkflowAsync(Workflow workflow, string input)
{
// Execute in streaming mode to see real-time progress
await using StreamingRun run = await InProcessExecution.StreamAsync<string>(workflow, input);
// Watch the workflow events
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
switch (evt)
{
case AgentRunUpdateEvent agentUpdate:
// Stream agent output in real-time
if (!string.IsNullOrEmpty(agentUpdate.Update.Text))
{
Console.Write(agentUpdate.Update.Text);
}
break;
case WorkflowOutputEvent output:
Console.WriteLine("\n\n" + new string('=', 80));
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("âś… FINAL APPROVED CONTENT");
Console.ResetColor();
Console.WriteLine(new string('=', 80));
Console.WriteLine();
Console.WriteLine(output.Data);
Console.WriteLine();
Console.WriteLine(new string('=', 80));
break;
}
}
}
}
// ====================================
// Shared State for Iteration Tracking
// ====================================
/// <summary>
/// Tracks the current iteration and conversation history across workflow executions.
/// </summary>
internal sealed class FlowState
{
public int Iteration { get; set; } = 1;
public List<ChatMessage> History { get; } = [];
}
/// <summary>
/// Constants for accessing the shared flow state in workflow context.
/// </summary>
internal static class FlowStateShared
{
public const string Scope = "FlowStateScope";
public const string Key = "singleton";
}
/// <summary>
/// Helper methods for reading and writing shared flow state.
/// </summary>
internal static class FlowStateHelpers
{
public static async Task<FlowState> ReadFlowStateAsync(IWorkflowContext context)
{
FlowState? state = await context.ReadStateAsync<FlowState>(FlowStateShared.Key, scopeName: FlowStateShared.Scope);
return state ?? new FlowState();
}
public static ValueTask SaveFlowStateAsync(IWorkflowContext context, FlowState state)
=> context.QueueStateUpdateAsync(FlowStateShared.Key, state, scopeName: FlowStateShared.Scope);
}
// ====================================
// Data Transfer Objects
// ====================================
/// <summary>
/// Structured output schema for the Critic's decision.
/// Uses JsonPropertyName and Description attributes for OpenAI's JSON schema.
/// </summary>
[Description("Critic's review decision including approval status and feedback")]
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via JSON deserialization")]
internal sealed class CriticDecision
{
[JsonPropertyName("approved")]
[Description("Whether the content is approved (true) or needs revision (false)")]
public bool Approved { get; set; }
[JsonPropertyName("feedback")]
[Description("Specific feedback for improvements if not approved, empty if approved")]
public string Feedback { get; set; } = "";
// Non-JSON properties for workflow use
[JsonIgnore]
public string Content { get; set; } = "";
[JsonIgnore]
public int Iteration { get; set; }
}
// ====================================
// Custom Executors
// ====================================
/// <summary>
/// Executor that creates or revises content based on user requests or critic feedback.
/// This executor demonstrates multiple message handlers for different input types.
/// </summary>
internal sealed class WriterExecutor : Executor
{
private readonly AIAgent _agent;
public WriterExecutor(IChatClient chatClient) : base("Writer")
{
this._agent = new ChatClientAgent(
chatClient,
name: "Writer",
instructions: """
You are a skilled writer. Create clear, engaging content.
If you receive feedback, carefully revise the content to address all concerns.
Maintain the same topic and length requirements.
"""
);
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder
.AddHandler<string, ChatMessage>(this.HandleInitialRequestAsync)
.AddHandler<CriticDecision, ChatMessage>(this.HandleRevisionRequestAsync);
/// <summary>
/// Handles the initial writing request from the user.
/// </summary>
private async ValueTask<ChatMessage> HandleInitialRequestAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
return await this.HandleAsyncCoreAsync(new ChatMessage(ChatRole.User, message), context, cancellationToken);
}
/// <summary>
/// Handles revision requests from the critic with feedback.
/// </summary>
private async ValueTask<ChatMessage> HandleRevisionRequestAsync(
CriticDecision decision,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
string prompt = "Revise the following content based on this feedback:\n\n" +
$"Feedback: {decision.Feedback}\n\n" +
$"Original Content:\n{decision.Content}";
return await this.HandleAsyncCoreAsync(new ChatMessage(ChatRole.User, prompt), context, cancellationToken);
}
/// <summary>
/// Core implementation for generating content (initial or revised).
/// </summary>
private async Task<ChatMessage> HandleAsyncCoreAsync(
ChatMessage message,
IWorkflowContext context,
CancellationToken cancellationToken)
{
FlowState state = await FlowStateHelpers.ReadFlowStateAsync(context);
Console.WriteLine($"\n=== Writer (Iteration {state.Iteration}) ===\n");
StringBuilder sb = new();
await foreach (AgentRunResponseUpdate update in this._agent.RunStreamingAsync(message, cancellationToken: cancellationToken))
{
if (!string.IsNullOrEmpty(update.Text))
{
sb.Append(update.Text);
Console.Write(update.Text);
}
}
Console.WriteLine("\n");
string text = sb.ToString();
state.History.Add(new ChatMessage(ChatRole.Assistant, text));
await FlowStateHelpers.SaveFlowStateAsync(context, state);
return new ChatMessage(ChatRole.User, text);
}
}
/// <summary>
/// Executor that reviews content and decides whether to approve or request revisions.
/// Uses structured output with streaming for reliable decision-making.
/// </summary>
internal sealed class CriticExecutor : Executor<ChatMessage, CriticDecision>
{
private readonly AIAgent _agent;
public CriticExecutor(IChatClient chatClient) : base("Critic")
{
this._agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
{
Name = "Critic",
Instructions = """
You are a constructive critic. Review the content and provide specific feedback.
Always try to provide actionable suggestions for improvement and strive to identify improvement points.
Only approve if the content is high quality, clear, and meets the original requirements and you see no improvement points.
Provide your decision as structured output with:
- approved: true if content is good, false if revisions needed
- feedback: specific improvements needed (empty if approved)
Be concise but specific in your feedback.
""",
ChatOptions = new()
{
ResponseFormat = ChatResponseFormat.ForJsonSchema<CriticDecision>()
}
});
}
public override async ValueTask<CriticDecision> HandleAsync(
ChatMessage message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
FlowState state = await FlowStateHelpers.ReadFlowStateAsync(context);
Console.WriteLine($"=== Critic (Iteration {state.Iteration}) ===\n");
// Use RunStreamingAsync to get streaming updates, then deserialize at the end
IAsyncEnumerable<AgentRunResponseUpdate> updates = this._agent.RunStreamingAsync(message, cancellationToken: cancellationToken);
// Stream the output in real-time (for any rationale/explanation)
await foreach (AgentRunResponseUpdate update in updates)
{
if (!string.IsNullOrEmpty(update.Text))
{
Console.Write(update.Text);
}
}
Console.WriteLine("\n");
// Convert the stream to a response and deserialize the structured output
AgentRunResponse response = await updates.ToAgentRunResponseAsync(cancellationToken);
CriticDecision decision = response.Deserialize<CriticDecision>(JsonSerializerOptions.Web);
Console.WriteLine($"Decision: {(decision.Approved ? "✅ APPROVED" : "❌ NEEDS REVISION")}");
if (!string.IsNullOrEmpty(decision.Feedback))
{
Console.WriteLine($"Feedback: {decision.Feedback}");
}
Console.WriteLine();
// Safety: approve if max iterations reached
if (!decision.Approved && state.Iteration >= Program.MaxIterations)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"⚠️ Max iterations ({Program.MaxIterations}) reached - auto-approving");
Console.ResetColor();
decision.Approved = true;
decision.Feedback = "";
}
// Increment iteration ONLY if rejecting (will loop back to Writer)
if (!decision.Approved)
{
state.Iteration++;
}
// Store the decision in history
state.History.Add(new ChatMessage(ChatRole.Assistant,
$"[Decision: {(decision.Approved ? "Approved" : "Needs Revision")}] {decision.Feedback}"));
await FlowStateHelpers.SaveFlowStateAsync(context, state);
// Populate workflow-specific fields
decision.Content = message.Text ?? "";
decision.Iteration = state.Iteration;
return decision;
}
}
/// <summary>
/// Executor that presents the final approved content to the user.
/// </summary>
internal sealed class SummaryExecutor : Executor<CriticDecision, ChatMessage>
{
private readonly AIAgent _agent;
public SummaryExecutor(IChatClient chatClient) : base("Summary")
{
this._agent = new ChatClientAgent(
chatClient,
name: "Summary",
instructions: """
You present the final approved content to the user.
Simply output the polished content - no additional commentary needed.
"""
);
}
public override async ValueTask<ChatMessage> HandleAsync(
CriticDecision message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine("=== Summary ===\n");
string prompt = $"Present this approved content:\n\n{message.Content}";
StringBuilder sb = new();
await foreach (AgentRunResponseUpdate update in this._agent.RunStreamingAsync(new ChatMessage(ChatRole.User, prompt), cancellationToken: cancellationToken))
{
if (!string.IsNullOrEmpty(update.Text))
{
sb.Append(update.Text);
}
}
ChatMessage result = new(ChatRole.Assistant, sb.ToString());
await context.YieldOutputAsync(result, cancellationToken);
return result;
}
}
@@ -2,10 +2,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Observability;
namespace Microsoft.Agents.AI.Workflows.Execution;
@@ -15,6 +17,9 @@ namespace Microsoft.Agents.AI.Workflows.Execution;
/// </summary>
internal sealed class StreamingRunEventStream : IRunEventStream
{
private static readonly string s_namespace = typeof(StreamingRunEventStream).Namespace!;
private static readonly ActivitySource s_activitySource = new(s_namespace);
private readonly Channel<WorkflowEvent> _eventChannel;
private readonly ISuperStepRunner _stepRunner;
private readonly InputWaiter _inputWaiter;
@@ -58,6 +63,9 @@ internal sealed class StreamingRunEventStream : IRunEventStream
// Subscribe to events - they will flow directly to the channel as they're raised
this._stepRunner.OutgoingEvents.EventRaised += OnEventRaisedAsync;
using Activity? activity = s_activitySource.StartActivity(ActivityNames.WorkflowRun);
activity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.RunId, this._stepRunner.RunId);
try
{
// Wait for the first input before starting
@@ -65,6 +73,7 @@ internal sealed class StreamingRunEventStream : IRunEventStream
await this._inputWaiter.WaitForInputAsync(cancellationToken: linkedSource.Token).ConfigureAwait(false);
this._runStatus = RunStatus.Running;
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
while (!linkedSource.Token.IsCancellationRequested)
{
@@ -99,9 +108,17 @@ internal sealed class StreamingRunEventStream : IRunEventStream
{
// Expected during shutdown
}
catch (Exception e)
catch (Exception ex)
{
await this._eventChannel.Writer.WriteAsync(new WorkflowErrorEvent(e), linkedSource.Token).ConfigureAwait(false);
if (activity != null)
{
activity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
{ Tags.ErrorType, ex.GetType().FullName },
{ Tags.BuildErrorMessage, ex.Message },
}));
activity.CaptureException(ex);
}
await this._eventChannel.Writer.WriteAsync(new WorkflowErrorEvent(ex), linkedSource.Token).ConfigureAwait(false);
}
finally
{
@@ -110,6 +127,7 @@ internal sealed class StreamingRunEventStream : IRunEventStream
// Mark as ended when run loop exits
this._runStatus = RunStatus.Ended;
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
}
async ValueTask OnEventRaisedAsync(object? sender, WorkflowEvent e)
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
/// <summary>
/// Mock implementation of <see cref="WorkflowAgentProvider"/> for unit testing purposes.
/// </summary>
internal sealed class MockAgentProvider : Mock<WorkflowAgentProvider>
{
public IList<string> ExistingConversationIds { get; } = [];
public MockAgentProvider()
{
this.Setup(provider => provider.CreateConversationAsync(It.IsAny<CancellationToken>()))
.Returns(() => Task.FromResult(this.CreateConversationId()));
}
private string CreateConversationId()
{
string newConversationId = Guid.NewGuid().ToString("N");
this.ExistingConversationIds.Add(newConversationId);
return newConversationId;
}
}
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Bot.ObjectModel;
using Microsoft.PowerFx.Types;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="CreateConversationExecutor "/>.
/// </summary>
public sealed class CreateConversationExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task CreateNewConversationAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(nameof(CreateNewConversationAsync),
"TestConversationId",
executionIteration: 1);
}
[Fact]
public async Task CreateMultipleConversationsAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(nameof(CreateMultipleConversationsAsync),
"TestConversationId",
executionIteration: 4);
}
private async Task ExecuteTestAsync(
string displayName,
string variableName,
int executionIteration)
{
// Arrange
// Initialize state to simulate workflow environment.
this.State.InitializeSystem();
CreateConversation model = this.CreateModel(
this.FormatDisplayName(displayName),
FormatVariablePath(variableName));
MockAgentProvider mockAgentProvider = new();
CreateConversationExecutor action = new(model, mockAgentProvider.Object, this.State);
// Act
int expectedIterationCount = executionIteration;
while (executionIteration-- > 0)
{
await this.ExecuteAsync(action);
}
// Assert
VerifyModel(model, action);
Assert.Equal(expected: expectedIterationCount, actual: mockAgentProvider.ExistingConversationIds.Count);
this.VerifyState("TestConversationId", FormulaValue.New(mockAgentProvider.ExistingConversationIds.Last()));
}
private CreateConversation CreateModel(string displayName, string conversationIdVariable)
{
CreateConversation.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
ConversationId = PropertyPath.Create(conversationIdVariable)
};
return AssignParent<CreateConversation>(actionBuilder);
}
}
@@ -0,0 +1,186 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Agents.AI.Workflows.Observability;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// These tests ensure that OpenTelemetry Activity traces are properly created for workflow monitoring.
/// Tests are run in a collection to avoid parallel execution since ActivityListener is global.
/// Each test creates a new instance of ObservabilityTests and runs in serial within the collection.
/// This prevents interference between tests due to the global nature of ActivityListener.
/// </summary>
[Collection("ObservabilityTests")]
public sealed class ObservabilityTests : IDisposable
{
private readonly ActivityListener _activityListener;
private readonly ConcurrentBag<Activity> _capturedActivities = [];
private bool _isDisposed;
public ObservabilityTests()
{
// Set up activity listener to capture activities from workflow
// This is global and captures ALL workflow activities from ANY test in the same process!
this._activityListener = new ActivityListener
{
ShouldListenTo = source => source.Name.Contains(typeof(Workflow).Namespace!),
Sample = (ref ActivityCreationOptions<ActivityContext> options) => ActivitySamplingResult.AllData,
ActivityStarted = activity => this._capturedActivities.Add(activity),
};
ActivitySource.AddActivityListener(this._activityListener);
}
/// <summary>
/// Create a sample workflow for testing.
/// </summary>
/// <remarks>
/// This workflow is expected to create 8 activities that will be captured by the tests
/// - ActivityNames.WorkflowBuild
/// - ActivityNames.WorkflowRun
/// -- ActivityNames.EdgeGroupProcess
/// -- ActivityNames.ExecutorProcess (UppercaseExecutor)
/// --- ActivityNames.MessageSend
/// ---- ActivityNames.EdgeGroupProcess
/// -- ActivityNames.ExecutorProcess (ReverseTextExecutor)
/// --- ActivityNames.MessageSend
/// </remarks>
/// <returns>The created workflow.</returns>
private static Workflow CreateWorkflow()
{
// Create the executors
Func<string, string> uppercaseFunc = s => s.ToUpperInvariant();
var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor");
Func<string, string> reverseFunc = s => new string(s.Reverse().ToArray());
var reverse = reverseFunc.BindAsExecutor("ReverseTextExecutor");
// Build the workflow by connecting executors sequentially
WorkflowBuilder builder = new(uppercase);
builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse);
return builder.Build();
}
private static Dictionary<string, int> GetExpectedActivityNameCounts() =>
new()
{
{ ActivityNames.WorkflowBuild, 1 },
{ ActivityNames.WorkflowRun, 1 },
{ ActivityNames.EdgeGroupProcess, 2 },
{ ActivityNames.ExecutorProcess, 2 },
{ ActivityNames.MessageSend, 2 }
};
private static InProcessExecutionEnvironment GetExecutionEnvironment(string name) =>
name switch
{
"Default" => InProcessExecution.Default,
"Lockstep" => InProcessExecution.Lockstep,
"OffThread" => InProcessExecution.OffThread,
"Concurrent" => InProcessExecution.Concurrent,
_ => throw new ArgumentException($"Unknown execution environment name: {name}")
};
public void Dispose()
{
if (!this._isDisposed)
{
this._activityListener?.Dispose();
this._isDisposed = true;
}
}
private async Task TestWorkflowEndToEndActivitiesAsync(string executionEnvironmentName)
{
// Arrange
// Create a test activity to correlate captured activities
using var testActivity = new Activity("ObservabilityTest").Start();
// Act
var workflow = CreateWorkflow();
var executionEnvironment = GetExecutionEnvironment(executionEnvironmentName);
Run run = await executionEnvironment.RunAsync(workflow, "Hello, World!");
await run.DisposeAsync();
await Task.Delay(100); // Allow time for activities to be captured
// Assert
var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList();
capturedActivities.Should().HaveCount(8, "Exactly 8 activities should be created.");
// Make sure all expected activities exist and have the correct count
foreach (var kvp in GetExpectedActivityNameCounts())
{
var activityName = kvp.Key;
var expectedCount = kvp.Value;
var actualCount = capturedActivities.Count(a => a.OperationName == activityName);
actualCount.Should().Be(expectedCount, $"Activity '{activityName}' should occur {expectedCount} times.");
}
// Verify WorkflowRun activity events include workflow lifecycle events
var workflowRunActivity = capturedActivities.First(a => a.OperationName == ActivityNames.WorkflowRun);
var activityEvents = workflowRunActivity.Events.ToList();
activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowStarted, "activity should have workflow started event");
activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowCompleted, "activity should have workflow completed event");
}
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_DefaultAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("Default");
}
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_OffThreadAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("OffThread");
}
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_ConcurrentAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("Concurrent");
}
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_LockstepAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("Lockstep");
}
[Fact]
public async Task CreatesWorkflowActivities_WithCorrectNameAsync()
{
// Arrange
// Create a test activity to correlate captured activities
using var testActivity = new Activity("ObservabilityTest").Start();
// Act
CreateWorkflow();
await Task.Delay(100); // Allow time for activities to be captured
// Assert
var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList();
capturedActivities.Should().HaveCount(1, "Exactly 1 activity should be created.");
capturedActivities[0].OperationName.Should().Be(ActivityNames.WorkflowBuild,
"The activity should have the correct operation name for workflow build.");
var events = capturedActivities[0].Events.ToList();
events.Should().Contain(e => e.Name == EventNames.BuildStarted, "activity should have build started event");
events.Should().Contain(e => e.Name == EventNames.BuildValidationCompleted, "activity should have build validation completed event");
events.Should().Contain(e => e.Name == EventNames.BuildCompleted, "activity should have build completed event");
var tags = capturedActivities[0].Tags.ToDictionary(t => t.Key, t => t.Value);
tags.Should().ContainKey(Tags.WorkflowId);
tags.Should().ContainKey(Tags.WorkflowDefinition);
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ repos:
- id: check-json
name: Check JSON files
files: \.json$
exclude: ^.*\.vscode\/.*
exclude: ^.*\.vscode\/.*|^python/demos/samples/chatkit-integration/frontend/(tsconfig.*\.json|package-lock\.json)$
- id: end-of-file-fixer
name: Fix End of File
files: \.py$
+9
View File
@@ -12,6 +12,15 @@
"console": "integratedTerminal",
"justMyCode": false
},
{
"name": "AG-UI Examples Server",
"type": "debugpy",
"request": "launch",
"module": "examples",
"cwd": "${workspaceFolder}/packages/ag-ui",
"console": "integratedTerminal",
"justMyCode": false
},
{
"name": "Python Attach",
"type": "debugpy",
+6
View File
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.0b251105] - 2025-11-05
### Added
- **agent-framework-ag-ui**: Initial release of AG-UI protocol integration for Agent Framework ([#1826](https://github.com/microsoft/agent-framework/pull/1826))
## [1.0.0b251104] - 2025-11-04
### Added
+1 -1
View File
@@ -233,7 +233,7 @@ if __name__ == "__main__":
asyncio.run(main())
```
**Note**: Advanced orchestration patterns like GroupChat, Sequential, and Concurrent orchestrations are coming soon.
For more advanced orchestration patterns including Sequential, GroupChat, Concurrent, Magentic, and Handoff orchestrations, see the [orchestration samples](samples/getting_started/workflows/orchestration).
## More Examples & Samples
+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.
+71
View File
@@ -0,0 +1,71 @@
# Agent Framework AG-UI Integration
AG-UI protocol integration for Agent Framework, enabling seamless integration with AG-UI's web interface and streaming protocol.
## Installation
```bash
pip install agent-framework-ag-ui
```
## Quick Start
```python
from fastapi import FastAPI
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
# Create your agent
agent = ChatAgent(
name="my_agent",
instructions="You are a helpful assistant.",
chat_client=AzureOpenAIChatClient(
endpoint="https://your-resource.openai.azure.com/",
deployment_name="gpt-4o-mini",
),
)
# Create FastAPI app and add AG-UI endpoint
app = FastAPI()
add_agent_framework_fastapi_endpoint(app, agent, "/")
# Run with: uvicorn main:app --reload
```
## Documentation
- **[Getting Started Tutorial](getting_started/)** - Step-by-step guide to building your first AG-UI server and client
- **[Examples](examples/)** - Complete examples for AG-UI features
## Features
This integration supports all 7 AG-UI features:
1. **Agentic Chat**: Basic streaming chat with tool calling support
2. **Backend Tool Rendering**: Tools executed on backend with results streamed to client
3. **Human in the Loop**: Function approval requests for user confirmation before tool execution
4. **Agentic Generative UI**: Async tools for long-running operations with progress updates
5. **Tool-based Generative UI**: Custom UI components rendered on frontend based on tool calls
6. **Shared State**: Bidirectional state sync between client and server
7. **Predictive State Updates**: Stream tool arguments as optimistic state updates during execution
## Architecture
The package uses a clean, orchestrator-based architecture:
- **AgentFrameworkAgent**: Lightweight wrapper that delegates to orchestrators
- **Orchestrators**: Handle different execution flows (default, human-in-the-loop, etc.)
- **Confirmation Strategies**: Domain-specific confirmation messages (extensible)
- **AgentFrameworkEventBridge**: Converts Agent Framework events to AG-UI events
- **Message Adapters**: Bidirectional conversion between AG-UI and Agent Framework message formats
- **FastAPI Endpoint**: Streaming HTTP endpoint with Server-Sent Events (SSE)
## Next Steps
1. **New to AG-UI?** Start with the [Getting Started Tutorial](getting_started/)
2. **Want to see examples?** Check out the [Examples](examples/) for AG-UI features
## License
MIT
@@ -0,0 +1,31 @@
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI protocol integration for Agent Framework."""
import importlib.metadata
from ._agent import AgentFrameworkAgent
from ._confirmation_strategies import (
ConfirmationStrategy,
DefaultConfirmationStrategy,
DocumentWriterConfirmationStrategy,
RecipeConfirmationStrategy,
TaskPlannerConfirmationStrategy,
)
from ._endpoint import add_agent_framework_fastapi_endpoint
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"AgentFrameworkAgent",
"add_agent_framework_fastapi_endpoint",
"ConfirmationStrategy",
"DefaultConfirmationStrategy",
"TaskPlannerConfirmationStrategy",
"RecipeConfirmationStrategy",
"DocumentWriterConfirmationStrategy",
"__version__",
]
@@ -0,0 +1,160 @@
# Copyright (c) Microsoft. All rights reserved.
"""AgentFrameworkAgent wrapper for AG-UI protocol - Clean Architecture."""
from collections.abc import AsyncGenerator
from typing import Any
from ag_ui.core import BaseEvent
from agent_framework import AgentProtocol
from ._confirmation_strategies import ConfirmationStrategy, DefaultConfirmationStrategy
from ._orchestrators import (
DefaultOrchestrator,
ExecutionContext,
HumanInTheLoopOrchestrator,
Orchestrator,
)
class AgentConfig:
"""Configuration for agent wrapper."""
def __init__(
self,
state_schema: dict[str, Any] | None = None,
predict_state_config: dict[str, dict[str, str]] | None = None,
require_confirmation: bool = True,
):
"""Initialize agent configuration.
Args:
state_schema: Optional state schema for state management
predict_state_config: Configuration for predictive state updates
require_confirmation: Whether predictive updates require confirmation
"""
self.state_schema = state_schema or {}
self.predict_state_config = predict_state_config or {}
self.require_confirmation = require_confirmation
class AgentFrameworkAgent:
"""Wraps Agent Framework agents for AG-UI protocol compatibility.
Translates between Agent Framework's AgentProtocol and AG-UI's event-based
protocol. Uses orchestrators to handle different execution flows (standard
execution, human-in-the-loop, etc.). Orchestrators are checked in order;
the first matching orchestrator handles the request.
Supports predictive state updates for agentic generative UI, with optional
confirmation requirements configurable per use case.
"""
def __init__(
self,
agent: AgentProtocol,
name: str | None = None,
description: str | None = None,
state_schema: dict[str, Any] | None = None,
predict_state_config: dict[str, dict[str, str]] | None = None,
require_confirmation: bool = True,
orchestrators: list[Orchestrator] | None = None,
confirmation_strategy: ConfirmationStrategy | None = None,
):
"""Initialize the AG-UI compatible agent wrapper.
Args:
agent: The Agent Framework agent to wrap
name: Optional name for the agent
description: Optional description
state_schema: Optional state schema for state management
predict_state_config: Configuration for predictive state updates.
Format: {"state_key": {"tool": "tool_name", "tool_argument": "arg_name"}}
require_confirmation: Whether predictive updates require confirmation.
Set to False for agentic generative UI that updates automatically.
orchestrators: Custom orchestrators (auto-configured if None).
Orchestrators are checked in order; first match handles the request.
confirmation_strategy: Strategy for generating confirmation messages.
Defaults to DefaultConfirmationStrategy if None.
"""
self.agent = agent
self.name = name or getattr(agent, "name", "agent")
self.description = description or getattr(agent, "description", "")
self.config = AgentConfig(
state_schema=state_schema,
predict_state_config=predict_state_config,
require_confirmation=require_confirmation,
)
# Configure orchestrators
if orchestrators is None:
self.orchestrators = self._default_orchestrators()
else:
self.orchestrators = orchestrators
# Configure confirmation strategy
if confirmation_strategy is None:
self.confirmation_strategy: ConfirmationStrategy = DefaultConfirmationStrategy()
else:
self.confirmation_strategy = confirmation_strategy
def _default_orchestrators(self) -> list[Orchestrator]:
"""Create default orchestrator chain.
Returns:
List of orchestrators in priority order. First matching orchestrator
handles the request, so order matters.
"""
return [
HumanInTheLoopOrchestrator(), # Handle tool approval responses
# Add more specialized orchestrators here as needed
DefaultOrchestrator(), # Fallback: standard agent execution
]
async def run_agent(
self,
input_data: dict[str, Any],
) -> AsyncGenerator[BaseEvent, None]:
"""Run the agent and yield AG-UI events.
This is the ONLY public method - much simpler than the original 376-line
implementation. All orchestration logic has been extracted into dedicated
Orchestrator classes.
The method creates an ExecutionContext with all needed data, then finds
the first orchestrator that can handle the request and delegates to it.
Args:
input_data: The AG-UI run input containing messages, state, etc.
Yields:
AG-UI events
Raises:
RuntimeError: If no orchestrator matches (should never happen if
DefaultOrchestrator is last in the chain)
"""
# Create execution context with all needed data
context = ExecutionContext(
input_data=input_data,
agent=self.agent,
config=self.config,
confirmation_strategy=self.confirmation_strategy,
)
# Find matching orchestrator and execute
for orchestrator in self.orchestrators:
if orchestrator.can_handle(context):
async for event in orchestrator.run(context):
yield event
return
# Should never reach here if DefaultOrchestrator is last
raise RuntimeError("No orchestrator matched - check configuration")
__all__ = [
"AgentFrameworkAgent",
"AgentConfig",
]
@@ -0,0 +1,175 @@
# Copyright (c) Microsoft. All rights reserved.
"""Confirmation strategies for human-in-the-loop approval flows.
Each agent can provide a custom confirmation strategy to generate domain-specific
messages when users approve or reject changes/actions.
"""
from abc import ABC, abstractmethod
from typing import Any
class ConfirmationStrategy(ABC):
"""Strategy for generating confirmation messages during human-in-the-loop flows."""
@abstractmethod
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
"""Generate message when user approves function execution.
Args:
steps: List of approved steps with 'description', 'status', etc.
Returns:
Message to display to user
"""
...
@abstractmethod
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
"""Generate message when user rejects function execution.
Args:
steps: List of rejected steps
Returns:
Message to display to user
"""
...
@abstractmethod
def on_state_confirmed(self) -> str:
"""Generate message when user confirms predictive state changes.
Returns:
Message to display to user
"""
...
@abstractmethod
def on_state_rejected(self) -> str:
"""Generate message when user rejects predictive state changes.
Returns:
Message to display to user
"""
...
class DefaultConfirmationStrategy(ConfirmationStrategy):
"""Generic confirmation messages suitable for most agents.
This preserves the original behavior from v1.
"""
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
"""Generate generic approval message with step list."""
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
message_parts = [f"Executing {len(enabled_steps)} approved steps:\n\n"]
for i, step in enumerate(enabled_steps, 1):
message_parts.append(f"{i}. {step['description']}\n")
message_parts.append("\nAll steps completed successfully!")
return "".join(message_parts)
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
"""Generate generic rejection message."""
return "No problem! What would you like me to change about the plan?"
def on_state_confirmed(self) -> str:
"""Generate generic state confirmation message."""
return "Changes confirmed and applied successfully!"
def on_state_rejected(self) -> str:
"""Generate generic state rejection message."""
return "No problem! What would you like me to change?"
class TaskPlannerConfirmationStrategy(ConfirmationStrategy):
"""Domain-specific confirmation messages for task planning agents."""
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
"""Generate task-specific approval message."""
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
message_parts = ["Executing your requested tasks:\n\n"]
for i, step in enumerate(enabled_steps, 1):
message_parts.append(f"{i}. {step['description']}\n")
message_parts.append("\nAll tasks completed successfully!")
return "".join(message_parts)
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
"""Generate task-specific rejection message."""
return "No problem! Let me revise the plan. What would you like me to change?"
def on_state_confirmed(self) -> str:
"""Task planners typically don't use state confirmation."""
return "Tasks confirmed and ready to execute!"
def on_state_rejected(self) -> str:
"""Task planners typically don't use state confirmation."""
return "No problem! How should I adjust the task list?"
class RecipeConfirmationStrategy(ConfirmationStrategy):
"""Domain-specific confirmation messages for recipe agents."""
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
"""Generate recipe-specific approval message."""
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
message_parts = ["Updating your recipe:\n\n"]
for i, step in enumerate(enabled_steps, 1):
message_parts.append(f"{i}. {step['description']}\n")
message_parts.append("\nRecipe updated successfully!")
return "".join(message_parts)
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
"""Generate recipe-specific rejection message."""
return "No problem! What ingredients or steps should I change?"
def on_state_confirmed(self) -> str:
"""Generate recipe-specific state confirmation message."""
return "Recipe changes applied successfully!"
def on_state_rejected(self) -> str:
"""Generate recipe-specific state rejection message."""
return "No problem! What would you like me to adjust in the recipe?"
class DocumentWriterConfirmationStrategy(ConfirmationStrategy):
"""Domain-specific confirmation messages for document writing agents."""
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
"""Generate document-specific approval message."""
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
message_parts = ["Applying your edits:\n\n"]
for i, step in enumerate(enabled_steps, 1):
message_parts.append(f"{i}. {step['description']}\n")
message_parts.append("\nDocument updated successfully!")
return "".join(message_parts)
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
"""Generate document-specific rejection message."""
return "No problem! Which changes should I keep or modify?"
def on_state_confirmed(self) -> str:
"""Generate document-specific state confirmation message."""
return "Document edits applied!"
def on_state_rejected(self) -> str:
"""Generate document-specific state rejection message."""
return "No problem! What should I change about the document?"
@@ -0,0 +1,94 @@
# Copyright (c) Microsoft. All rights reserved.
"""FastAPI endpoint creation for AG-UI agents."""
import logging
from typing import Any
from ag_ui.encoder import EventEncoder
from agent_framework import AgentProtocol
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from ._agent import AgentFrameworkAgent
logger = logging.getLogger(__name__)
def add_agent_framework_fastapi_endpoint(
app: FastAPI,
agent: AgentProtocol | AgentFrameworkAgent,
path: str = "/",
state_schema: dict[str, Any] | None = None,
predict_state_config: dict[str, dict[str, str]] | None = None,
allow_origins: list[str] | None = None,
) -> None:
"""Add an AG-UI endpoint to a FastAPI app.
Args:
app: The FastAPI application
agent: The agent to expose (can be raw AgentProtocol or wrapped)
path: The endpoint path
state_schema: Optional state schema for shared state management
predict_state_config: Optional predictive state update configuration.
Format: {"state_key": {"tool": "tool_name", "tool_argument": "arg_name"}}
allow_origins: CORS origins (not yet implemented)
"""
if isinstance(agent, AgentProtocol):
wrapped_agent = AgentFrameworkAgent(
agent=agent,
state_schema=state_schema,
predict_state_config=predict_state_config,
)
else:
wrapped_agent = agent
@app.post(path)
async def agent_endpoint(request: Request): # type: ignore[misc]
"""Handle AG-UI agent requests.
Note: Function is accessed via FastAPI's decorator registration,
despite appearing unused to static analysis.
"""
try:
input_data = await request.json()
logger.debug(
f"[{path}] Received request - Run ID: {input_data.get('run_id', 'no-run-id')}, "
f"Thread ID: {input_data.get('thread_id', 'no-thread-id')}, "
f"Messages: {len(input_data.get('messages', []))}"
)
logger.info(f"Received request at {path}: {input_data.get('run_id', 'no-run-id')}")
async def event_generator():
encoder = EventEncoder()
event_count = 0
async for event in wrapped_agent.run_agent(input_data):
event_count += 1
logger.debug(f"[{path}] Event {event_count}: {type(event).__name__}")
# Log event payload for debugging
if hasattr(event, "model_dump"):
event_data = event.model_dump(exclude_none=True)
logger.debug(f"[{path}] Event payload: {event_data}")
encoded = encoder.encode(event)
logger.debug(
f"[{path}] Encoded as: {encoded[:200]}..."
if len(encoded) > 200
else f"[{path}] Encoded as: {encoded}"
)
yield encoded
logger.info(f"[{path}] Completed streaming {event_count} events")
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
except Exception as e:
logger.error(f"Error in agent endpoint: {e}", exc_info=True)
return {"error": str(e)}
@@ -0,0 +1,675 @@
# Copyright (c) Microsoft. All rights reserved.
"""Event bridge for converting Agent Framework events to AG-UI protocol."""
import json
import logging
import re
from typing import Any
from ag_ui.core import (
BaseEvent,
CustomEvent,
EventType,
MessagesSnapshotEvent,
RunFinishedEvent,
RunStartedEvent,
StateDeltaEvent,
StateSnapshotEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallResultEvent,
ToolCallStartEvent,
)
from agent_framework import (
AgentRunResponseUpdate,
FunctionApprovalRequestContent,
FunctionCallContent,
FunctionResultContent,
TextContent,
)
from ._utils import generate_event_id
logger = logging.getLogger(__name__)
class AgentFrameworkEventBridge:
"""Converts Agent Framework responses to AG-UI events."""
def __init__(
self,
run_id: str,
thread_id: str,
predict_state_config: dict[str, dict[str, str]] | None = None,
current_state: dict[str, Any] | None = None,
skip_text_content: bool = False,
input_messages: list[Any] | None = None,
require_confirmation: bool = True,
) -> None:
"""
Initialize the event bridge.
Args:
run_id: The run identifier.
thread_id: The thread identifier.
predict_state_config: Configuration for predictive state updates.
Format: {"state_key": {"tool": "tool_name", "tool_argument": "arg_name"}}
current_state: Reference to the current state dict for tracking updates.
skip_text_content: If True, skip emitting TextMessageContentEvents (for structured outputs).
input_messages: The input messages from the conversation history.
require_confirmation: Whether predictive state updates require user confirmation.
"""
self.run_id = run_id
self.thread_id = thread_id
self.current_message_id: str | None = None
self.current_tool_call_id: str | None = None
self.current_tool_call_name: str | None = None # Track the tool name across streaming chunks
self.predict_state_config = predict_state_config or {}
self.current_state = current_state or {}
self.pending_state_updates: dict[str, Any] = {} # Track updates from tool calls
self.skip_text_content = skip_text_content
self.require_confirmation = require_confirmation
# For predictive state updates: accumulate streaming arguments
self.streaming_tool_args: str = "" # Accumulated JSON string
self.last_emitted_state: dict[str, Any] = {} # Track last emitted state to avoid duplicates
self.state_delta_count: int = 0 # Counter for sampling log output
self.should_stop_after_confirm: bool = False # Flag to stop run after confirm_changes
self.suppressed_summary: str = "" # Store LLM summary to show after confirmation
# For MessagesSnapshotEvent: track tool calls and results
self.input_messages = input_messages or []
self.pending_tool_calls: list[dict[str, Any]] = [] # Track tool calls for assistant message
self.tool_results: list[dict[str, Any]] = [] # Track tool results
async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[BaseEvent]:
"""
Convert an AgentRunResponseUpdate to AG-UI events.
Args:
update: The agent run update to convert.
Returns:
List of AG-UI events.
"""
events: list[BaseEvent] = []
for content in update.contents:
if isinstance(content, TextContent):
# Skip text content if using structured outputs (it's just the JSON)
if self.skip_text_content:
continue
# Skip text content if we're about to emit confirm_changes
# The summary should only appear after user confirms
if self.should_stop_after_confirm:
logger.debug(" >>> Skipping text content - waiting for confirm_changes response")
# Save the summary text to show after confirmation
self.suppressed_summary += content.text
continue
if not self.current_message_id:
self.current_message_id = generate_event_id()
start_event = TextMessageStartEvent(
message_id=self.current_message_id,
role="assistant",
)
events.append(start_event)
event = TextMessageContentEvent(
message_id=self.current_message_id,
delta=content.text,
)
events.append(event)
elif isinstance(content, FunctionCallContent):
# Log tool calls for debugging
if content.name:
logger.debug(f"Tool call: {content.name} (call_id: {content.call_id})")
if not content.name and not content.call_id and not self.current_tool_call_name:
args_preview = str(content.arguments)[:50] if content.arguments else "None"
logger.warning(f"FunctionCallContent missing name and call_id. Args: {args_preview}")
# Get or use existing tool call ID - all chunks of same tool call share the same call_id
# Important: the first chunk might have name but no call_id yet
if content.call_id:
tool_call_id = content.call_id
elif self.current_tool_call_id:
tool_call_id = self.current_tool_call_id
else:
# Generate a new ID for this tool call
tool_call_id = (
generate_event_id()
) # Handle streaming tool calls - name comes in first chunk, arguments in subsequent chunks
if content.name:
# This is a new tool call or the first chunk with the name
self.current_tool_call_id = tool_call_id
self.current_tool_call_name = content.name
tool_start_event = ToolCallStartEvent(
tool_call_id=tool_call_id,
tool_call_name=content.name,
parent_message_id=self.current_message_id,
)
logger.info(f" >>> Emitting ToolCallStartEvent with name='{content.name}', id='{tool_call_id}'")
events.append(tool_start_event)
# Track tool call for MessagesSnapshotEvent
# Initialize a new tool call entry
self.pending_tool_calls.append(
{
"id": tool_call_id,
"type": "function",
"function": {
"name": content.name,
"arguments": "", # Will accumulate as we get argument chunks
},
}
)
else:
# Subsequent chunk without name - update our tracked ID if needed
if tool_call_id:
self.current_tool_call_id = tool_call_id
# Emit arguments if present
if content.arguments:
# content.arguments is already a JSON string from the LLM for streaming calls
# For non-streaming it could be a dict, so we need to handle both
if isinstance(content.arguments, str):
delta_str = content.arguments
else:
# If it's a dict, convert to JSON
delta_str = json.dumps(content.arguments)
logger.info(f" >>> Emitting ToolCallArgsEvent with delta: {delta_str!r}..., id='{tool_call_id}'")
args_event = ToolCallArgsEvent(
tool_call_id=tool_call_id,
delta=delta_str,
)
events.append(args_event)
# Accumulate arguments for MessagesSnapshotEvent
if self.pending_tool_calls:
# Find the matching tool call and append the delta
for tool_call in self.pending_tool_calls:
if tool_call["id"] == tool_call_id:
tool_call["function"]["arguments"] += delta_str
break
# Predictive state updates - accumulate streaming arguments and emit deltas
# Use current_tool_call_name since content.name is only present on first chunk
if self.current_tool_call_name and self.predict_state_config:
# Accumulate the argument string
if isinstance(content.arguments, str):
self.streaming_tool_args += content.arguments
else:
self.streaming_tool_args += json.dumps(content.arguments)
logger.debug(
f" >>> Predictive state: accumulated {len(self.streaming_tool_args)} chars for tool '{self.current_tool_call_name}'"
)
# Try to parse accumulated arguments (may be incomplete JSON)
# We use a lenient approach: try standard parsing first, then try to extract partial values
parsed_args = None
try:
parsed_args = json.loads(self.streaming_tool_args)
except json.JSONDecodeError:
# JSON is incomplete - try to extract partial string values
# For streaming "document" field, we can extract: {"document": "text...
# Look for pattern: {"field": "value (incomplete)
for state_key, config in self.predict_state_config.items():
if config["tool"] == self.current_tool_call_name:
tool_arg_name = config["tool_argument"]
# Try to extract partial string value for this argument
# Pattern: "argument_name": "partial text
pattern = rf'"{re.escape(tool_arg_name)}":\s*"([^"]*)'
match = re.search(pattern, self.streaming_tool_args)
if match:
partial_value = match.group(1)
# Unescape common sequences
partial_value = (
partial_value.replace("\\n", "\n").replace('\\"', '"').replace("\\\\", "\\")
)
# Emit delta if we have new content
if (
state_key not in self.last_emitted_state
or self.last_emitted_state[state_key] != partial_value
):
state_delta_event = StateDeltaEvent(
delta=[
{
"op": "replace",
"path": f"/{state_key}",
"value": partial_value,
}
],
)
self.state_delta_count += 1
if self.state_delta_count % 10 == 1:
value_preview = (
str(partial_value)[:100] + "..."
if len(str(partial_value)) > 100
else str(partial_value)
)
logger.info(
f" >>> StateDeltaEvent #{self.state_delta_count} for '{state_key}': "
f"op=replace, path=/{state_key}, value={value_preview}"
)
elif self.state_delta_count % 100 == 0:
logger.info(f" >>> StateDeltaEvent #{self.state_delta_count} emitted")
events.append(state_delta_event)
self.last_emitted_state[state_key] = partial_value
self.pending_state_updates[state_key] = partial_value
# If we successfully parsed complete JSON, process it
if parsed_args:
# Check if this tool matches any predictive state config
for state_key, config in self.predict_state_config.items():
if config["tool"] == self.current_tool_call_name:
tool_arg_name = config["tool_argument"]
# Extract the state value
if tool_arg_name == "*":
state_value = parsed_args
elif tool_arg_name in parsed_args:
state_value = parsed_args[tool_arg_name]
else:
continue
# Only emit if state has changed from last emission
if (
state_key not in self.last_emitted_state
or self.last_emitted_state[state_key] != state_value
):
# Emit StateDeltaEvent for real-time UI updates (JSON Patch format)
state_delta_event = StateDeltaEvent(
delta=[
{
"op": "replace", # Use replace since field exists in schema
"path": f"/{state_key}", # JSON Pointer path with leading slash
"value": state_value,
}
],
)
# Increment counter and log every 10th emission with sample data
self.state_delta_count += 1
if self.state_delta_count % 10 == 1: # Log 1st, 11th, 21st, etc.
value_preview = (
str(state_value)[:100] + "..."
if len(str(state_value)) > 100
else str(state_value)
)
logger.info(
f" >>> StateDeltaEvent #{self.state_delta_count} for '{state_key}': "
f"op=replace, path=/{state_key}, value={value_preview}"
)
elif self.state_delta_count % 100 == 0: # Also log every 100th
logger.info(f" >>> StateDeltaEvent #{self.state_delta_count} emitted")
events.append(state_delta_event)
# Track what we emitted
self.last_emitted_state[state_key] = state_value
self.pending_state_updates[state_key] = state_value
# Legacy predictive state check (for when arguments are complete)
if content.name and content.arguments:
parsed_args = content.parse_arguments()
if parsed_args:
logger.info(f"Checking predict_state_config: {self.predict_state_config}")
for state_key, config in self.predict_state_config.items():
logger.info(f"Checking state_key='{state_key}', config={config}")
if config["tool"] == content.name:
tool_arg_name = config["tool_argument"]
logger.info(
f"MATCHED tool '{content.name}' for state key '{state_key}', arg='{tool_arg_name}'"
)
# If tool_argument is "*", use all arguments as the state value
if tool_arg_name == "*":
state_value = parsed_args
logger.info(f"Using all args as state value, keys: {list(state_value.keys())}")
elif tool_arg_name in parsed_args:
state_value = parsed_args[tool_arg_name]
logger.info(f"Using specific arg '{tool_arg_name}' as state value")
else:
logger.warning(f"Tool argument '{tool_arg_name}' not found in parsed args")
continue
# Emit predictive delta (JSON Patch format)
state_delta_event = StateDeltaEvent(
delta=[
{
"op": "replace", # Use replace since field exists in schema
"path": f"/{state_key}", # JSON Pointer path with leading slash
"value": state_value,
}
],
)
logger.info(
f" >>> Emitting StateDeltaEvent for key '{state_key}', value type: {type(state_value)}"
)
events.append(state_delta_event)
# Track pending update for later snapshot
self.pending_state_updates[state_key] = state_value
# Note: ToolCallEndEvent is emitted when we receive FunctionResultContent,
# not here during streaming, since we don't know when the stream is complete
elif isinstance(content, FunctionResultContent):
# First emit ToolCallEndEvent to close the tool call
if content.call_id:
end_event = ToolCallEndEvent(
tool_call_id=content.call_id,
)
logger.info(f" >>> Emitting ToolCallEndEvent for completed tool call '{content.call_id}'")
events.append(end_event)
# Log total StateDeltaEvent count for this tool call
if self.state_delta_count > 0:
logger.info(
f" >>> Tool call '{content.call_id}' complete: emitted {self.state_delta_count} StateDeltaEvents total"
)
# Reset streaming accumulator and counter for next tool call
self.streaming_tool_args = ""
self.state_delta_count = 0
# Tool result - emit ToolCallResultEvent
result_message_id = generate_event_id()
# Preserve structured data for backend tool rendering
# Serialize dicts to JSON string, otherwise convert to string
if isinstance(content.result, dict):
result_content = json.dumps(content.result) # type: ignore[arg-type]
elif content.result is not None:
result_content = str(content.result)
else:
result_content = ""
result_event = ToolCallResultEvent(
message_id=result_message_id,
tool_call_id=content.call_id,
content=result_content,
role="tool",
)
events.append(result_event)
# Track tool result for MessagesSnapshotEvent
self.tool_results.append(
{
"id": result_message_id,
"role": "tool",
"tool_call_id": content.call_id,
"content": result_content,
}
)
# Emit MessagesSnapshotEvent with the complete conversation including tool calls and results
# This is required for CopilotKit's useCopilotAction to detect tool result
if self.pending_tool_calls and self.tool_results:
# Build assistant message with tool_calls
assistant_message = {
"id": generate_event_id(),
"role": "assistant",
"tool_calls": self.pending_tool_calls.copy(), # Copy the accumulated tool calls
}
# Build complete messages array: input messages + assistant message + tool results
all_messages = list(self.input_messages) + [assistant_message] + self.tool_results.copy()
# Emit MessagesSnapshotEvent using the proper event type
messages_snapshot_event = MessagesSnapshotEvent(
type=EventType.MESSAGES_SNAPSHOT, messages=all_messages
)
logger.info(f" >>> Emitting MessagesSnapshotEvent with {len(all_messages)} messages")
events.append(messages_snapshot_event)
# After tool execution, emit StateSnapshotEvent if we have pending state updates
if self.pending_state_updates:
# Update the current state with pending updates
for key, value in self.pending_state_updates.items():
self.current_state[key] = value
# Log the state structure for debugging
logger.info(f"Emitting StateSnapshotEvent with keys: {list(self.current_state.keys())}")
if "recipe" in self.current_state:
recipe = self.current_state["recipe"]
logger.info(
f"Recipe fields: title={recipe.get('title')}, "
f"skill_level={recipe.get('skill_level')}, "
f"ingredients_count={len(recipe.get('ingredients', []))}, "
f"instructions_count={len(recipe.get('instructions', []))}"
)
# Emit complete state snapshot
state_snapshot_event = StateSnapshotEvent(
snapshot=self.current_state,
)
events.append(state_snapshot_event)
# Check if this was a predictive state update tool (e.g., write_document_local)
# If so, emit a confirm_changes tool call for the UI modal
tool_was_predictive = False
logger.debug(
f" >>> Checking predictive state: current_tool='{self.current_tool_call_name}', "
f"predict_config={list(self.predict_state_config.keys()) if self.predict_state_config else 'None'}"
)
for state_key, config in self.predict_state_config.items():
# Check if this tool call matches a predictive config
# We need to match against self.current_tool_call_name
if self.current_tool_call_name and config["tool"] == self.current_tool_call_name:
logger.info(
f" >>> Tool '{self.current_tool_call_name}' matches predictive config for state key '{state_key}'"
)
tool_was_predictive = True
break
if tool_was_predictive and self.require_confirmation:
# Emit confirm_changes tool call sequence
confirm_call_id = generate_event_id()
logger.info(" >>> Emitting confirm_changes tool call for predictive update")
# Track confirm_changes tool call for MessagesSnapshotEvent (so it persists after RUN_FINISHED)
self.pending_tool_calls.append(
{
"id": confirm_call_id,
"type": "function",
"function": {
"name": "confirm_changes",
"arguments": "{}",
},
}
)
# Start the confirm_changes tool call
confirm_start = ToolCallStartEvent(
tool_call_id=confirm_call_id,
tool_call_name="confirm_changes",
)
events.append(confirm_start)
# Empty args for confirm_changes
confirm_args = ToolCallArgsEvent(
tool_call_id=confirm_call_id,
delta="{}",
)
events.append(confirm_args)
# End the confirm_changes tool call
confirm_end = ToolCallEndEvent(
tool_call_id=confirm_call_id,
)
events.append(confirm_end)
# Emit MessagesSnapshotEvent so confirm_changes persists after RUN_FINISHED
# Build assistant message with pending confirm_changes tool call
assistant_message = {
"id": generate_event_id(),
"role": "assistant",
"tool_calls": self.pending_tool_calls.copy(), # Includes confirm_changes
}
# Build complete messages array: input messages + assistant message + any tool results
all_messages = list(self.input_messages) + [assistant_message] + self.tool_results.copy()
# Emit MessagesSnapshotEvent
messages_snapshot_event = MessagesSnapshotEvent(
type=EventType.MESSAGES_SNAPSHOT, messages=all_messages
)
logger.info(
f" >>> Emitting MessagesSnapshotEvent for confirm_changes with {len(all_messages)} messages"
)
events.append(messages_snapshot_event)
# Set flag to stop the run after this - we're waiting for user response
self.should_stop_after_confirm = True
logger.info(" >>> Set flag to stop run after confirm_changes")
elif tool_was_predictive:
logger.info(" >>> Skipping confirm_changes - require_confirmation is False")
# Clear pending updates and reset tool name tracker
self.pending_state_updates.clear()
self.last_emitted_state.clear()
self.current_tool_call_name = None # Reset for next tool call
elif isinstance(content, FunctionApprovalRequestContent):
# Human in the loop - function approval request
logger.info("=== FUNCTION APPROVAL REQUEST ===")
logger.info(f" Function: {content.function_call.name}")
logger.info(f" Call ID: {content.function_call.call_id}")
# Parse the arguments to extract state for predictive UI updates
parsed_args = content.function_call.parse_arguments()
logger.info(f" Parsed args keys: {list(parsed_args.keys()) if parsed_args else 'None'}")
# Check if this matches our predict_state_config and emit state
if parsed_args and self.predict_state_config:
logger.info(f" Checking predict_state_config: {self.predict_state_config}")
for state_key, config in self.predict_state_config.items():
if config["tool"] == content.function_call.name:
tool_arg_name = config["tool_argument"]
logger.info(
f" MATCHED tool '{content.function_call.name}' for state key '{state_key}', arg='{tool_arg_name}'"
)
# Extract the state value
if tool_arg_name == "*":
state_value = parsed_args
elif tool_arg_name in parsed_args:
state_value = parsed_args[tool_arg_name]
else:
logger.warning(f" Tool argument '{tool_arg_name}' not found in parsed args")
continue
# Update current state
self.current_state[state_key] = state_value
logger.info(
f" >>> Emitting StateSnapshotEvent for key '{state_key}', value type: {type(state_value)}"
)
# Emit state snapshot
state_snapshot = StateSnapshotEvent(
snapshot=self.current_state,
)
events.append(state_snapshot)
# The tool call has been streamed already (Start/Args events)
# Now we need to close it with an End event before the agent waits for approval
if content.function_call.call_id:
end_event = ToolCallEndEvent(
tool_call_id=content.function_call.call_id,
)
logger.info(
f" >>> Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'"
)
events.append(end_event)
# Emit custom event for approval request
# Note: In AG-UI protocol, the frontend handles interrupts automatically
# when it sees a tool call with the configured name (via predict_state_config)
# This custom event is for additional metadata if needed
approval_event = CustomEvent(
name="function_approval_request",
value={
"id": content.id,
"function_call": {
"call_id": content.function_call.call_id,
"name": content.function_call.name,
"arguments": content.function_call.parse_arguments(),
},
},
)
logger.info(f" >>> Emitting function_approval_request custom event for '{content.function_call.name}'")
events.append(approval_event)
return events
def create_run_started_event(self) -> RunStartedEvent:
"""Create a run started event."""
return RunStartedEvent(
run_id=self.run_id,
thread_id=self.thread_id,
)
def create_run_finished_event(self, result: Any = None) -> RunFinishedEvent:
"""Create a run finished event."""
return RunFinishedEvent(
run_id=self.run_id,
thread_id=self.thread_id,
result=result,
)
def create_message_start_event(self, message_id: str, role: str = "assistant") -> TextMessageStartEvent:
"""Create a message start event."""
return TextMessageStartEvent(
message_id=message_id,
role=role, # type: ignore
)
def create_message_end_event(self, message_id: str) -> TextMessageEndEvent:
"""Create a message end event."""
return TextMessageEndEvent(
message_id=message_id,
)
def create_state_snapshot_event(self, state: dict[str, Any]) -> StateSnapshotEvent:
"""Create a state snapshot event.
Args:
state: The complete state snapshot.
Returns:
StateSnapshotEvent.
"""
return StateSnapshotEvent(
snapshot=state,
)
def create_state_delta_event(self, delta: list[dict[str, Any]]) -> StateDeltaEvent:
"""Create a state delta event using JSON Patch format (RFC 6902).
Args:
delta: List of JSON Patch operations.
Returns:
StateDeltaEvent.
"""
return StateDeltaEvent(
delta=delta,
)
@@ -0,0 +1,218 @@
# Copyright (c) Microsoft. All rights reserved.
"""Message format conversion between AG-UI and Agent Framework."""
from typing import Any
from agent_framework import (
ChatMessage,
FunctionApprovalResponseContent,
FunctionCallContent,
Role,
TextContent,
)
# Role mapping constants
_AGUI_TO_FRAMEWORK_ROLE = {
"user": Role.USER,
"assistant": Role.ASSISTANT,
"system": Role.SYSTEM,
}
_FRAMEWORK_TO_AGUI_ROLE = {
Role.USER: "user",
Role.ASSISTANT: "assistant",
Role.SYSTEM: "system",
}
def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[ChatMessage]:
"""Convert AG-UI messages to Agent Framework format.
Args:
messages: List of AG-UI messages
Returns:
List of Agent Framework ChatMessage objects
"""
result: list[ChatMessage] = []
for msg in messages:
# Check for backend tool rendering results FIRST (may not have role field)
if "actionExecutionId" in msg or "actionName" in msg:
# Backend tool rendering - convert to FunctionResultContent
from agent_framework import FunctionResultContent
tool_call_id = msg.get("actionExecutionId", "")
result_content = msg.get("result", msg.get("content", ""))
chat_msg = ChatMessage(
role=Role.ASSISTANT, # Tool results are assistant messages
contents=[FunctionResultContent(call_id=tool_call_id, result=result_content)],
)
if "id" in msg:
chat_msg.message_id = msg["id"]
result.append(chat_msg)
continue
role_str = msg.get("role", "user")
# Handle tool result messages (with role="tool")
if role_str == "tool":
# Check if this is a standard tool result (has tool_call_id or toolCallId)
tool_call_id = msg.get("tool_call_id") or msg.get("toolCallId")
result_content = msg.get("content", "")
# Distinguish between backend tool results and approval responses
# Approval responses have {"accepted": ...} structure
is_approval = False
if result_content:
import json
try:
parsed_content = json.loads(result_content)
is_approval = "accepted" in parsed_content
except (json.JSONDecodeError, TypeError):
is_approval = False
# Backend tool results have non-empty content WITHOUT "accepted" field
if tool_call_id and result_content and not is_approval:
# Backend tool execution - convert to FunctionResultContent
from agent_framework import FunctionResultContent
chat_msg = ChatMessage(
role=Role.ASSISTANT, # Tool results are assistant messages
contents=[FunctionResultContent(call_id=tool_call_id, result=result_content)],
)
if "id" in msg:
chat_msg.message_id = msg["id"]
result.append(chat_msg)
continue
else:
# Human-in-the-loop approval response - mark for special handling
content = msg.get("content", "")
chat_msg = ChatMessage(
role=Role.USER, # Approval responses are user messages
contents=[TextContent(text=content)],
)
# Mark this as a tool result so we can detect it later
chat_msg.metadata = {"is_tool_result": True, "tool_call_id": msg.get("toolCallId", "")} # type: ignore[attr-defined]
if "id" in msg:
chat_msg.message_id = msg["id"]
result.append(chat_msg)
continue
role = _AGUI_TO_FRAMEWORK_ROLE.get(role_str, Role.USER)
# Check if this message contains function approvals
if "function_approvals" in msg and msg["function_approvals"]:
# Convert function approvals to FunctionApprovalResponseContent
contents: list[Any] = []
for approval in msg["function_approvals"]:
# Create FunctionCallContent with the modified arguments
func_call = FunctionCallContent(
call_id=approval.get("call_id", ""),
name=approval.get("name", ""),
arguments=approval.get("arguments", {}),
)
# Create the approval response
approval_response = FunctionApprovalResponseContent(
approved=approval.get("approved", True),
id=approval.get("id", ""),
function_call=func_call,
)
contents.append(approval_response)
chat_msg = ChatMessage(role=role, contents=contents) # type: ignore[arg-type]
else:
# Regular text message
content = msg.get("content", "")
if isinstance(content, str):
chat_msg = ChatMessage(role=role, contents=[TextContent(text=content)])
else:
chat_msg = ChatMessage(role=role, contents=[TextContent(text=str(content))])
if "id" in msg:
chat_msg.message_id = msg["id"]
result.append(chat_msg)
return result
def agent_framework_messages_to_agui(messages: list[ChatMessage]) -> list[dict[str, Any]]:
"""Convert Agent Framework messages to AG-UI format.
Args:
messages: List of Agent Framework ChatMessage objects
Returns:
List of AG-UI message dictionaries
"""
result: list[dict[str, Any]] = []
for msg in messages:
role = _FRAMEWORK_TO_AGUI_ROLE.get(msg.role, "user")
content_text = ""
tool_calls: list[dict[str, Any]] = []
for content in msg.contents:
if isinstance(content, TextContent):
content_text += content.text
elif isinstance(content, FunctionCallContent):
tool_calls.append(
{
"id": content.call_id,
"type": "function",
"function": {
"name": content.name,
"arguments": content.arguments,
},
}
)
agui_msg: dict[str, Any] = {
"role": role,
"content": content_text,
}
if msg.message_id:
agui_msg["id"] = msg.message_id
if tool_calls:
agui_msg["tool_calls"] = tool_calls
result.append(agui_msg)
return result
def extract_text_from_contents(contents: list[Any]) -> str:
"""Extract text from Agent Framework contents.
Args:
contents: List of content objects
Returns:
Concatenated text
"""
text_parts: list[str] = []
for content in contents:
if isinstance(content, TextContent):
text_parts.append(content.text)
elif hasattr(content, "text"):
text_parts.append(content.text)
return "".join(text_parts)
__all__ = [
"agui_messages_to_agent_framework",
"agent_framework_messages_to_agui",
"extract_text_from_contents",
]
@@ -0,0 +1,439 @@
# Copyright (c) Microsoft. All rights reserved.
"""Orchestrators for multi-turn agent flows."""
import json
import logging
import uuid
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Any
from ag_ui.core import (
BaseEvent,
RunErrorEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
)
from agent_framework import AgentProtocol, AgentThread, TextContent
from ._utils import generate_event_id
if TYPE_CHECKING:
from ._agent import AgentConfig
from ._confirmation_strategies import ConfirmationStrategy
logger = logging.getLogger(__name__)
class ExecutionContext:
"""Shared context for orchestrators."""
def __init__(
self,
input_data: dict[str, Any],
agent: AgentProtocol,
config: "AgentConfig", # noqa: F821
confirmation_strategy: "ConfirmationStrategy | None" = None, # noqa: F821
):
"""Initialize execution context.
Args:
input_data: AG-UI run input containing messages, state, etc.
agent: The Agent Framework agent to execute
config: Agent configuration
confirmation_strategy: Strategy for generating confirmation messages
"""
self.input_data = input_data
self.agent = agent
self.config = config
self.confirmation_strategy = confirmation_strategy
# Lazy-loaded properties
self._messages = None
self._last_message = None
self._run_id: str | None = None
self._thread_id: str | None = None
@property
def messages(self):
"""Get converted Agent Framework messages (lazy loaded)."""
if self._messages is None:
from ._message_adapters import agui_messages_to_agent_framework
raw = self.input_data.get("messages", [])
self._messages = agui_messages_to_agent_framework(raw)
return self._messages
@property
def last_message(self):
"""Get the last message in the conversation (lazy loaded)."""
if self._last_message is None and self.messages:
self._last_message = self.messages[-1]
return self._last_message
@property
def run_id(self) -> str:
"""Get or generate run ID."""
if self._run_id is None:
self._run_id = self.input_data.get("run_id") or str(uuid.uuid4())
# This should never be None after the if block above, but satisfy type checkers
if self._run_id is None: # pragma: no cover
raise RuntimeError("Failed to initialize run_id")
return self._run_id
@property
def thread_id(self) -> str:
"""Get or generate thread ID."""
if self._thread_id is None:
self._thread_id = self.input_data.get("thread_id") or str(uuid.uuid4())
# This should never be None after the if block above, but satisfy type checkers
if self._thread_id is None: # pragma: no cover
raise RuntimeError("Failed to initialize thread_id")
return self._thread_id
class Orchestrator(ABC):
"""Base orchestrator for agent execution flows."""
@abstractmethod
def can_handle(self, context: ExecutionContext) -> bool:
"""Determine if this orchestrator handles the current request.
Args:
context: Execution context with input data and agent
Returns:
True if this orchestrator should handle the request
"""
...
@abstractmethod
async def run(
self,
context: ExecutionContext,
) -> AsyncGenerator[BaseEvent, None]:
"""Execute the orchestration and yield events.
Args:
context: Execution context
Yields:
AG-UI events
"""
# This is never executed - just satisfies mypy's requirement for async generators
if False: # pragma: no cover
yield
raise NotImplementedError
class HumanInTheLoopOrchestrator(Orchestrator):
"""Handles tool approval responses from user."""
def can_handle(self, context: ExecutionContext) -> bool:
"""Check if last message is a tool approval response.
Args:
context: Execution context
Returns:
True if last message is a tool result
"""
msg = context.last_message
if not msg or not hasattr(msg, "metadata"):
return False
metadata = getattr(msg, "metadata", None)
if not metadata:
return False
return bool(metadata.get("is_tool_result", False))
async def run(
self,
context: ExecutionContext,
) -> AsyncGenerator[BaseEvent, None]:
"""Process approval response and generate confirmation events.
This implementation is extracted from the legacy _agent.py lines 144-244.
Args:
context: Execution context
Yields:
AG-UI events (TextMessage, RunFinished)
"""
from ._confirmation_strategies import DefaultConfirmationStrategy
from ._events import AgentFrameworkEventBridge
logger.info("=== TOOL RESULT DETECTED (HumanInTheLoopOrchestrator) ===")
# Create event bridge for run events
event_bridge = AgentFrameworkEventBridge(
run_id=context.run_id,
thread_id=context.thread_id,
)
# CRITICAL: Every AG-UI run must start with RunStartedEvent
yield event_bridge.create_run_started_event()
# Get confirmation strategy (use default if none provided)
strategy = context.confirmation_strategy
if strategy is None:
strategy = DefaultConfirmationStrategy()
# Parse the tool result content
tool_content_text = ""
last_message = context.last_message
if last_message:
for content in last_message.contents:
if isinstance(content, TextContent):
tool_content_text = content.text
break
try:
tool_result = json.loads(tool_content_text)
accepted = tool_result.get("accepted", False)
steps = tool_result.get("steps", [])
logger.info(f" Accepted: {accepted}")
logger.info(f" Steps count: {len(steps)}")
# Emit a text message confirming execution
message_id = generate_event_id()
yield TextMessageStartEvent(message_id=message_id, role="assistant")
# Check if this is confirm_changes (no steps) or function approval (has steps)
if not steps:
# This is confirm_changes for predictive state updates
if accepted:
confirmation_message = strategy.on_state_confirmed()
else:
confirmation_message = strategy.on_state_rejected()
elif accepted:
# User approved - execute the enabled steps (function approval flow)
confirmation_message = strategy.on_approval_accepted(steps)
else:
# User rejected
confirmation_message = strategy.on_approval_rejected(steps)
yield TextMessageContentEvent(
message_id=message_id,
delta=confirmation_message,
)
yield TextMessageEndEvent(message_id=message_id)
# Emit run finished
yield event_bridge.create_run_finished_event()
except json.JSONDecodeError:
logger.error(f"Failed to parse tool result: {tool_content_text}")
yield RunErrorEvent(message=f"Invalid tool result format: {tool_content_text[:100]}")
yield event_bridge.create_run_finished_event()
class DefaultOrchestrator(Orchestrator):
"""Standard agent execution (no special handling)."""
def can_handle(self, context: ExecutionContext) -> bool:
"""Always returns True as this is the fallback orchestrator.
Args:
context: Execution context
Returns:
Always True
"""
return True
async def run(
self,
context: ExecutionContext,
) -> AsyncGenerator[BaseEvent, None]:
"""Standard agent run with event translation.
This implements the default agent execution flow using the event bridge
to translate Agent Framework events to AG-UI events.
Args:
context: Execution context
Yields:
AG-UI events
"""
from ._events import AgentFrameworkEventBridge
logger.info(f"Starting default agent run for thread_id={context.thread_id}, run_id={context.run_id}")
# Initialize state tracking
initial_state = context.input_data.get("state", {})
current_state: dict[str, Any] = initial_state.copy() if initial_state else {}
# Check if agent uses structured outputs (response_format)
chat_options = getattr(context.agent, "chat_options", None)
response_format = getattr(chat_options, "response_format", None) if chat_options else None
skip_text_content = response_format is not None
# Create event bridge
event_bridge = AgentFrameworkEventBridge(
run_id=context.run_id,
thread_id=context.thread_id,
predict_state_config=context.config.predict_state_config,
current_state=current_state,
skip_text_content=skip_text_content,
input_messages=context.input_data.get("messages", []),
require_confirmation=context.config.require_confirmation,
)
yield event_bridge.create_run_started_event()
# Emit PredictState custom event if we have predictive state config
if context.config.predict_state_config:
from ag_ui.core import CustomEvent, EventType
predict_state_value = [
{
"state_key": state_key,
"tool": config["tool"],
"tool_argument": config["tool_argument"],
}
for state_key, config in context.config.predict_state_config.items()
]
yield CustomEvent(
type=EventType.CUSTOM,
name="PredictState",
value=predict_state_value,
)
# If we have a state schema, ensure we emit initial state snapshot
if context.config.state_schema:
# Initialize missing state fields with appropriate empty values based on schema type
for key, schema in context.config.state_schema.items():
if key not in current_state:
# Default to empty object; use empty array if schema specifies "array" type
current_state[key] = [] if isinstance(schema, dict) and schema.get("type") == "array" else {} # type: ignore
yield event_bridge.create_state_snapshot_event(current_state)
# Create thread for context tracking
thread = AgentThread()
thread.metadata = { # type: ignore[attr-defined]
"ag_ui_thread_id": context.thread_id,
"ag_ui_run_id": context.run_id,
}
# Inject current state into thread metadata so agent can access it
if current_state:
thread.metadata["current_state"] = current_state # type: ignore[attr-defined]
# Add incoming AG-UI messages to the thread history
if context.messages:
await thread.on_new_messages(context.messages)
# Get the last message as the new input
new_message = context.last_message
if not new_message:
logger.warning("No messages provided in AG-UI input")
yield event_bridge.create_run_finished_event()
return
# Inject current state as system message context if we have state
messages_to_run: list[Any] = []
if current_state and context.config.state_schema:
state_json = json.dumps(current_state, indent=2)
from agent_framework import ChatMessage
state_context_msg = ChatMessage(
role="system",
contents=[
TextContent(
text=f"""Current state of the application:
{state_json}
When modifying state, you MUST include ALL existing data plus your changes.
For example, if adding a new ingredient, include all existing ingredients PLUS the new one.
Never replace existing data - always append or merge."""
)
],
)
messages_to_run.append(state_context_msg)
messages_to_run.append(new_message)
# Collect all updates to get the final structured output
all_updates: list[Any] = []
async for update in context.agent.run_stream(messages_to_run, thread=thread):
all_updates.append(update)
events = await event_bridge.from_agent_run_update(update)
for event in events:
yield event
# After agent completes, check if we should stop (waiting for user to confirm changes)
if event_bridge.should_stop_after_confirm:
logger.info(" >>> Stopping run after confirm_changes - waiting for user response")
yield event_bridge.create_run_finished_event()
return
# After streaming completes, check if agent has response_format and extract structured output
if all_updates and response_format:
from agent_framework import AgentRunResponse
from pydantic import BaseModel
logger.info(f"Processing structured output, update count: {len(all_updates)}")
# Convert streaming updates to final response to get the structured output
final_response = AgentRunResponse.from_agent_run_response_updates(
all_updates, output_format_type=response_format
)
if final_response.value and isinstance(final_response.value, BaseModel):
# Convert Pydantic model to dict
response_dict = final_response.value.model_dump(mode="json", exclude_none=True)
logger.info(f"Received structured output: {list(response_dict.keys())}")
# Extract state fields based on state_schema
state_updates: dict[str, Any] = {}
if context.config.state_schema:
# Use state_schema to determine which fields are state
for state_key in context.config.state_schema.keys():
if state_key in response_dict:
state_updates[state_key] = response_dict[state_key]
else:
# No schema: treat all non-message fields as state
state_updates = {k: v for k, v in response_dict.items() if k != "message"}
# Apply state updates if any found
if state_updates:
current_state.update(state_updates)
# Emit StateSnapshotEvent with the updated state
state_snapshot = event_bridge.create_state_snapshot_event(current_state)
yield state_snapshot
logger.info(f"Emitted StateSnapshotEvent with updates: {list(state_updates.keys())}")
# If there's a message field, emit it as chat text
if "message" in response_dict and response_dict["message"]:
message_id = generate_event_id()
yield TextMessageStartEvent(message_id=message_id, role="assistant")
yield TextMessageContentEvent(message_id=message_id, delta=response_dict["message"])
yield TextMessageEndEvent(message_id=message_id)
logger.info(f"Emitted conversational message: {response_dict['message'][:100]}...")
if event_bridge.current_message_id:
yield event_bridge.create_message_end_event(event_bridge.current_message_id)
yield event_bridge.create_run_finished_event()
logger.info(f"Completed agent run for thread_id={context.thread_id}, run_id={context.run_id}")
__all__ = [
"Orchestrator",
"ExecutionContext",
"HumanInTheLoopOrchestrator",
"DefaultOrchestrator",
]
@@ -0,0 +1,27 @@
# Copyright (c) Microsoft. All rights reserved.
"""Type definitions for AG-UI integration."""
from typing import Any, TypedDict
class PredictStateConfig(TypedDict):
"""Configuration for predictive state updates."""
state_key: str
tool: str
tool_argument: str | None
class RunMetadata(TypedDict):
"""Metadata for agent run."""
run_id: str
thread_id: str
predict_state: list[PredictStateConfig] | None
class AgentState(TypedDict):
"""Base state for AG-UI agents."""
messages: list[Any] | None
@@ -0,0 +1,57 @@
# Copyright (c) Microsoft. All rights reserved.
"""Utility functions for AG-UI integration."""
import copy
import uuid
from dataclasses import asdict, is_dataclass
from datetime import date, datetime
from typing import Any
def generate_event_id() -> str:
"""Generate a unique event ID."""
return str(uuid.uuid4())
def merge_state(current: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
"""Merge state updates.
Args:
current: Current state dictionary
update: Update to apply
Returns:
Merged state
"""
result = copy.deepcopy(current)
result.update(update)
return result
def make_json_safe(obj: Any) -> Any: # noqa: ANN401
"""Make an object JSON serializable.
Args:
obj: Object to make JSON safe
Returns:
JSON-serializable version of the object
"""
if obj is None or isinstance(obj, (str, int, float, bool)):
return obj
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if is_dataclass(obj):
return asdict(obj) # type: ignore[arg-type]
if hasattr(obj, "model_dump"):
return obj.model_dump() # type: ignore[no-any-return]
if hasattr(obj, "dict"):
return obj.dict() # type: ignore[no-any-return]
if hasattr(obj, "__dict__"):
return {key: make_json_safe(value) for key, value in vars(obj).items()} # type: ignore[misc]
if isinstance(obj, (list, tuple)):
return [make_json_safe(item) for item in obj] # type: ignore[misc]
if isinstance(obj, dict):
return {key: make_json_safe(value) for key, value in obj.items()} # type: ignore[misc]
return str(obj)
@@ -0,0 +1 @@
# Marker file for PEP 561
@@ -0,0 +1,3 @@
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
AZURE_OPENAI_API_KEY=your-api-key-here
PORT=8000
+5
View File
@@ -0,0 +1,5 @@
{
"python.analysis.extraPaths": [
"${workspaceFolder}/packages/ag-ui/examples"
]
}
+243
View File
@@ -0,0 +1,243 @@
# Agent Framework AG-UI Integration
AG-UI protocol integration for Agent Framework, enabling seamless integration with AG-UI's web interface and streaming protocol.
## Installation
```bash
pip install agent-framework-ag-ui
```
## Quick Start
```python
from fastapi import FastAPI
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
# Create your agent
agent = ChatAgent(
name="my_agent",
instructions="You are a helpful assistant.",
chat_client=AzureOpenAIChatClient(model_id="gpt-4o"),
)
# Create FastAPI app and add AG-UI endpoint
app = FastAPI()
add_agent_framework_fastapi_endpoint(app, agent, "/agent")
# Run with: uvicorn main:app --reload
```
## Features
This integration supports all 7 AG-UI features:
1. **Agentic Chat**: Basic streaming chat with tool calling support
2. **Backend Tool Rendering**: Tools executed on backend with results streamed via ToolCallResultEvent
3. **Human in the Loop**: Function approval requests for user confirmation before tool execution
4. **Agentic Generative UI**: Async tools for long-running operations with progress updates
5. **Tool-based Generative UI**: Custom UI components rendered on frontend based on tool calls
6. **Shared State**: Bidirectional state sync using StateSnapshotEvent and StateDeltaEvent
7. **Predictive State Updates**: Stream tool arguments as optimistic state updates during execution
## Examples
Complete examples for all features are in the `examples/` directory:
- `examples/agents/simple_agent.py` - Basic agentic chat
- `examples/agents/weather_agent.py` - Backend tool rendering
- `examples/agents/task_planner_agent.py` - Human in the loop with approvals
- `examples/agents/research_assistant_agent.py` - Agentic generative UI
- `examples/agents/ui_generator_agent.py` - Tool-based generative UI
- `examples/agents/recipe_agent.py` - Shared state management
- `examples/agents/document_writer_agent.py` - Predictive state updates
- `examples/server/main.py` - FastAPI server with all endpoints
Run the example server:
```bash
cd examples/server
uvicorn main:app --reload
```
To enable debug logging:
```bash
ENABLE_DEBUG_LOGGING=1 uvicorn main:app --reload
```
The server exposes endpoints at:
- `/agentic_chat`
- `/backend_tool_rendering`
- `/human_in_the_loop`
- `/agentic_generative_ui`
- `/tool_based_generative_ui`
- `/shared_state`
- `/predictive_state_updates`
## Architecture
The package uses a clean, orchestrator-based architecture:
- **AgentFrameworkAgent**: Lightweight wrapper that delegates to orchestrators
- **Orchestrators**: Handle different execution flows (default, human-in-the-loop, etc.)
- **Confirmation Strategies**: Domain-specific confirmation messages (extensible)
- **AgentFrameworkEventBridge**: Converts AgentRunResponseUpdate to AG-UI events
- **Message Adapters**: Bidirectional conversion between AG-UI and Agent Framework message formats
- **FastAPI Endpoint**: Streaming HTTP endpoint with Server-Sent Events (SSE)
### Key Design Patterns
- **Orchestrator Pattern**: Separates flow control from protocol translation
- **Strategy Pattern**: Pluggable confirmation message strategies
- **Context Object**: Lazy-loaded execution context passed to orchestrators
- **Event Bridge**: Stateless translation of Agent Framework events to AG-UI events
## Advanced Usage
### Shared State
State is injected as system messages and updated via predictive state updates:
```python
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import AgentFrameworkAgent
# Create your agent
agent = ChatAgent(
name="recipe_agent",
chat_client=AzureOpenAIChatClient(model_id="gpt-4o"),
)
state_schema = {
"recipe": {
"type": "object",
"properties": {
"name": {"type": "string"},
"ingredients": {"type": "array"}
}
}
}
# Configure which tool updates which state fields
predict_state_config = {
"recipe": {"tool": "update_recipe", "tool_argument": "recipe_data"}
}
wrapped_agent = AgentFrameworkAgent(
agent=agent,
state_schema=state_schema,
predict_state_config=predict_state_config,
)
```
### Predictive State Updates
Predictive state updates automatically stream tool arguments as optimistic state updates:
```python
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import AgentFrameworkAgent
# Create your agent
agent = ChatAgent(
name="document_writer",
chat_client=AzureOpenAIChatClient(model_id="gpt-4o"),
)
predict_state_config = {
"current_title": {"tool": "write_document", "tool_argument": "title"},
"current_content": {"tool": "write_document", "tool_argument": "content"},
}
wrapped_agent = AgentFrameworkAgent(
agent=agent,
state_schema={"current_title": {"type": "string"}, "current_content": {"type": "string"}},
predict_state_config=predict_state_config,
require_confirmation=True, # User can approve/reject changes
)
```
### Custom Confirmation Strategies
Provide domain-specific confirmation messages:
```python
from typing import Any
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import AgentFrameworkAgent, ConfirmationStrategy
class CustomConfirmationStrategy(ConfirmationStrategy):
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
return "Your custom approval message!"
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
return "Your custom rejection message!"
def on_state_confirmed(self) -> str:
return "State changes confirmed!"
def on_state_rejected(self) -> str:
return "State changes rejected!"
agent = ChatAgent(
name="custom_agent",
chat_client=AzureOpenAIChatClient(model_id="gpt-4o"),
)
wrapped_agent = AgentFrameworkAgent(
agent=agent,
confirmation_strategy=CustomConfirmationStrategy(),
)
```
### Human in the Loop
Human-in-the-loop is automatically handled when tools are marked for approval:
```python
from agent_framework import ai_function
@ai_function(approval_mode="always_require")
def sensitive_action(param: str) -> str:
"""This action requires user approval."""
return f"Executed with {param}"
# The orchestrator automatically detects approval responses and handles them
```
### Custom Orchestrators
Add custom execution flows by implementing the Orchestrator pattern:
```python
from agent_framework_ag_ui._orchestrators import Orchestrator, ExecutionContext
class MyCustomOrchestrator(Orchestrator):
def can_handle(self, context: ExecutionContext) -> bool:
# Return True if this orchestrator should handle the request
return context.input_data.get("custom_mode") == True
async def run(self, context: ExecutionContext):
# Custom execution logic
yield RunStartedEvent(...)
# ... your custom flow
yield RunFinishedEvent(...)
wrapped_agent = AgentFrameworkAgent(
agent=your_agent,
orchestrators=[MyCustomOrchestrator(), DefaultOrchestrator()],
)
## Documentation
For detailed documentation, see [DESIGN.md](DESIGN.md).
## License
MIT
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,8 @@
# Copyright (c) Microsoft. All rights reserved.
"""Entry point for running the AG-UI examples server as a module."""
from .server.main import main
if __name__ == "__main__":
main()
@@ -0,0 +1,3 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example agents for AG-UI demonstration."""
@@ -0,0 +1,58 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example agent demonstrating predictive state updates with document writing."""
from agent_framework import ChatAgent, ai_function
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy
@ai_function
def write_document_local(document: str) -> str:
"""Write a document. Use markdown formatting to format the document.
It's good to format the document extensively so it's easy to read.
You can use all kinds of markdown.
However, do not use italic or strike-through formatting, it's reserved for another purpose.
You MUST write the full document, even when changing only a few words.
When making edits to the document, try to make them minimal - do not change every word.
Keep stories SHORT!
Args:
document: The complete document content in markdown format
Returns:
Confirmation that the document was written
"""
return "Document written."
agent = ChatAgent(
name="document_writer",
instructions=(
"You are a helpful assistant for writing documents. "
"To write the document, you MUST use the write_document_local tool. "
"You MUST write the full document, even when changing only a few words. "
"When you wrote the document, DO NOT repeat it as a message. "
"Just briefly summarize the changes you made. 2 sentences max. "
"\n\n"
"The current state of the document will be provided to you. "
"When editing, make minimal changes - do not change every word unless requested."
),
chat_client=AzureOpenAIChatClient(),
tools=[write_document_local],
)
document_writer_agent = AgentFrameworkAgent(
agent=agent,
name="DocumentWriter",
description="Writes and edits documents with predictive state updates",
state_schema={
"document": {"type": "string", "description": "The current document content"},
},
predict_state_config={
"document": {"tool": "write_document_local", "tool_argument": "document"},
},
confirmation_strategy=DocumentWriterConfirmationStrategy(),
)
@@ -0,0 +1,76 @@
# Copyright (c) Microsoft. All rights reserved.
"""Human-in-the-loop agent demonstrating step customization (Feature 5)."""
from enum import Enum
from agent_framework import ChatAgent, ai_function
from agent_framework.azure import AzureOpenAIChatClient
from pydantic import BaseModel, Field
class StepStatus(str, Enum):
"""Status of a task step."""
ENABLED = "enabled"
DISABLED = "disabled"
class TaskStep(BaseModel):
"""A single step in a task execution plan."""
description: str = Field(..., description="The text of the step in imperative form (e.g., 'Dig hole', 'Open door')")
status: StepStatus = Field(default=StepStatus.ENABLED, description="Whether the step is enabled or disabled")
@ai_function(
name="generate_task_steps",
description="Generate execution steps for a task",
approval_mode="always_require",
)
def generate_task_steps(steps: list[TaskStep]) -> str:
"""Make up 10 steps (only a couple of words per step) that are required for a task.
The step should be in imperative form (i.e. Dig hole, Open door, ...).
Each step will have status='enabled' by default.
Args:
steps: An array of 10 step objects, each containing description and status
Returns:
Confirmation message
"""
return f"Generated {len(steps)} execution steps for the task."
# Create the human-in-the-loop agent using tool-based approach for predictive state
human_in_the_loop_agent = ChatAgent(
name="human_in_the_loop_agent",
instructions="""You are a helpful assistant that can perform any task by breaking it down into steps.
When asked to perform a task, you MUST call the `generate_task_steps` function with the proper
number of steps per the request.
Rules for steps:
- Each step description should be in imperative form (e.g., "Dig hole", "Open door", "Prepare ingredients")
- Each step should be brief (only a couple of words)
- All steps must have status='enabled' initially
Example steps for "Build a robot":
1. "Design blueprint"
2. "Gather components"
3. "Assemble frame"
4. "Install motors"
5. "Wire electronics"
6. "Program controller"
7. "Test movements"
8. "Add sensors"
9. "Calibrate systems"
10. "Final testing"
After calling the function, provide a brief acknowledgment like:
"I've created a plan with 10 steps. You can customize which steps to enable before I proceed."
""",
chat_client=AzureOpenAIChatClient(),
tools=[generate_task_steps],
)
@@ -0,0 +1,122 @@
# Copyright (c) Microsoft. All rights reserved.
"""Recipe agent example demonstrating shared state management (Feature 3)."""
from enum import Enum
from agent_framework import ChatAgent, ai_function
from agent_framework.azure import AzureOpenAIChatClient
from pydantic import BaseModel, Field
from agent_framework_ag_ui import AgentFrameworkAgent, RecipeConfirmationStrategy
class SkillLevel(str, Enum):
"""The skill level required for the recipe."""
BEGINNER = "Beginner"
INTERMEDIATE = "Intermediate"
ADVANCED = "Advanced"
class CookingTime(str, Enum):
"""The cooking time of the recipe."""
FIVE_MIN = "5 min"
FIFTEEN_MIN = "15 min"
THIRTY_MIN = "30 min"
FORTY_FIVE_MIN = "45 min"
SIXTY_PLUS_MIN = "60+ min"
class Ingredient(BaseModel):
"""An ingredient with its details."""
icon: str = Field(..., description="Emoji icon representing the ingredient (e.g., 🥕)")
name: str = Field(..., description="Name of the ingredient")
amount: str = Field(..., description="Amount or quantity of the ingredient")
class Recipe(BaseModel):
"""A complete recipe."""
title: str = Field(..., description="The title of the recipe")
skill_level: SkillLevel = Field(..., description="The skill level required")
special_preferences: list[str] = Field(
default_factory=list, description="Dietary preferences (e.g., Vegetarian, Gluten-free)"
)
cooking_time: CookingTime = Field(..., description="The estimated cooking time")
ingredients: list[Ingredient] = Field(..., description="Complete list of ingredients")
instructions: list[str] = Field(..., description="Step-by-step cooking instructions")
@ai_function
def update_recipe(recipe: Recipe) -> str:
"""Update the recipe with new or modified content.
You MUST write the complete recipe with ALL fields, even when changing only a few items.
When modifying an existing recipe, include ALL existing ingredients and instructions plus your changes.
NEVER delete existing data - only add or modify.
Args:
recipe: The complete recipe object with all details
Returns:
Confirmation that the recipe was updated
"""
return "Recipe updated."
# Create the recipe agent using tool-based approach for streaming
agent = ChatAgent(
name="recipe_agent",
instructions="""You are a helpful recipe assistant that creates and modifies recipes.
CRITICAL RULES:
1. You will receive the current recipe state in the system context
2. To update the recipe, you MUST use the update_recipe tool
3. When modifying a recipe, ALWAYS include ALL existing data plus your changes in the tool call
4. NEVER delete existing ingredients or instructions - only add or modify
5. After calling the tool, provide a brief conversational message (1-2 sentences)
When creating a NEW recipe:
- Provide all required fields: title, skill_level, cooking_time, ingredients, instructions
- Use actual emojis for ingredient icons (🥕 🧄 🧅 🍅 🌿 🍗 🥩 🧀)
- Leave special_preferences empty unless specified
- Message: "Here's your recipe!" or similar
When MODIFYING or IMPROVING an existing recipe:
- Include ALL existing ingredients + any new ones
- Include ALL existing instructions + any new/modified ones
- Update other fields as needed
- Message: Explain what you improved (e.g., "I upgraded the ingredients to premium quality")
- When asked to "improve", enhance with:
* Better ingredients (upgrade quality, add complementary flavors)
* More detailed instructions
* Professional techniques
* Adjust skill_level if complexity changes
* Add relevant special_preferences
Example improvements:
- Upgrade "chicken" → "organic free-range chicken breast"
- Add herbs: basil, oregano, thyme
- Add aromatics: garlic, shallots
- Add finishing touches: lemon zest, fresh parsley
- Make instructions more detailed and professional
""",
chat_client=AzureOpenAIChatClient(),
tools=[update_recipe],
)
recipe_agent = AgentFrameworkAgent(
agent=agent,
name="RecipeAgent",
description="Creates and modifies recipes with streaming state updates",
state_schema={
"recipe": {"type": "object", "description": "The current recipe"},
},
predict_state_config={
"recipe": {"tool": "update_recipe", "tool_argument": "recipe"},
},
confirmation_strategy=RecipeConfirmationStrategy(),
)
@@ -0,0 +1,100 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example agent demonstrating agentic generative UI with custom events during execution."""
import asyncio
from agent_framework import ChatAgent, ai_function
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import AgentFrameworkAgent
@ai_function
async def research_topic(topic: str) -> str:
"""Research a topic and generate a comprehensive report.
Args:
topic: The topic to research
Returns:
Research report
"""
# Simulate multi-step research process
steps = [
("Searching databases", 1.0),
("Analyzing sources", 1.5),
("Synthesizing information", 1.0),
("Generating report", 0.5),
]
results: list[str] = []
for step_name, duration in steps:
await asyncio.sleep(duration)
results.append(f"- {step_name}: completed")
return f"Research report on '{topic}':\n" + "\n".join(results)
@ai_function
async def create_presentation(title: str, num_slides: int) -> str:
"""Create a presentation with multiple slides.
Args:
title: Presentation title
num_slides: Number of slides to create
Returns:
Presentation summary
"""
# Simulate slide generation
slides: list[str] = []
for i in range(num_slides):
await asyncio.sleep(0.5)
slides.append(f"Slide {i + 1}: Content for {title}")
return f"Created presentation '{title}' with {num_slides} slides:\n" + "\n".join(slides)
@ai_function
async def analyze_data(dataset: str) -> str:
"""Analyze a dataset and produce insights.
Args:
dataset: The dataset name to analyze
Returns:
Analysis results
"""
# Simulate data analysis phases
phases = [
("Loading data", 0.8),
("Cleaning data", 1.0),
("Running statistical analysis", 1.2),
("Generating visualizations", 0.7),
]
insights: list[str] = []
for phase_name, duration in phases:
await asyncio.sleep(duration)
insights.append(f"- {phase_name}: done")
return f"Analysis of '{dataset}':\n" + "\n".join(insights)
agent = ChatAgent(
name="research_assistant",
instructions=(
"You are a research and analysis assistant. "
"You can research topics, create presentations, and analyze data. "
"Use the available tools to help users with their research needs."
),
chat_client=AzureOpenAIChatClient(),
tools=[research_topic, create_presentation, analyze_data],
)
research_assistant_agent = AgentFrameworkAgent(
agent=agent,
name="ResearchAssistant",
description="Research assistant that emits progress events during task execution",
)
@@ -0,0 +1,13 @@
# Copyright (c) Microsoft. All rights reserved.
"""Simple agentic chat example (Feature 1: Agentic Chat)."""
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
# Create a simple chat agent
agent = ChatAgent(
name="simple_chat_agent",
instructions="You are a helpful assistant. Be concise and friendly.",
chat_client=AzureOpenAIChatClient(),
)
@@ -0,0 +1,73 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example agent demonstrating human-in-the-loop with function approvals."""
from agent_framework import ChatAgent, ai_function
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import AgentFrameworkAgent, TaskPlannerConfirmationStrategy
@ai_function(approval_mode="always_require")
def create_calendar_event(title: str, date: str, time: str) -> str:
"""Create a calendar event.
Args:
title: The event title
date: The event date (YYYY-MM-DD)
time: The event time (HH:MM)
Returns:
Confirmation message
"""
return f"Calendar event '{title}' created for {date} at {time}"
@ai_function(approval_mode="always_require")
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email.
Args:
to: Recipient email address
subject: Email subject
body: Email body text
Returns:
Confirmation message
"""
return f"Email sent to {to} with subject '{subject}'"
@ai_function(approval_mode="always_require")
def book_meeting_room(room_name: str, date: str, start_time: str, end_time: str) -> str:
"""Book a meeting room.
Args:
room_name: The meeting room name
date: The booking date (YYYY-MM-DD)
start_time: Start time (HH:MM)
end_time: End time (HH:MM)
Returns:
Confirmation message
"""
return f"Meeting room '{room_name}' booked for {date} from {start_time} to {end_time}"
agent = ChatAgent(
name="task_planner",
instructions=(
"You are a helpful assistant that plans and executes tasks. "
"You have access to calendar, email, and meeting room booking functions. "
"All of these actions require user approval before execution."
),
chat_client=AzureOpenAIChatClient(),
tools=[create_calendar_event, send_email, book_meeting_room],
)
task_planner_agent = AgentFrameworkAgent(
agent=agent,
name="TaskPlanner",
description="Plans and executes tasks with user approval",
confirmation_strategy=TaskPlannerConfirmationStrategy(),
)
@@ -0,0 +1,318 @@
# Copyright (c) Microsoft. All rights reserved.
"""Task steps agent demonstrating agentic generative UI (Feature 6)."""
import asyncio
from collections.abc import AsyncGenerator
from enum import Enum
from typing import Any
from ag_ui.core import (
EventType,
MessagesSnapshotEvent,
RunFinishedEvent,
StateDeltaEvent,
StateSnapshotEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallStartEvent,
)
from agent_framework import ChatAgent, ai_function
from agent_framework.azure import AzureOpenAIChatClient
from pydantic import BaseModel, Field
from agent_framework_ag_ui import AgentFrameworkAgent
class StepStatus(str, Enum):
"""Status of a task step."""
PENDING = "pending"
COMPLETED = "completed"
class TaskStep(BaseModel):
"""A single step in a task."""
description: str = Field(
..., description="The text of the step in gerund form (e.g., 'Digging hole', 'Opening door')"
)
status: StepStatus = Field(default=StepStatus.PENDING, description="The status of the step")
@ai_function
def generate_task_steps(steps: list[TaskStep]) -> str:
"""Generate a list of task steps for completing a task.
Args:
steps: Complete list of task steps with descriptions and status
Returns:
Confirmation that steps were generated
"""
return "Steps generated."
# Create the task steps agent using tool-based approach for streaming
agent = ChatAgent(
name="task_steps_agent",
instructions="""You are a helpful assistant that breaks down tasks into actionable steps.
When asked to perform a task, you MUST:
1. Use the generate_task_steps tool to create the steps
2. Pay attention to how many steps the user requests (if specified)
3. If no specific number is mentioned, use a reasonable number of steps (typically 5-10)
4. Each step description should be in gerund form (e.g., "Designing spacecraft", "Training astronauts")
5. Each step should be brief (only 2-4 words)
6. All steps must have status='pending'
7. After calling the tool, provide a brief conversational message (one sentence) saying you created the plan
Example steps for "Build a treehouse in 5 steps":
- "Selecting location"
- "Gathering materials"
- "Assembling frame"
- "Installing platform"
- "Adding finishing touches"
""",
chat_client=AzureOpenAIChatClient(),
tools=[generate_task_steps],
)
task_steps_agent = AgentFrameworkAgent(
agent=agent,
name="TaskStepsAgent",
description="Generates task steps with streaming state updates",
state_schema={
"steps": {"type": "array", "description": "The list of task steps"},
},
predict_state_config={
"steps": {
"tool": "generate_task_steps",
"tool_argument": "steps",
}
},
require_confirmation=False, # Agentic generative UI updates automatically without confirmation
)
# Wrap the agent's run method to add step execution simulation
class TaskStepsAgentWithExecution:
"""Wrapper that adds step execution simulation after plan generation.
This wrapper delegates to AgentFrameworkAgent but is recognized as compatible
by add_agent_framework_fastapi_endpoint since it implements run_agent().
"""
def __init__(self, base_agent: AgentFrameworkAgent):
"""Initialize wrapper with base agent."""
self._base_agent = base_agent
@property
def name(self) -> str:
"""Delegate to base agent."""
return self._base_agent.name
@property
def description(self) -> str:
"""Delegate to base agent."""
return self._base_agent.description
def __getattr__(self, name: str) -> Any:
"""Delegate all other attribute access to base agent."""
return getattr(self._base_agent, name)
async def run_agent(self, input_data: dict[str, Any]) -> AsyncGenerator[Any, None]:
"""Run the agent and then simulate step execution."""
import logging
import uuid
logger = logging.getLogger(__name__)
logger.info(">>> TaskStepsAgentWithExecution.run_agent() called - wrapper is active")
# First, run the base agent to generate the plan - buffer text messages
final_state: dict[str, Any] | None = None
run_finished_event: Any = None
tool_call_id: str | None = None
buffered_text_events: list[Any] = [] # Buffer text from first LLM call
async for event in self._base_agent.run_agent(input_data):
event_type_str = str(event.type) if hasattr(event, "type") else type(event).__name__
logger.info(f">>> Processing event: {event_type_str}")
match event:
case StateSnapshotEvent(snapshot=snapshot):
final_state = snapshot
logger.info(f">>> Captured STATE_SNAPSHOT event with state: {final_state}")
yield event
case RunFinishedEvent():
run_finished_event = event
logger.info(">>> Captured RUN_FINISHED event - will send after step execution and summary")
case ToolCallStartEvent(tool_call_id=call_id):
tool_call_id = call_id
logger.info(f">>> Captured tool_call_id: {tool_call_id}")
yield event
case TextMessageStartEvent() | TextMessageContentEvent() | TextMessageEndEvent():
buffered_text_events.append(event)
logger.info(f">>> Buffered {event_type_str} from first LLM call")
case _:
logger.info(f">>> Yielding event immediately: {event_type_str}")
yield event
logger.info(f">>> Base agent completed. Final state: {final_state}")
# Now simulate executing the steps
if final_state and "steps" in final_state:
steps = final_state["steps"]
logger.info(f">>> Starting step execution simulation for {len(steps)} steps")
for i in range(len(steps)):
logger.info(f">>> Simulating execution of step {i + 1}/{len(steps)}: {steps[i].get('description')}")
await asyncio.sleep(1.0) # Simulate work
# Update step to completed
steps[i]["status"] = "completed"
logger.info(f">>> Step {i + 1} marked as completed")
# Send delta event with manual JSON patch format
delta_event = StateDeltaEvent(
type=EventType.STATE_DELTA,
delta=[
{
"op": "replace",
"path": f"/steps/{i}/status",
"value": "completed",
}
],
)
logger.info(f">>> Yielding StateDeltaEvent for step {i + 1}")
yield delta_event
# Send final snapshot
final_snapshot = StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot={"steps": steps},
)
logger.info(">>> Yielding final StateSnapshotEvent with all steps completed")
yield final_snapshot
# SECOND LLM call: Stream summary from chat client directly
logger.info(">>> Making SECOND LLM call to generate summary after step execution")
# Get the underlying chat agent and client
chat_agent = self._base_agent.agent # type: ignore
chat_client = chat_agent.chat_client # type: ignore
# Build messages for summary call
from agent_framework._types import ChatMessage, TextContent
original_messages = input_data.get("messages", [])
# Convert to ChatMessage objects if needed
messages: list[ChatMessage] = []
for msg in original_messages:
if isinstance(msg, dict):
content_str = msg.get("content", "")
if isinstance(content_str, str):
messages.append(
ChatMessage(
role=msg.get("role", "user"),
contents=[TextContent(text=content_str)],
)
)
elif isinstance(msg, ChatMessage):
messages.append(msg)
# Add completion message
messages.append(
ChatMessage(
role="user",
contents=[
TextContent(
text="The steps have been successfully executed. Provide a brief one-sentence summary."
)
],
)
)
# Stream the LLM response and manually emit text events
logger.info(">>> Calling chat client for summary")
message_id = str(uuid.uuid4())
try:
# Emit TEXT_MESSAGE_START
yield TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START,
message_id=message_id,
role="assistant",
)
# Small delay to ensure START event is processed before CONTENT events
await asyncio.sleep(0.01)
# Stream completion
accumulated_text = ""
async for chunk in chat_client.get_streaming_response(messages=messages):
# chunk is ChatResponseUpdate
if hasattr(chunk, "text") and chunk.text:
accumulated_text += chunk.text
# Emit TEXT_MESSAGE_CONTENT
yield TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=message_id,
delta=chunk.text,
)
# Emit TEXT_MESSAGE_END
yield TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=message_id,
)
logger.info(f">>> Summary complete: {accumulated_text}")
# Build complete message for persistence
summary_message = {
"role": "assistant",
"content": accumulated_text,
"id": message_id,
}
final_messages = list(original_messages)
final_messages.append(summary_message)
# Emit MessagesSnapshotEvent to persist in history
yield MessagesSnapshotEvent(
type=EventType.MESSAGES_SNAPSHOT,
messages=final_messages,
)
except Exception as e:
logger.error(f">>> Error generating summary: {e}")
# Generate a new message ID for the error
error_message_id = str(uuid.uuid4())
# Yield TEXT_MESSAGE_START for error
yield TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START,
message_id=error_message_id,
role="assistant",
)
# Yield error message content
yield TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=error_message_id,
delta=f"[Summary generation error: {e!s}]",
)
# Yield TEXT_MESSAGE_END for error
yield TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=error_message_id,
)
else:
logger.warning(f">>> No steps found in final_state to execute. final_state={final_state}")
# Finally send the original RUN_FINISHED event
if run_finished_event:
logger.info(">>> Yielding original RUN_FINISHED event")
yield run_finished_event
# Export the wrapped agent
task_steps_agent_wrapped = TaskStepsAgentWithExecution(task_steps_agent)
@@ -0,0 +1,119 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example agent demonstrating Tool-based Generative UI (Feature 5)."""
from typing import Any
from agent_framework import ChatAgent, ai_function
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import AgentFrameworkAgent
@ai_function
def generate_haiku(english: list[str], japanese: list[str], image_name: str | None, gradient: str) -> str:
"""Generate a haiku with image and gradient background (FRONTEND_RENDER).
This tool generates UI for displaying a haiku with an image and gradient background.
The frontend should render this as a custom haiku component.
Args:
english: English haiku lines (exactly 3 lines)
japanese: Japanese haiku lines (exactly 3 lines)
image_name: Image filename for visual accompaniment. Must be one of:
- "Osaka_Castle_Turret_Stone_Wall_Pine_Trees_Daytime.jpg"
- "Tokyo_Skyline_Night_Tokyo_Tower_Mount_Fuji_View.jpg"
- "Itsukushima_Shrine_Miyajima_Floating_Torii_Gate_Sunset_Long_Exposure.jpg"
- "Takachiho_Gorge_Waterfall_River_Lush_Greenery_Japan.jpg"
- "Bonsai_Tree_Potted_Japanese_Art_Green_Foliage.jpeg"
- "Shirakawa-go_Gassho-zukuri_Thatched_Roof_Village_Aerial_View.jpg"
- "Ginkaku-ji_Silver_Pavilion_Kyoto_Japanese_Garden_Pond_Reflection.jpg"
- "Senso-ji_Temple_Asakusa_Cherry_Blossoms_Kimono_Umbrella.jpg"
- "Cherry_Blossoms_Sakura_Night_View_City_Lights_Japan.jpg"
- "Mount_Fuji_Lake_Reflection_Cherry_Blossoms_Sakura_Spring.jpg"
gradient: CSS gradient string for background (e.g., "linear-gradient(135deg, #667eea 0%, #764ba2 100%)")
Returns:
Haiku metadata for frontend rendering
"""
return f"Haiku generated with image: {image_name}"
@ai_function
def create_chart(chart_type: str, data_points: list[dict[str, Any]], title: str) -> str:
"""Create an interactive chart (FRONTEND_RENDER).
This tool creates chart specifications for frontend rendering.
The frontend should render this as an interactive chart component.
Args:
chart_type: Type of chart (bar, line, pie, scatter)
data_points: Data points for the chart
title: Chart title
Returns:
Chart specification for frontend rendering
"""
return f"Chart '{title}' created with {len(data_points)} data points"
@ai_function
def display_timeline(events: list[dict[str, Any]], start_date: str, end_date: str) -> str:
"""Display an interactive timeline (FRONTEND_RENDER).
This tool creates timeline specifications for frontend rendering.
The frontend should render this as an interactive timeline component.
Args:
events: Events to display on the timeline
start_date: Timeline start date
end_date: Timeline end date
Returns:
Timeline specification for frontend rendering
"""
return f"Timeline created with {len(events)} events from {start_date} to {end_date}"
@ai_function
def show_comparison_table(items: list[dict[str, Any]], columns: list[str]) -> str:
"""Show a comparison table (FRONTEND_RENDER).
This tool creates table specifications for frontend rendering.
The frontend should render this as an interactive comparison table.
Args:
items: Items to compare
columns: Column names
Returns:
Table specification for frontend rendering
"""
return f"Comparison table created with {len(items)} items and {len(columns)} columns"
# Create the UI generator agent using tool-based approach with forced tool usage
agent = ChatAgent(
name="ui_generator",
instructions="""You MUST use the provided tools to generate content. Never respond with plain text descriptions.
For haiku requests:
- Call generate_haiku tool with all 4 required parameters
- English: 3 lines
- Japanese: 3 lines
- image_name: Choose from available images
- gradient: CSS gradient string
For other requests, use the appropriate tool (create_chart, display_timeline, show_comparison_table).
""",
chat_client=AzureOpenAIChatClient(),
tools=[generate_haiku, create_chart, display_timeline, show_comparison_table],
# Force tool usage - the LLM MUST call a tool, cannot respond with plain text
chat_options={"tool_choice": "required"},
)
ui_generator_agent = AgentFrameworkAgent(
agent=agent,
name="UIGenerator",
description="Generates custom UI components through tool calls",
)
@@ -0,0 +1,71 @@
# Copyright (c) Microsoft. All rights reserved.
"""Weather agent example demonstrating backend tool rendering."""
from typing import Any
from agent_framework import ChatAgent, ai_function
from agent_framework.azure import AzureOpenAIChatClient
@ai_function
def get_weather(location: str) -> dict[str, Any]:
"""Get the current weather for a location.
Args:
location: The city or location to get weather for.
Returns:
Weather information as a dictionary with temperatures in Celsius.
"""
# Simulated weather data with structured format (temperatures in Celsius for dojo UI)
weather_data = {
"seattle": {"temperature": 11, "conditions": "rainy", "humidity": 75, "wind_speed": 12, "feels_like": 10},
"san francisco": {"temperature": 14, "conditions": "foggy", "humidity": 85, "wind_speed": 8, "feels_like": 13},
"new york city": {"temperature": 18, "conditions": "sunny", "humidity": 60, "wind_speed": 10, "feels_like": 17},
"miami": {"temperature": 29, "conditions": "hot and humid", "humidity": 90, "wind_speed": 5, "feels_like": 32},
"chicago": {"temperature": 9, "conditions": "windy", "humidity": 65, "wind_speed": 20, "feels_like": 6},
}
location_lower = location.lower()
if location_lower in weather_data:
return weather_data[location_lower]
return {
"temperature": 21,
"conditions": "partly cloudy",
"humidity": 50,
"wind_speed": 10,
"feels_like": 20,
}
@ai_function
def get_forecast(location: str, days: int = 3) -> str:
"""Get the weather forecast for a location.
Args:
location: The city or location to get forecast for.
days: Number of days to forecast (default: 3).
Returns:
Forecast information string.
"""
forecast: list[str] = []
for day in range(1, min(days, 7) + 1):
forecast.append(f"Day {day}: Partly cloudy, {60 + day * 2}°F")
return f"{days}-day forecast for {location}:\n" + "\n".join(forecast)
# Create the weather agent
weather_agent = ChatAgent(
name="weather_agent",
instructions=(
"You are a helpful weather assistant. "
"Use the get_weather and get_forecast functions to help users with weather information. "
"Always provide friendly and informative responses."
),
chat_client=AzureOpenAIChatClient(),
tools=[get_weather, get_forecast],
)
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,3 @@
# Copyright (c) Microsoft. All rights reserved.
"""API endpoints for AG-UI examples."""
@@ -0,0 +1,22 @@
# Copyright (c) Microsoft. All rights reserved.
"""Backend tool rendering endpoint."""
from fastapi import FastAPI
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
from ...agents.weather_agent import weather_agent
def register_backend_tool_rendering(app: FastAPI) -> None:
"""Register the backend tool rendering endpoint.
Args:
app: The FastAPI application.
"""
add_agent_framework_fastapi_endpoint(
app,
weather_agent,
"/backend_tool_rendering",
)
@@ -0,0 +1,129 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example FastAPI server with AG-UI endpoints."""
import logging
import os
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
from ..agents.document_writer_agent import document_writer_agent
from ..agents.human_in_the_loop_agent import human_in_the_loop_agent
from ..agents.recipe_agent import recipe_agent
from ..agents.simple_agent import agent as simple_agent
from ..agents.task_steps_agent import task_steps_agent_wrapped as task_steps_agent # Custom wrapper
from ..agents.ui_generator_agent import ui_generator_agent
from ..agents.weather_agent import weather_agent
# Configure logging to file and console (disabled by default - set ENABLE_DEBUG_LOGGING=1 to enable)
if os.getenv("ENABLE_DEBUG_LOGGING"):
log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "ag_ui_server.log")
# Remove any existing handlers
root_logger = logging.getLogger()
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# Configure new handlers
file_handler = logging.FileHandler(log_file, mode="w")
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
root_logger.setLevel(logging.INFO)
# Explicitly set log levels for our modules
logging.getLogger("agent_framework_ag_ui").setLevel(logging.INFO)
logging.getLogger("agent_framework").setLevel(logging.INFO)
logger = logging.getLogger(__name__)
logger.info(f"AG-UI Examples Server starting... Logs writing to: {log_file}")
app = FastAPI(title="Agent Framework AG-UI Example Server")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Agentic Chat - basic chat agent
add_agent_framework_fastapi_endpoint(
app=app,
agent=simple_agent,
path="/agentic_chat",
)
# Backend Tool Rendering - agent with tools
add_agent_framework_fastapi_endpoint(
app=app,
agent=weather_agent,
path="/backend_tool_rendering",
)
# Shared State - recipe agent with structured output
add_agent_framework_fastapi_endpoint(
app=app,
agent=recipe_agent,
path="/shared_state",
)
# Predictive State Updates - document writer with predictive state
add_agent_framework_fastapi_endpoint(
app=app,
agent=document_writer_agent,
path="/predictive_state_updates",
)
# Human in the Loop - human-in-the-loop agent with step customization
add_agent_framework_fastapi_endpoint(
app=app,
agent=human_in_the_loop_agent,
path="/human_in_the_loop",
state_schema={"steps": {"type": "array"}},
predict_state_config={"steps": {"tool": "generate_task_steps", "tool_argument": "steps"}},
)
# Agentic Generative UI - task steps agent with streaming state updates
add_agent_framework_fastapi_endpoint(
app=app,
agent=task_steps_agent, # type: ignore[arg-type]
path="/agentic_generative_ui",
)
# Tool-based Generative UI - UI generator with frontend-rendered tools
add_agent_framework_fastapi_endpoint(
app=app,
agent=ui_generator_agent,
path="/tool_based_generative_ui",
)
def main():
"""Run the server."""
port = int(os.getenv("PORT", "8888"))
host = os.getenv("HOST", "127.0.0.1")
# Use log_config=None to prevent uvicorn from reconfiguring logging
# This preserves our file + console logging setup
uvicorn.run(
app,
host=host,
port=port,
log_config=None,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,705 @@
# Getting Started with AG-UI (Python)
The AG-UI (Agent UI) protocol provides a standardized way for client applications to interact with AI agents over HTTP. This tutorial demonstrates how to build both server and client applications using the AG-UI protocol with Python.
## What is AG-UI?
AG-UI is a protocol that enables:
- **Remote agent hosting**: Host AI agents as web services that can be accessed by multiple clients
- **Streaming responses**: Real-time streaming of agent responses using Server-Sent Events (SSE)
- **Standardized communication**: Consistent message format for agent interactions
- **Thread management**: Maintain conversation context across multiple requests
- **Advanced features**: Human-in-the-loop, state management, tool rendering
## Prerequisites
Before you begin, ensure you have the following:
- Python 3.10 or later
- Azure OpenAI service endpoint and deployment configured
- Azure CLI installed and authenticated (for DefaultAzureCredential)
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource
**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai).
**Note**: These samples use `DefaultAzureCredential` for authentication. Make sure you're authenticated with Azure (e.g., via `az login`, or environment variables). For more information, see the [Azure Identity documentation](https://learn.microsoft.com/python/api/azure-identity/azure.identity.defaultazurecredential).
> **Warning**
> The AG-UI protocol is still under development and subject to change.
> We will keep these samples updated as the protocol evolves.
## Step 1: Creating an AG-UI Server
The AG-UI server hosts your AI agent and exposes it via HTTP endpoints using FastAPI.
### Install Required Packages
```bash
pip install agent-framework-ag-ui agent-framework-core fastapi uvicorn
```
Or using uv:
```bash
uv pip install agent-framework-ag-ui agent-framework-core fastapi uvicorn
```
### Server Code
Create a file named `server.py`:
```python
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI server example."""
import os
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
from fastapi import FastAPI
# Read required configuration
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
deployment_name = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME")
if not endpoint:
raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required")
if not deployment_name:
raise ValueError("AZURE_OPENAI_DEPLOYMENT_NAME environment variable is required")
# Create the AI agent
agent = ChatAgent(
name="AGUIAssistant",
instructions="You are a helpful assistant.",
chat_client=AzureOpenAIChatClient(
endpoint=endpoint,
deployment_name=deployment_name,
),
)
# Create FastAPI app
app = FastAPI(title="AG-UI Server")
# Register the AG-UI endpoint
add_agent_framework_fastapi_endpoint(app, agent, "/")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=5100)
```
### Key Concepts
- **`add_agent_framework_fastapi_endpoint`**: Registers the AG-UI endpoint with automatic request/response handling and SSE streaming
- **`ChatAgent`**: The agent that will handle incoming requests
- **FastAPI Integration**: Uses FastAPI's native async support for streaming responses
- **Instructions**: The agent is created with default instructions, which can be overridden by client messages
- **Configuration**: `AzureOpenAIChatClient` can read from environment variables (`AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_KEY`) or accept parameters directly
**Alternative (simpler)**: Use environment variables only:
```python
# No need to read environment variables manually
agent = ChatAgent(
name="AGUIAssistant",
instructions="You are a helpful assistant.",
chat_client=AzureOpenAIChatClient(), # Reads from environment automatically
)
```
### Configure and Run the Server
Set the required environment variables:
```bash
export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
export AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="gpt-4o-mini"
# Optional: Set API key if not using DefaultAzureCredential
# export AZURE_OPENAI_API_KEY="your-api-key"
```
Run the server:
```bash
python server.py
```
Or using uvicorn directly:
```bash
uvicorn server:app --host 127.0.0.1 --port 5100
```
The server will start listening on `http://127.0.0.1:5100`.
## Step 2: Creating an AG-UI Client
The AG-UI client connects to the remote server and displays streaming responses.
### Install Required Packages
```bash
pip install httpx
```
### Client Code
Create a file named `client.py`:
```python
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI client example."""
import asyncio
import json
import os
from typing import AsyncIterator
import httpx
class AGUIClient:
"""Simple AG-UI protocol client."""
def __init__(self, server_url: str):
"""Initialize the client.
Args:
server_url: The AG-UI server endpoint URL
"""
self.server_url = server_url
self.thread_id: str | None = None
async def send_message(self, message: str) -> AsyncIterator[dict]:
"""Send a message and stream the response.
Args:
message: The user message to send
Yields:
AG-UI events from the server
"""
# Prepare the request
request_data = {
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": message},
]
}
# Include thread_id if we have one (for conversation continuity)
if self.thread_id:
request_data["thread_id"] = self.thread_id
# Stream the response
async with httpx.AsyncClient(timeout=60.0) as client:
async with client.stream(
"POST",
self.server_url,
json=request_data,
headers={"Accept": "text/event-stream"},
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
# Parse Server-Sent Events format
if line.startswith("data: "):
data = line[6:] # Remove "data: " prefix
try:
event = json.loads(data)
yield event
# Capture thread_id from RUN_STARTED event
if event.get("type") == "RUN_STARTED" and not self.thread_id:
self.thread_id = event.get("threadId")
except json.JSONDecodeError:
continue
async def main():
"""Main client loop."""
# Get server URL from environment or use default
server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/")
print(f"Connecting to AG-UI server at: {server_url}\n")
client = AGUIClient(server_url)
try:
while True:
# Get user input
message = input("\nUser (:q or quit to exit): ")
if not message.strip():
print("Request cannot be empty.")
continue
if message.lower() in (":q", "quit"):
break
# Send message and display streaming response
print("\n", end="")
async for event in client.send_message(message):
event_type = event.get("type", "")
if event_type == "RUN_STARTED":
thread_id = event.get("threadId", "")
run_id = event.get("runId", "")
print(f"\033[93m[Run Started - Thread: {thread_id}, Run: {run_id}]\033[0m")
elif event_type == "TEXT_MESSAGE_CONTENT":
# Stream text content in cyan
print(f"\033[96m{event.get('delta', '')}\033[0m", end="", flush=True)
elif event_type == "RUN_FINISHED":
thread_id = event.get("threadId", "")
run_id = event.get("runId", "")
print(f"\n\033[92m[Run Finished - Thread: {thread_id}, Run: {run_id}]\033[0m")
elif event_type == "RUN_ERROR":
error_message = event.get("message", "Unknown error")
print(f"\n\033[91m[Run Error - Message: {error_message}]\033[0m")
print()
except KeyboardInterrupt:
print("\n\nExiting...")
except Exception as e:
print(f"\n\033[91mAn error occurred: {e}\033[0m")
if __name__ == "__main__":
asyncio.run(main())
```
### Key Concepts
- **Server-Sent Events (SSE)**: The protocol uses SSE format (`data: {json}\n\n`)
- **Event Types**: Different events provide metadata and content (all event types use UPPERCASE with underscores):
- `RUN_STARTED`: Signals the agent has started processing
- `TEXT_MESSAGE_START`: Signals the start of a text message from the agent
- `TEXT_MESSAGE_CONTENT`: Incremental text streamed from the agent (with `delta` field)
- `TEXT_MESSAGE_END`: Signals the end of a text message
- `RUN_FINISHED`: Signals successful completion
- `RUN_ERROR`: Error information if something goes wrong
- **Field Naming**: Event fields use camelCase (e.g., `threadId`, `runId`, `messageId`) when accessing JSON events
- **Thread Management**: The `threadId` maintains conversation context across requests
- **Client-Side Instructions**: System messages are sent from the client
### Configure and Run the Client
Optionally set a custom server URL:
```bash
export AGUI_SERVER_URL="http://127.0.0.1:5100/"
```
Run the client (in a separate terminal):
```bash
python client.py
```
## Step 3: Testing the Complete System
### Expected Output
```
$ python client.py
Connecting to AG-UI server at: http://127.0.0.1:5100/
User (:q or quit to exit): What is the capital of France?
[Run Started - Thread: abc123, Run: xyz789]
The capital of France is Paris. It is known for its rich history, culture,
and iconic landmarks such as the Eiffel Tower and the Louvre Museum.
[Run Finished - Thread: abc123, Run: xyz789]
User (:q or quit to exit): Tell me a fun fact about space
[Run Started - Thread: abc123, Run: def456]
Here's a fun fact: A day on Venus is longer than its year! Venus takes
about 243 Earth days to rotate once on its axis, but only about 225 Earth
days to orbit the Sun.
[Run Finished - Thread: abc123, Run: def456]
User (:q or quit to exit): :q
```
### Color-Coded Output
The client displays different content types with distinct colors:
- **Yellow**: Run started notifications
- **Cyan**: Agent text responses (streamed in real-time)
- **Green**: Run completion notifications
- **Red**: Error messages
## Testing with curl (Optional)
Before running the client, you can test the server manually using curl:
```bash
curl -N http://127.0.0.1:5100/ \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{
"messages": [
{"role": "user", "content": "What is the capital of France?"}
]
}'
```
You should see Server-Sent Events streaming back:
```
data: {"type":"RUN_STARTED","threadId":"...","runId":"..."}
data: {"type":"TEXT_MESSAGE_START","messageId":"...","role":"assistant"}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"...","delta":"The"}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"...","delta":" capital"}
...
data: {"type":"TEXT_MESSAGE_END","messageId":"..."}
data: {"type":"RUN_FINISHED","threadId":"...","runId":"..."}
```
## How It Works
### Server-Side Flow
1. Client sends HTTP POST request with messages
2. FastAPI endpoint receives the request
3. `AgentFrameworkAgent` wrapper orchestrates the execution
4. Agent processes the messages using Agent Framework
5. `AgentFrameworkEventBridge` converts agent updates to AG-UI events
6. Responses are streamed back as Server-Sent Events (SSE)
7. Connection closes when the run completes
### Client-Side Flow
1. Client sends HTTP POST request to server endpoint
2. Server responds with SSE stream
3. Client parses incoming `data:` lines as JSON events
4. Each event is displayed based on its type
5. `threadId` is captured for conversation continuity
6. Stream completes when `RUN_FINISHED` event arrives
### Protocol Details
The AG-UI protocol uses:
- **HTTP POST** for sending requests
- **Server-Sent Events (SSE)** for streaming responses
- **JSON** for event serialization
- **Thread IDs** for maintaining conversation context
- **Run IDs** for tracking individual executions
- **Event type naming**: UPPERCASE with underscores (e.g., `RUN_STARTED`, `TEXT_MESSAGE_CONTENT`)
- **Field naming**: camelCase (e.g., `threadId`, `runId`, `messageId`)
## Advanced Features
The Python AG-UI implementation supports all 7 AG-UI features:
### 1. Backend Tool Rendering
Add tools to your agent for backend execution:
```python
from typing import Any
from agent_framework import ChatAgent, ai_function
from agent_framework.azure import AzureOpenAIChatClient
@ai_function
def get_weather(location: str) -> dict[str, Any]:
"""Get weather for a location."""
return {"temperature": 72, "conditions": "sunny"}
agent = ChatAgent(
name="weather_agent",
instructions="Use tools to help users.",
chat_client=AzureOpenAIChatClient(
endpoint="https://your-resource.openai.azure.com/",
deployment_name="gpt-4o-mini",
),
tools=[get_weather],
)
```
The client will receive `TOOL_CALL_START`, `TOOL_CALL_ARGS`, `TOOL_CALL_END`, and `TOOL_CALL_RESULT` events.
### 2. Human in the Loop
Request user confirmation before executing tools:
```python
from fastapi import FastAPI
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import AgentFrameworkAgent, add_agent_framework_fastapi_endpoint
agent = ChatAgent(
name="my_agent",
instructions="You are a helpful assistant.",
chat_client=AzureOpenAIChatClient(
endpoint="https://your-resource.openai.azure.com/",
deployment_name="gpt-4o-mini",
),
)
wrapped_agent = AgentFrameworkAgent(
agent=agent,
require_confirmation=True, # Enable human-in-the-loop
)
app = FastAPI()
add_agent_framework_fastapi_endpoint(app, wrapped_agent, "/")
```
The client receives tool approval request events and can send approval responses.
### 3. State Management
Share state between client and server:
```python
wrapped_agent = AgentFrameworkAgent(
agent=agent,
state_schema={
"location": {"type": "string"},
"preferences": {"type": "object"},
},
)
```
Events include `STATE_SNAPSHOT` and `STATE_DELTA` for bidirectional sync.
### 4. Predictive State Updates
Stream tool arguments as optimistic state updates:
```python
wrapped_agent = AgentFrameworkAgent(
agent=agent,
predict_state_config={
"location": {"tool": "get_weather", "tool_argument": "location"}
},
require_confirmation=False, # Auto-update without confirmation
)
```
State updates stream in real-time as the LLM generates tool arguments.
## Common Patterns
### Custom Server Configuration
```python
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
# Add CORS for web clients
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
add_agent_framework_fastapi_endpoint(app, agent, "/agent")
```
### Multiple Agents
```python
app = FastAPI()
weather_agent = ChatAgent(name="weather", ...)
finance_agent = ChatAgent(name="finance", ...)
add_agent_framework_fastapi_endpoint(app, weather_agent, "/weather")
add_agent_framework_fastapi_endpoint(app, finance_agent, "/finance")
```
### Custom Client Timeout
```python
async with httpx.AsyncClient(timeout=300.0) as client:
async with client.stream("POST", server_url, ...) as response:
async for line in response.aiter_lines():
# Process events
pass
```
### Error Handling
```python
try:
async for event in client.send_message(message):
if event.get("type") == "RUN_ERROR":
error_msg = event.get("message", "Unknown error")
print(f"Error: {error_msg}")
# Handle error appropriately
except httpx.HTTPError as e:
print(f"HTTP error: {e}")
except Exception as e:
print(f"Unexpected error: {e}")
```
### Conversation Continuity
The client automatically maintains `threadId` across requests:
```python
client = AGUIClient(server_url)
# First message
async for event in client.send_message("Hello"):
# Client captures threadId from RUN_STARTED
pass
# Second message - uses same threadId
async for event in client.send_message("Continue our conversation"):
# Conversation context is maintained
pass
```
## AG-UI Event Reference
### Core Events
| Event Type | Description | Key Fields |
|------------|-------------|------------|
| `RUN_STARTED` | Agent execution started | `threadId`, `runId` |
| `RUN_FINISHED` | Agent execution completed | `threadId`, `runId` |
| `RUN_ERROR` | Agent execution error | `message` |
### Text Message Events
| Event Type | Description | Key Fields |
|------------|-------------|------------|
| `TEXT_MESSAGE_START` | Start of agent text message | `messageId`, `role` |
| `TEXT_MESSAGE_CONTENT` | Streaming text content | `messageId`, `delta` |
| `TEXT_MESSAGE_END` | End of agent text message | `messageId` |
### Tool Events
| Event Type | Description | Key Fields |
|------------|-------------|------------|
| `TOOL_CALL_START` | Tool call initiated | `toolCallId`, `toolCallName` |
| `TOOL_CALL_ARGS` | Tool arguments streaming | `toolCallId`, `delta` |
| `TOOL_CALL_END` | Tool call complete | `toolCallId` |
| `TOOL_CALL_RESULT` | Tool execution result | `toolCallId`, `content` |
### State Events
| Event Type | Description | Key Fields |
|------------|-------------|------------|
| `STATE_SNAPSHOT` | Complete state | `snapshot` |
| `STATE_DELTA` | State changes (JSON Patch) | `delta` |
### Other Events
| Event Type | Description | Key Fields |
|------------|-------------|------------|
| `MESSAGES_SNAPSHOT` | Conversation history | `messages` |
| `CUSTOM` | Custom event data | `name`, `value` |
## Next Steps
Now that you understand the basics of AG-UI, you can:
- **Add Tools**: Create custom `@ai_function` tools for your domain
- **Web Integration**: Build React/Vue frontends using the AG-UI protocol
- **State Management**: Implement shared state for generative UI applications
- **Human-in-the-Loop**: Add approval workflows for sensitive operations
- **Deployment**: Deploy to Azure Container Apps or Azure App Service
- **Multi-Agent Systems**: Coordinate multiple specialized agents
- **Monitoring**: Add logging and OpenTelemetry for observability
## Additional Resources
- [AG-UI Examples](../examples/README.md): Complete working examples for all 7 features
- [Agent Framework Documentation](../../core/README.md): Learn more about creating agents
- [AG-UI Protocol Spec](https://docs.ag-ui.com/): Official protocol documentation
## Troubleshooting
### Connection Refused
Ensure the server is running before starting the client:
```bash
# Terminal 1
python server.py
# Terminal 2 (after server starts)
python client.py
```
### Authentication Errors
Make sure you're authenticated with Azure:
```bash
az login
```
Verify you have the correct role assignment on the Azure OpenAI resource.
### Streaming Not Working
Check that your client timeout is sufficient:
```python
httpx.AsyncClient(timeout=60.0) # 60 seconds should be enough
```
For long-running agents, increase the timeout accordingly.
### No Events Received
Ensure you're using the correct `Accept` header:
```python
headers={"Accept": "text/event-stream"}
```
And parsing SSE format correctly (lines starting with `data: `).
### Thread Context Lost
The client automatically manages thread continuity. If context is lost:
1. Check that `threadId` is being captured from `RUN_STARTED` events
2. Ensure the same client instance is used across messages
3. Verify the server is receiving the `thread_id` in subsequent requests
### Event Type Mismatches
Remember that event types are UPPERCASE with underscores (`RUN_STARTED`, not `run_started`) and field names are camelCase (`threadId`, not `thread_id`).
### Import Errors
Make sure all packages are installed:
```bash
pip install agent-framework-ag-ui agent-framework-core fastapi uvicorn httpx
```
Or check your virtual environment is activated:
```bash
source venv/bin/activate # Linux/macOS
venv\Scripts\activate # Windows
```
@@ -0,0 +1,122 @@
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI client example."""
import asyncio
import json
import os
from collections.abc import AsyncIterator
import httpx
class AGUIClient:
"""Simple AG-UI protocol client."""
def __init__(self, server_url: str):
"""Initialize the client.
Args:
server_url: The AG-UI server endpoint URL
"""
self.server_url = server_url
self.thread_id: str | None = None
async def send_message(self, message: str) -> AsyncIterator[dict]:
"""Send a message and stream the response.
Args:
message: The user message to send
Yields:
AG-UI events from the server
"""
# Prepare the request
request_data: dict[str, object] = {
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": message},
]
}
# Include thread_id if we have one (for conversation continuity)
if self.thread_id:
request_data["thread_id"] = self.thread_id
# Stream the response
async with httpx.AsyncClient(timeout=60.0) as client:
async with client.stream(
"POST",
self.server_url,
json=request_data,
headers={"Accept": "text/event-stream"},
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
# Parse Server-Sent Events format
if line.startswith("data: "):
data = line[6:] # Remove "data: " prefix
try:
event = json.loads(data)
yield event
# Capture thread_id from RUN_STARTED event
if event.get("type") == "RUN_STARTED" and not self.thread_id:
self.thread_id = event.get("threadId")
except json.JSONDecodeError:
continue
async def main():
"""Main client loop."""
# Get server URL from environment or use default
server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/")
print(f"Connecting to AG-UI server at: {server_url}\n")
client = AGUIClient(server_url)
try:
while True:
# Get user input
message = input("\nUser (:q or quit to exit): ")
if not message.strip():
print("Request cannot be empty.")
continue
if message.lower() in (":q", "quit"):
break
# Send message and display streaming response
print("\n", end="")
async for event in client.send_message(message):
event_type = event.get("type", "")
if event_type == "RUN_STARTED":
thread_id = event.get("threadId", "")
run_id = event.get("runId", "")
print(f"\033[93m[Run Started - Thread: {thread_id}, Run: {run_id}]\033[0m")
elif event_type == "TEXT_MESSAGE_CONTENT":
# Stream text content in cyan
print(f"\033[96m{event.get('delta', '')}\033[0m", end="", flush=True)
elif event_type == "RUN_FINISHED":
thread_id = event.get("threadId", "")
run_id = event.get("runId", "")
print(f"\n\033[92m[Run Finished - Thread: {thread_id}, Run: {run_id}]\033[0m")
elif event_type == "RUN_ERROR":
error_message = event.get("message", "Unknown error")
print(f"\n\033[91m[Run Error - Message: {error_message}]\033[0m")
print()
except KeyboardInterrupt:
print("\n\nExiting...")
except Exception as e:
print(f"\n\033[91mAn error occurred: {e}\033[0m")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,44 @@
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI server example."""
import os
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from dotenv import load_dotenv
from fastapi import FastAPI
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
load_dotenv()
# Read required configuration
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
deployment_name = os.environ.get("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME")
if not endpoint:
raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required")
if not deployment_name:
raise ValueError("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME environment variable is required")
# Create the AI agent
agent = ChatAgent(
name="AGUIAssistant",
instructions="You are a helpful assistant.",
chat_client=AzureOpenAIChatClient(
endpoint=endpoint,
deployment_name=deployment_name,
),
)
# Create FastAPI app
app = FastAPI(title="AG-UI Server")
# Register the AG-UI endpoint
add_agent_framework_fastapi_endpoint(app, agent, "/")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=5100)
+74
View File
@@ -0,0 +1,74 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b251105"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
requires-python = ">=3.10"
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",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core",
"ag-ui-protocol>=0.1.9",
"fastapi>=0.115.0",
"uvicorn>=0.30.0"
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
"httpx>=0.27.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["agent_framework_ag_ui"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["."]
[tool.ruff]
line-length = 120
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W"]
ignore = ["E501"]
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = false
[tool.pyright]
exclude = ["tests", "examples"]
typeCheckingMode = "basic"
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ag_ui"
test = "pytest --cov=agent_framework_ag_ui --cov-report=term-missing:skip-covered tests"
+1
View File
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,577 @@
# Copyright (c) Microsoft. All rights reserved.
"""Comprehensive tests for AgentFrameworkAgent (_agent.py)."""
import json
import pytest
from agent_framework import ChatAgent, TextContent
from agent_framework._types import ChatResponseUpdate
async def test_agent_initialization_basic():
"""Test basic agent initialization without state schema."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
wrapper = AgentFrameworkAgent(agent=agent)
assert wrapper.name == "test_agent"
assert wrapper.agent == agent
assert wrapper.config.state_schema == {}
assert wrapper.config.predict_state_config == {}
async def test_agent_initialization_with_state_schema():
"""Test agent initialization with state_schema."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
state_schema = {"document": {"type": "string"}}
wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema)
assert wrapper.config.state_schema == state_schema
async def test_agent_initialization_with_predict_state_config():
"""Test agent initialization with predict_state_config."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
predict_config = {"document": {"tool": "write_doc", "tool_argument": "content"}}
wrapper = AgentFrameworkAgent(agent=agent, predict_state_config=predict_config)
assert wrapper.config.predict_state_config == predict_config
async def test_run_started_event_emission():
"""Test RunStartedEvent is emitted at start of run."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
wrapper = AgentFrameworkAgent(agent=agent)
input_data = {"messages": [{"role": "user", "content": "Hi"}]}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# First event should be RunStartedEvent
assert events[0].type == "RUN_STARTED"
assert events[0].run_id is not None
assert events[0].thread_id is not None
async def test_predict_state_custom_event_emission():
"""Test PredictState CustomEvent is emitted when predict_state_config is present."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
predict_config = {
"document": {"tool": "write_doc", "tool_argument": "content"},
"summary": {"tool": "summarize", "tool_argument": "text"},
}
wrapper = AgentFrameworkAgent(agent=agent, predict_state_config=predict_config)
input_data = {"messages": [{"role": "user", "content": "Hi"}]}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Find PredictState event
predict_events = [e for e in events if e.type == "CUSTOM" and e.name == "PredictState"]
assert len(predict_events) == 1
predict_value = predict_events[0].value
assert len(predict_value) == 2
assert {"state_key": "document", "tool": "write_doc", "tool_argument": "content"} in predict_value
assert {"state_key": "summary", "tool": "summarize", "tool_argument": "text"} in predict_value
async def test_initial_state_snapshot_with_schema():
"""Test initial StateSnapshotEvent emission when state_schema present."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
state_schema = {"document": {"type": "string"}}
wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema)
input_data = {
"messages": [{"role": "user", "content": "Hi"}],
"state": {"document": "Initial content"},
}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Find StateSnapshotEvent
snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"]
assert len(snapshot_events) >= 1
# First snapshot should have initial state
assert snapshot_events[0].snapshot == {"document": "Initial content"}
async def test_state_initialization_object_type():
"""Test state initialization with object type in schema."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
state_schema = {"recipe": {"type": "object", "properties": {}}}
wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema)
input_data = {"messages": [{"role": "user", "content": "Hi"}]}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Find StateSnapshotEvent
snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"]
assert len(snapshot_events) >= 1
# Should initialize as empty object
assert snapshot_events[0].snapshot == {"recipe": {}}
async def test_state_initialization_array_type():
"""Test state initialization with array type in schema."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
state_schema = {"steps": {"type": "array", "items": {}}}
wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema)
input_data = {"messages": [{"role": "user", "content": "Hi"}]}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Find StateSnapshotEvent
snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"]
assert len(snapshot_events) >= 1
# Should initialize as empty array
assert snapshot_events[0].snapshot == {"steps": []}
async def test_run_finished_event_emission():
"""Test RunFinishedEvent is emitted at end of run."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
wrapper = AgentFrameworkAgent(agent=agent)
input_data = {"messages": [{"role": "user", "content": "Hi"}]}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Last event should be RunFinishedEvent
assert events[-1].type == "RUN_FINISHED"
async def test_tool_result_confirm_changes_accepted():
"""Test confirm_changes tool result handling when accepted."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="Document updated")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
wrapper = AgentFrameworkAgent(
agent=agent,
state_schema={"document": {"type": "string"}},
predict_state_config={"document": {"tool": "write_doc", "tool_argument": "content"}},
)
# Simulate tool result message with acceptance
tool_result = {"accepted": True, "steps": []}
input_data = {
"messages": [
{
"role": "tool", # Tool result from UI
"content": json.dumps(tool_result),
"toolCallId": "confirm_call_123",
}
],
"state": {"document": "Updated content"},
}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Should emit text message confirming acceptance
text_content_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"]
assert len(text_content_events) > 0
# Should contain confirmation message mentioning the state key or generic confirmation
confirmation_found = any(
"document" in e.delta.lower()
or "confirm" in e.delta.lower()
or "applied" in e.delta.lower()
or "changes" in e.delta.lower()
for e in text_content_events
)
assert confirmation_found, f"No confirmation in deltas: {[e.delta for e in text_content_events]}"
async def test_tool_result_confirm_changes_rejected():
"""Test confirm_changes tool result handling when rejected."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="OK")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
wrapper = AgentFrameworkAgent(agent=agent)
# Simulate tool result message with rejection
tool_result = {"accepted": False, "steps": []}
input_data = {
"messages": [
{
"role": "tool",
"content": json.dumps(tool_result),
"toolCallId": "confirm_call_123",
}
],
}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Should emit text message asking what to change
text_content_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"]
assert len(text_content_events) > 0
assert any("what would you like me to change" in e.delta.lower() for e in text_content_events)
async def test_tool_result_function_approval_accepted():
"""Test function approval tool result when steps are accepted."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="OK")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
wrapper = AgentFrameworkAgent(agent=agent)
# Simulate tool result with multiple steps
tool_result = {
"accepted": True,
"steps": [
{"id": "step1", "description": "Send email", "status": "enabled"},
{"id": "step2", "description": "Create calendar event", "status": "enabled"},
],
}
input_data = {
"messages": [
{
"role": "tool",
"content": json.dumps(tool_result),
"toolCallId": "approval_call_123",
}
],
}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Should list enabled steps
text_content_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"]
assert len(text_content_events) > 0
# Concatenate all text content
full_text = "".join(e.delta for e in text_content_events)
assert "executing" in full_text.lower()
assert "2 approved steps" in full_text.lower()
assert "send email" in full_text.lower()
assert "create calendar event" in full_text.lower()
async def test_tool_result_function_approval_rejected():
"""Test function approval tool result when rejected."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="OK")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
wrapper = AgentFrameworkAgent(agent=agent)
# Simulate tool result rejection with steps
tool_result = {
"accepted": False,
"steps": [{"id": "step1", "description": "Send email", "status": "disabled"}],
}
input_data = {
"messages": [
{
"role": "tool",
"content": json.dumps(tool_result),
"toolCallId": "approval_call_123",
}
],
}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Should ask what to change about the plan
text_content_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"]
assert len(text_content_events) > 0
assert any("what would you like me to change about the plan" in e.delta.lower() for e in text_content_events)
async def test_thread_metadata_tracking():
"""Test that thread metadata includes ag_ui_thread_id and ag_ui_run_id."""
from agent_framework_ag_ui import AgentFrameworkAgent
thread_metadata = {}
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
# Capture thread metadata from kwargs
nonlocal thread_metadata
if "thread" in kwargs:
thread_metadata = kwargs["thread"].metadata
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
wrapper = AgentFrameworkAgent(agent=agent)
input_data = {
"messages": [{"role": "user", "content": "Hi"}],
"thread_id": "test_thread_123",
"run_id": "test_run_456",
}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Check thread metadata was set
# Note: This test may need adjustment based on actual thread passing mechanism
async def test_state_context_injection():
"""Test that current state is injected into thread metadata."""
from agent_framework_ag_ui import AgentFrameworkAgent
thread_metadata = {}
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
# Track if state context message was added
nonlocal thread_metadata
# In actual implementation, thread is passed and state is in metadata
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
wrapper = AgentFrameworkAgent(
agent=agent,
state_schema={"document": {"type": "string"}},
)
input_data = {
"messages": [{"role": "user", "content": "Hi"}],
"state": {"document": "Test content"},
}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# State should be injected - this is validated by agent execution flow
async def test_no_messages_provided():
"""Test handling when no messages are provided."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
wrapper = AgentFrameworkAgent(agent=agent)
input_data = {"messages": []}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Should emit RunStartedEvent and RunFinishedEvent only
assert len(events) == 2
assert events[0].type == "RUN_STARTED"
assert events[-1].type == "RUN_FINISHED"
async def test_message_end_event_emission():
"""Test TextMessageEndEvent is emitted for assistant messages."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="Hello world")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
wrapper = AgentFrameworkAgent(agent=agent)
input_data = {"messages": [{"role": "user", "content": "Hi"}]}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Should have TextMessageEndEvent before RunFinishedEvent
end_events = [e for e in events if e.type == "TEXT_MESSAGE_END"]
assert len(end_events) == 1
# EndEvent should come before FinishedEvent
end_index = events.index(end_events[0])
finished_index = events.index([e for e in events if e.type == "RUN_FINISHED"][0])
assert end_index < finished_index
async def test_error_handling_with_exception():
"""Test that exceptions during agent execution are re-raised."""
from agent_framework_ag_ui import AgentFrameworkAgent
class FailingChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
if False:
yield
raise RuntimeError("Simulated failure")
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=FailingChatClient())
wrapper = AgentFrameworkAgent(agent=agent)
input_data = {"messages": [{"role": "user", "content": "Hi"}]}
with pytest.raises(RuntimeError, match="Simulated failure"):
async for event in wrapper.run_agent(input_data):
pass
async def test_json_decode_error_in_tool_result():
"""Test handling of JSONDecodeError when parsing tool result."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="Fallback response")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
wrapper = AgentFrameworkAgent(agent=agent)
# Send invalid JSON as tool result
input_data = {
"messages": [
{
"role": "tool",
"content": "invalid json {not valid}",
"toolCallId": "call_123",
}
],
}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Should fall through to normal agent processing
text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"]
assert len(text_events) > 0
assert text_events[0].delta == "Fallback response"
async def test_suppressed_summary_with_document_state():
"""Test suppressed summary uses document state for confirmation message."""
from agent_framework_ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="Response")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
wrapper = AgentFrameworkAgent(
agent=agent,
state_schema={"document": {"type": "string"}},
predict_state_config={"document": {"tool": "write_doc", "tool_argument": "content"}},
confirmation_strategy=DocumentWriterConfirmationStrategy(),
)
# Simulate confirmation with document state
tool_result = {"accepted": True, "steps": []}
input_data = {
"messages": [
{
"role": "tool",
"content": json.dumps(tool_result),
"toolCallId": "confirm_123",
}
],
"state": {"document": "This is the beginning of a document. It contains important information."},
}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Should generate fallback summary from document state
text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"]
assert len(text_events) > 0
# Should contain some reference to the document
full_text = "".join(e.delta for e in text_events)
assert "written" in full_text.lower() or "document" in full_text.lower()
@@ -0,0 +1,124 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for backend tool rendering."""
from ag_ui.core import (
TextMessageContentEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallResultEvent,
ToolCallStartEvent,
)
from agent_framework import AgentRunResponseUpdate, FunctionCallContent, FunctionResultContent, TextContent
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
async def test_tool_call_flow():
"""Test complete tool call flow: call -> args -> end -> result."""
bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread")
# Step 1: Tool call starts
tool_call = FunctionCallContent(
call_id="weather-123",
name="get_weather",
arguments={"location": "Seattle"},
)
update1 = AgentRunResponseUpdate(contents=[tool_call])
events1 = await bridge.from_agent_run_update(update1)
# Should have: ToolCallStartEvent, ToolCallArgsEvent
assert len(events1) == 2
assert isinstance(events1[0], ToolCallStartEvent)
assert isinstance(events1[1], ToolCallArgsEvent)
start_event = events1[0]
assert start_event.tool_call_id == "weather-123"
assert start_event.tool_call_name == "get_weather"
args_event = events1[1]
assert "Seattle" in args_event.delta
# Step 2: Tool result comes back
tool_result = FunctionResultContent(
call_id="weather-123",
result="Weather in Seattle: Rainy, 52°F",
)
update2 = AgentRunResponseUpdate(contents=[tool_result])
events2 = await bridge.from_agent_run_update(update2)
# Should have: ToolCallEndEvent, ToolCallResultEvent, MessagesSnapshotEvent
assert len(events2) == 3
assert isinstance(events2[0], ToolCallEndEvent)
assert isinstance(events2[1], ToolCallResultEvent)
end_event = events2[0]
assert end_event.tool_call_id == "weather-123"
result_event = events2[1]
assert result_event.tool_call_id == "weather-123"
assert "Seattle" in result_event.content
assert "Rainy" in result_event.content
async def test_text_with_tool_call():
"""Test agent response with both text and tool calls."""
bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread")
# Agent says something then calls a tool
text_content = TextContent(text="Let me check the weather for you.")
tool_call = FunctionCallContent(
call_id="weather-456",
name="get_forecast",
arguments={"location": "San Francisco", "days": 3},
)
update = AgentRunResponseUpdate(contents=[text_content, tool_call])
events = await bridge.from_agent_run_update(update)
# Should have: TextMessageStart, TextMessageContent, ToolCallStart, ToolCallArgs
assert len(events) == 4
assert isinstance(events[0], TextMessageStartEvent)
assert isinstance(events[1], TextMessageContentEvent)
assert isinstance(events[2], ToolCallStartEvent)
assert isinstance(events[3], ToolCallArgsEvent)
text_event = events[1]
assert "check the weather" in text_event.delta
tool_start = events[2]
assert tool_start.tool_call_name == "get_forecast"
async def test_multiple_tool_results():
"""Test handling multiple tool results in sequence."""
bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread")
# Multiple tool results
results = [
FunctionResultContent(call_id="tool-1", result="Result 1"),
FunctionResultContent(call_id="tool-2", result="Result 2"),
FunctionResultContent(call_id="tool-3", result="Result 3"),
]
update = AgentRunResponseUpdate(contents=results)
events = await bridge.from_agent_run_update(update)
# Should have 3 pairs of ToolCallEndEvent + ToolCallResultEvent = 6 events
assert len(events) == 6
# Verify the pattern: End, Result, End, Result, End, Result
for i in range(3):
end_idx = i * 2
result_idx = i * 2 + 1
assert isinstance(events[end_idx], ToolCallEndEvent)
assert isinstance(events[result_idx], ToolCallResultEvent)
assert events[end_idx].tool_call_id == f"tool-{i + 1}"
assert events[result_idx].tool_call_id == f"tool-{i + 1}"
assert f"Result {i + 1}" in events[result_idx].content
@@ -0,0 +1,275 @@
# Copyright (c) Microsoft. All rights reserved.
"""Comprehensive tests for all confirmation strategies."""
import pytest
from agent_framework_ag_ui._confirmation_strategies import (
ConfirmationStrategy,
DefaultConfirmationStrategy,
DocumentWriterConfirmationStrategy,
RecipeConfirmationStrategy,
TaskPlannerConfirmationStrategy,
)
@pytest.fixture
def sample_steps():
"""Sample steps for testing approval messages."""
return [
{"description": "Step 1: Do something", "status": "enabled"},
{"description": "Step 2: Do another thing", "status": "enabled"},
{"description": "Step 3: Disabled step", "status": "disabled"},
]
@pytest.fixture
def all_enabled_steps():
"""All steps enabled."""
return [
{"description": "Task A", "status": "enabled"},
{"description": "Task B", "status": "enabled"},
{"description": "Task C", "status": "enabled"},
]
@pytest.fixture
def empty_steps():
"""Empty steps list."""
return []
class TestDefaultConfirmationStrategy:
"""Tests for DefaultConfirmationStrategy."""
def test_on_approval_accepted_with_enabled_steps(self, sample_steps):
strategy = DefaultConfirmationStrategy()
message = strategy.on_approval_accepted(sample_steps)
assert "Executing 2 approved steps" in message
assert "Step 1: Do something" in message
assert "Step 2: Do another thing" in message
assert "Step 3" not in message # Disabled step shouldn't appear
assert "All steps completed successfully!" in message
def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps):
strategy = DefaultConfirmationStrategy()
message = strategy.on_approval_accepted(all_enabled_steps)
assert "Executing 3 approved steps" in message
assert "Task A" in message
assert "Task B" in message
assert "Task C" in message
def test_on_approval_accepted_with_empty_steps(self, empty_steps):
strategy = DefaultConfirmationStrategy()
message = strategy.on_approval_accepted(empty_steps)
assert "Executing 0 approved steps" in message
assert "All steps completed successfully!" in message
def test_on_approval_rejected(self, sample_steps):
strategy = DefaultConfirmationStrategy()
message = strategy.on_approval_rejected(sample_steps)
assert "No problem!" in message
assert "What would you like me to change" in message
def test_on_state_confirmed(self):
strategy = DefaultConfirmationStrategy()
message = strategy.on_state_confirmed()
assert "Changes confirmed" in message
assert "successfully" in message
def test_on_state_rejected(self):
strategy = DefaultConfirmationStrategy()
message = strategy.on_state_rejected()
assert "No problem!" in message
assert "What would you like me to change" in message
class TestTaskPlannerConfirmationStrategy:
"""Tests for TaskPlannerConfirmationStrategy."""
def test_on_approval_accepted_with_enabled_steps(self, sample_steps):
strategy = TaskPlannerConfirmationStrategy()
message = strategy.on_approval_accepted(sample_steps)
assert "Executing your requested tasks" in message
assert "1. Step 1: Do something" in message
assert "2. Step 2: Do another thing" in message
assert "Step 3" not in message
assert "All tasks completed successfully!" in message
def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps):
strategy = TaskPlannerConfirmationStrategy()
message = strategy.on_approval_accepted(all_enabled_steps)
assert "Executing your requested tasks" in message
assert "1. Task A" in message
assert "2. Task B" in message
assert "3. Task C" in message
def test_on_approval_accepted_with_empty_steps(self, empty_steps):
strategy = TaskPlannerConfirmationStrategy()
message = strategy.on_approval_accepted(empty_steps)
assert "Executing your requested tasks" in message
assert "All tasks completed successfully!" in message
def test_on_approval_rejected(self, sample_steps):
strategy = TaskPlannerConfirmationStrategy()
message = strategy.on_approval_rejected(sample_steps)
assert "No problem!" in message
assert "revise the plan" in message
def test_on_state_confirmed(self):
strategy = TaskPlannerConfirmationStrategy()
message = strategy.on_state_confirmed()
assert "Tasks confirmed" in message
assert "ready to execute" in message
def test_on_state_rejected(self):
strategy = TaskPlannerConfirmationStrategy()
message = strategy.on_state_rejected()
assert "No problem!" in message
assert "adjust the task list" in message
class TestRecipeConfirmationStrategy:
"""Tests for RecipeConfirmationStrategy."""
def test_on_approval_accepted_with_enabled_steps(self, sample_steps):
strategy = RecipeConfirmationStrategy()
message = strategy.on_approval_accepted(sample_steps)
assert "Updating your recipe" in message
assert "1. Step 1: Do something" in message
assert "2. Step 2: Do another thing" in message
assert "Step 3" not in message
assert "Recipe updated successfully!" in message
def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps):
strategy = RecipeConfirmationStrategy()
message = strategy.on_approval_accepted(all_enabled_steps)
assert "Updating your recipe" in message
assert "1. Task A" in message
assert "2. Task B" in message
assert "3. Task C" in message
def test_on_approval_accepted_with_empty_steps(self, empty_steps):
strategy = RecipeConfirmationStrategy()
message = strategy.on_approval_accepted(empty_steps)
assert "Updating your recipe" in message
assert "Recipe updated successfully!" in message
def test_on_approval_rejected(self, sample_steps):
strategy = RecipeConfirmationStrategy()
message = strategy.on_approval_rejected(sample_steps)
assert "No problem!" in message
assert "ingredients or steps" in message
def test_on_state_confirmed(self):
strategy = RecipeConfirmationStrategy()
message = strategy.on_state_confirmed()
assert "Recipe changes applied" in message
assert "successfully" in message
def test_on_state_rejected(self):
strategy = RecipeConfirmationStrategy()
message = strategy.on_state_rejected()
assert "No problem!" in message
assert "adjust in the recipe" in message
class TestDocumentWriterConfirmationStrategy:
"""Tests for DocumentWriterConfirmationStrategy."""
def test_on_approval_accepted_with_enabled_steps(self, sample_steps):
strategy = DocumentWriterConfirmationStrategy()
message = strategy.on_approval_accepted(sample_steps)
assert "Applying your edits" in message
assert "1. Step 1: Do something" in message
assert "2. Step 2: Do another thing" in message
assert "Step 3" not in message
assert "Document updated successfully!" in message
def test_on_approval_accepted_with_all_enabled(self, all_enabled_steps):
strategy = DocumentWriterConfirmationStrategy()
message = strategy.on_approval_accepted(all_enabled_steps)
assert "Applying your edits" in message
assert "1. Task A" in message
assert "2. Task B" in message
assert "3. Task C" in message
def test_on_approval_accepted_with_empty_steps(self, empty_steps):
strategy = DocumentWriterConfirmationStrategy()
message = strategy.on_approval_accepted(empty_steps)
assert "Applying your edits" in message
assert "Document updated successfully!" in message
def test_on_approval_rejected(self, sample_steps):
strategy = DocumentWriterConfirmationStrategy()
message = strategy.on_approval_rejected(sample_steps)
assert "No problem!" in message
assert "keep or modify" in message
def test_on_state_confirmed(self):
strategy = DocumentWriterConfirmationStrategy()
message = strategy.on_state_confirmed()
assert "Document edits applied!" in message
def test_on_state_rejected(self):
strategy = DocumentWriterConfirmationStrategy()
message = strategy.on_state_rejected()
assert "No problem!" in message
assert "change about the document" in message
class TestConfirmationStrategyInterface:
"""Tests for ConfirmationStrategy abstract base class."""
def test_cannot_instantiate_abstract_class(self):
"""Verify ConfirmationStrategy is abstract and cannot be instantiated."""
with pytest.raises(TypeError):
ConfirmationStrategy() # type: ignore
def test_all_strategies_implement_interface(self):
"""Verify all concrete strategies implement the full interface."""
strategies = [
DefaultConfirmationStrategy(),
TaskPlannerConfirmationStrategy(),
RecipeConfirmationStrategy(),
DocumentWriterConfirmationStrategy(),
]
sample_steps = [{"description": "Test", "status": "enabled"}]
for strategy in strategies:
# All should have these methods
assert callable(strategy.on_approval_accepted)
assert callable(strategy.on_approval_rejected)
assert callable(strategy.on_state_confirmed)
assert callable(strategy.on_state_rejected)
# All should return strings
assert isinstance(strategy.on_approval_accepted(sample_steps), str)
assert isinstance(strategy.on_approval_rejected(sample_steps), str)
assert isinstance(strategy.on_state_confirmed(), str)
assert isinstance(strategy.on_state_rejected(), str)
@@ -0,0 +1,243 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for document writer predictive state flow with confirm_changes."""
from ag_ui.core import EventType
from agent_framework import FunctionCallContent, FunctionResultContent, TextContent
from agent_framework._types import AgentRunResponseUpdate
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
async def test_streaming_document_with_state_deltas():
"""Test that streaming tool arguments emit progressive StateDeltaEvents."""
predict_config = {
"document": {"tool": "write_document_local", "tool_argument": "document"},
}
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
predict_state_config=predict_config,
)
# Simulate streaming tool call - first chunk with name
tool_call_start = FunctionCallContent(
call_id="call_123",
name="write_document_local",
arguments='{"document":"Once',
)
update1 = AgentRunResponseUpdate(contents=[tool_call_start])
events1 = await bridge.from_agent_run_update(update1)
# Should have ToolCallStartEvent and ToolCallArgsEvent
assert any(e.type == EventType.TOOL_CALL_START for e in events1)
assert any(e.type == EventType.TOOL_CALL_ARGS for e in events1)
# Second chunk - incomplete JSON, should try partial extraction
tool_call_chunk2 = FunctionCallContent(
call_id="call_123",
name=None, # Name only in first chunk
arguments=" upon a time",
)
update2 = AgentRunResponseUpdate(contents=[tool_call_chunk2])
events2 = await bridge.from_agent_run_update(update2)
# Should emit StateDeltaEvent with partial document
state_deltas = [e for e in events2 if e.type == EventType.STATE_DELTA]
assert len(state_deltas) >= 1
# Check JSON Patch format
delta = state_deltas[0]
assert isinstance(delta.delta, list)
assert len(delta.delta) > 0
assert delta.delta[0]["op"] == "replace"
assert delta.delta[0]["path"] == "/document"
assert "Once upon a time" in delta.delta[0]["value"]
async def test_confirm_changes_emission():
"""Test that confirm_changes tool call is emitted after predictive tool completion."""
predict_config = {
"document": {"tool": "write_document_local", "tool_argument": "document"},
}
current_state = {}
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
predict_state_config=predict_config,
current_state=current_state,
)
# Set current tool name (simulating earlier tool call start)
bridge.current_tool_call_name = "write_document_local"
bridge.pending_state_updates = {"document": "A short story"}
# Tool result
tool_result = FunctionResultContent(
call_id="call_123",
result="Document written.",
)
update = AgentRunResponseUpdate(contents=[tool_result])
events = await bridge.from_agent_run_update(update)
# Should have: ToolCallEndEvent, ToolCallResultEvent, StateSnapshotEvent, confirm_changes sequence
assert any(e.type == EventType.TOOL_CALL_END for e in events)
assert any(e.type == EventType.TOOL_CALL_RESULT for e in events)
assert any(e.type == EventType.STATE_SNAPSHOT for e in events)
# Check for confirm_changes tool call
confirm_starts = [
e for e in events if e.type == EventType.TOOL_CALL_START and e.tool_call_name == "confirm_changes"
]
assert len(confirm_starts) == 1
confirm_args = [e for e in events if e.type == EventType.TOOL_CALL_ARGS and e.delta == "{}"]
assert len(confirm_args) >= 1
confirm_ends = [e for e in events if e.type == EventType.TOOL_CALL_END]
# At least 2: one for write_document_local, one for confirm_changes
assert len(confirm_ends) >= 2
# Check that stop flag is set
assert bridge.should_stop_after_confirm is True
async def test_text_suppression_before_confirm():
"""Test that text messages are suppressed when confirm_changes is pending."""
predict_config = {
"document": {"tool": "write_document_local", "tool_argument": "document"},
}
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
predict_state_config=predict_config,
)
# Set flag indicating we're waiting for confirmation
bridge.should_stop_after_confirm = True
# Text content that should be suppressed
text = TextContent(text="I have written a story about pirates.")
update = AgentRunResponseUpdate(contents=[text])
events = await bridge.from_agent_run_update(update)
# Should NOT emit TextMessageContentEvent
text_events = [e for e in events if e.type == EventType.TEXT_MESSAGE_CONTENT]
assert len(text_events) == 0
# But should save the text
assert bridge.suppressed_summary == "I have written a story about pirates."
async def test_no_confirm_for_non_predictive_tools():
"""Test that confirm_changes is NOT emitted for regular tool calls."""
predict_config = {
"document": {"tool": "write_document_local", "tool_argument": "document"},
}
current_state = {}
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
predict_state_config=predict_config,
current_state=current_state,
)
# Different tool (not in predict_state_config)
bridge.current_tool_call_name = "get_weather"
tool_result = FunctionResultContent(
call_id="call_456",
result="Sunny, 72°F",
)
update = AgentRunResponseUpdate(contents=[tool_result])
events = await bridge.from_agent_run_update(update)
# Should NOT have confirm_changes
confirm_starts = [
e for e in events if e.type == EventType.TOOL_CALL_START and e.tool_call_name == "confirm_changes"
]
assert len(confirm_starts) == 0
# Stop flag should NOT be set
assert bridge.should_stop_after_confirm is False
async def test_state_delta_deduplication():
"""Test that duplicate state values don't emit multiple StateDeltaEvents."""
predict_config = {
"document": {"tool": "write_document_local", "tool_argument": "document"},
}
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
predict_state_config=predict_config,
)
# First tool call with document
tool_call1 = FunctionCallContent(
call_id="call_1",
name="write_document_local",
arguments='{"document":"Same text"}',
)
update1 = AgentRunResponseUpdate(contents=[tool_call1])
events1 = await bridge.from_agent_run_update(update1)
# Count state deltas
state_deltas_1 = [e for e in events1 if e.type == EventType.STATE_DELTA]
assert len(state_deltas_1) >= 1
# Second tool call with SAME document (shouldn't emit new delta)
bridge.current_tool_call_name = "write_document_local"
tool_call2 = FunctionCallContent(
call_id="call_2",
name=None,
arguments='{"document":"Same text"}', # Identical content
)
update2 = AgentRunResponseUpdate(contents=[tool_call2])
events2 = await bridge.from_agent_run_update(update2)
# Should NOT emit state delta (same value)
state_deltas_2 = [e for e in events2 if e.type == EventType.STATE_DELTA]
assert len(state_deltas_2) == 0
async def test_predict_state_config_multiple_fields():
"""Test predictive state with multiple state fields."""
predict_config = {
"title": {"tool": "create_post", "tool_argument": "title"},
"content": {"tool": "create_post", "tool_argument": "body"},
}
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
predict_state_config=predict_config,
)
# Tool call with both fields
tool_call = FunctionCallContent(
call_id="call_999",
name="create_post",
arguments='{"title":"My Post","body":"Post content"}',
)
update = AgentRunResponseUpdate(contents=[tool_call])
events = await bridge.from_agent_run_update(update)
# Should emit StateDeltaEvent for both fields
state_deltas = [e for e in events if e.type == EventType.STATE_DELTA]
assert len(state_deltas) >= 2
# Check both fields are present
paths = [delta.delta[0]["path"] for delta in state_deltas]
assert "/title" in paths
assert "/content" in paths
@@ -0,0 +1,242 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for FastAPI endpoint creation (_endpoint.py)."""
import json
from typing import Any
from agent_framework import ChatAgent, TextContent
from agent_framework._types import ChatResponseUpdate
from fastapi import FastAPI
from fastapi.testclient import TestClient
from agent_framework_ag_ui._agent import AgentFrameworkAgent
from agent_framework_ag_ui._endpoint import add_agent_framework_fastapi_endpoint
class MockChatClient:
"""Mock chat client for testing."""
def __init__(self, response_text: str = "Test response"):
self.response_text = response_text
async def get_streaming_response(self, messages: list[Any], chat_options: Any, **kwargs: Any):
"""Mock streaming response."""
yield ChatResponseUpdate(contents=[TextContent(text=self.response_text)])
async def test_add_endpoint_with_agent_protocol():
"""Test adding endpoint with raw AgentProtocol."""
app = FastAPI()
agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient())
add_agent_framework_fastapi_endpoint(app, agent, path="/test-agent")
client = TestClient(app)
response = client.post("/test-agent", json={"messages": [{"role": "user", "content": "Hello"}]})
assert response.status_code == 200
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
async def test_add_endpoint_with_wrapped_agent():
"""Test adding endpoint with pre-wrapped AgentFrameworkAgent."""
app = FastAPI()
agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient())
wrapped_agent = AgentFrameworkAgent(agent=agent, name="wrapped")
add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/wrapped-agent")
client = TestClient(app)
response = client.post("/wrapped-agent", json={"messages": [{"role": "user", "content": "Hello"}]})
assert response.status_code == 200
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
async def test_endpoint_with_state_schema():
"""Test endpoint with state_schema parameter."""
app = FastAPI()
agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient())
state_schema = {"document": {"type": "string"}}
add_agent_framework_fastapi_endpoint(app, agent, path="/stateful", state_schema=state_schema)
client = TestClient(app)
response = client.post(
"/stateful", json={"messages": [{"role": "user", "content": "Hello"}], "state": {"document": ""}}
)
assert response.status_code == 200
async def test_endpoint_with_predict_state_config():
"""Test endpoint with predict_state_config parameter."""
app = FastAPI()
agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient())
predict_config = {"document": {"tool": "write_doc", "tool_argument": "content"}}
add_agent_framework_fastapi_endpoint(app, agent, path="/predictive", predict_state_config=predict_config)
client = TestClient(app)
response = client.post("/predictive", json={"messages": [{"role": "user", "content": "Hello"}]})
assert response.status_code == 200
async def test_endpoint_request_logging():
"""Test that endpoint logs request details."""
app = FastAPI()
agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient())
add_agent_framework_fastapi_endpoint(app, agent, path="/logged")
client = TestClient(app)
response = client.post(
"/logged",
json={
"messages": [{"role": "user", "content": "Test"}],
"run_id": "run-123",
"thread_id": "thread-456",
},
)
assert response.status_code == 200
async def test_endpoint_event_streaming():
"""Test that endpoint streams events correctly."""
app = FastAPI()
agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient("Streamed response"))
add_agent_framework_fastapi_endpoint(app, agent, path="/stream")
client = TestClient(app)
response = client.post("/stream", json={"messages": [{"role": "user", "content": "Hello"}]})
assert response.status_code == 200
content = response.content.decode("utf-8")
lines = [line for line in content.split("\n") if line.strip()]
found_run_started = False
found_text_content = False
found_run_finished = False
for line in lines:
if line.startswith("data: "):
event_data = json.loads(line[6:])
if event_data.get("type") == "RUN_STARTED":
found_run_started = True
elif event_data.get("type") == "TEXT_MESSAGE_CONTENT":
found_text_content = True
elif event_data.get("type") == "RUN_FINISHED":
found_run_finished = True
assert found_run_started
assert found_text_content
assert found_run_finished
async def test_endpoint_error_handling():
"""Test endpoint error handling during request parsing."""
app = FastAPI()
agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient())
add_agent_framework_fastapi_endpoint(app, agent, path="/failing")
client = TestClient(app)
# Send invalid JSON to trigger parsing error before streaming
response = client.post("/failing", data="invalid json", headers={"content-type": "application/json"})
# The exception handler catches it and returns JSON error
assert response.status_code == 200
content = json.loads(response.content)
assert "error" in content
assert "Expecting value" in content["error"]
async def test_endpoint_multiple_paths():
"""Test adding multiple endpoints with different paths."""
app = FastAPI()
agent1 = ChatAgent(name="agent1", instructions="First agent", chat_client=MockChatClient("Response 1"))
agent2 = ChatAgent(name="agent2", instructions="Second agent", chat_client=MockChatClient("Response 2"))
add_agent_framework_fastapi_endpoint(app, agent1, path="/agent1")
add_agent_framework_fastapi_endpoint(app, agent2, path="/agent2")
client = TestClient(app)
response1 = client.post("/agent1", json={"messages": [{"role": "user", "content": "Hi"}]})
response2 = client.post("/agent2", json={"messages": [{"role": "user", "content": "Hi"}]})
assert response1.status_code == 200
assert response2.status_code == 200
async def test_endpoint_default_path():
"""Test endpoint with default path."""
app = FastAPI()
agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient())
add_agent_framework_fastapi_endpoint(app, agent)
client = TestClient(app)
response = client.post("/", json={"messages": [{"role": "user", "content": "Hello"}]})
assert response.status_code == 200
async def test_endpoint_response_headers():
"""Test that endpoint sets correct response headers."""
app = FastAPI()
agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient())
add_agent_framework_fastapi_endpoint(app, agent, path="/headers")
client = TestClient(app)
response = client.post("/headers", json={"messages": [{"role": "user", "content": "Test"}]})
assert response.status_code == 200
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
assert "cache-control" in response.headers
assert response.headers["cache-control"] == "no-cache"
async def test_endpoint_empty_messages():
"""Test endpoint with empty messages list."""
app = FastAPI()
agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient())
add_agent_framework_fastapi_endpoint(app, agent, path="/empty")
client = TestClient(app)
response = client.post("/empty", json={"messages": []})
assert response.status_code == 200
async def test_endpoint_complex_input():
"""Test endpoint with complex input data."""
app = FastAPI()
agent = ChatAgent(name="test", instructions="Test agent", chat_client=MockChatClient())
add_agent_framework_fastapi_endpoint(app, agent, path="/complex")
client = TestClient(app)
response = client.post(
"/complex",
json={
"messages": [
{"role": "user", "content": "First message", "id": "msg-1"},
{"role": "assistant", "content": "Response", "id": "msg-2"},
{"role": "user", "content": "Follow-up", "id": "msg-3"},
],
"run_id": "complex-run-123",
"thread_id": "complex-thread-456",
"state": {"custom_field": "value"},
},
)
assert response.status_code == 200
@@ -0,0 +1,659 @@
# Copyright (c) Microsoft. All rights reserved.
"""Comprehensive tests for AgentFrameworkEventBridge (_events.py)."""
import json
from agent_framework import (
AgentRunResponseUpdate,
FunctionApprovalRequestContent,
FunctionCallContent,
FunctionResultContent,
TextContent,
)
async def test_basic_text_message_conversion():
"""Test basic TextContent to AG-UI events."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentRunResponseUpdate(contents=[TextContent(text="Hello")])
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
assert events[0].type == "TEXT_MESSAGE_START"
assert events[0].role == "assistant"
assert events[1].type == "TEXT_MESSAGE_CONTENT"
assert events[1].delta == "Hello"
async def test_text_message_streaming():
"""Test streaming TextContent with multiple chunks."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update1 = AgentRunResponseUpdate(contents=[TextContent(text="Hello ")])
update2 = AgentRunResponseUpdate(contents=[TextContent(text="world")])
events1 = await bridge.from_agent_run_update(update1)
events2 = await bridge.from_agent_run_update(update2)
# First update: START + CONTENT
assert len(events1) == 2
assert events1[0].type == "TEXT_MESSAGE_START"
assert events1[1].delta == "Hello "
# Second update: just CONTENT (same message)
assert len(events2) == 1
assert events2[0].type == "TEXT_MESSAGE_CONTENT"
assert events2[0].delta == "world"
# Both content events should have same message_id
assert events1[1].message_id == events2[0].message_id
async def test_skip_text_content_for_structured_outputs():
"""Test that text content is skipped when skip_text_content=True."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread", skip_text_content=True)
update = AgentRunResponseUpdate(contents=[TextContent(text='{"result": "data"}')])
events = await bridge.from_agent_run_update(update)
# No events should be emitted
assert len(events) == 0
async def test_tool_call_with_name():
"""Test FunctionCallContent with name emits ToolCallStartEvent."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentRunResponseUpdate(contents=[FunctionCallContent(name="search_web", call_id="call_123")])
events = await bridge.from_agent_run_update(update)
assert len(events) == 1
assert events[0].type == "TOOL_CALL_START"
assert events[0].tool_call_name == "search_web"
assert events[0].tool_call_id == "call_123"
async def test_tool_call_streaming_args():
"""Test streaming tool call arguments."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
# First chunk: name only
update1 = AgentRunResponseUpdate(contents=[FunctionCallContent(name="search_web", call_id="call_123")])
events1 = await bridge.from_agent_run_update(update1)
# Second chunk: arguments chunk 1 (name can be empty string for continuation)
update2 = AgentRunResponseUpdate(
contents=[FunctionCallContent(name="", call_id="call_123", arguments='{"query": "')]
)
events2 = await bridge.from_agent_run_update(update2)
# Third chunk: arguments chunk 2
update3 = AgentRunResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_123", arguments='AI"}')])
events3 = await bridge.from_agent_run_update(update3)
# First update: ToolCallStartEvent
assert len(events1) == 1
assert events1[0].type == "TOOL_CALL_START"
# Second update: ToolCallArgsEvent
assert len(events2) == 1
assert events2[0].type == "TOOL_CALL_ARGS"
assert events2[0].delta == '{"query": "'
# Third update: ToolCallArgsEvent
assert len(events3) == 1
assert events3[0].type == "TOOL_CALL_ARGS"
assert events3[0].delta == 'AI"}'
# All should have same tool_call_id
assert events1[0].tool_call_id == events2[0].tool_call_id == events3[0].tool_call_id
async def test_tool_result_with_dict():
"""Test FunctionResultContent with dict result."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
result_data = {"status": "success", "count": 42}
update = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=result_data)])
events = await bridge.from_agent_run_update(update)
# Should emit ToolCallEndEvent + ToolCallResultEvent
assert len(events) == 2
assert events[0].type == "TOOL_CALL_END"
assert events[0].tool_call_id == "call_123"
assert events[1].type == "TOOL_CALL_RESULT"
assert events[1].tool_call_id == "call_123"
assert events[1].role == "tool"
# Result should be JSON-serialized
assert json.loads(events[1].content) == result_data
async def test_tool_result_with_string():
"""Test FunctionResultContent with string result."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result="Search complete")])
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
assert events[0].type == "TOOL_CALL_END"
assert events[1].type == "TOOL_CALL_RESULT"
assert events[1].content == "Search complete"
async def test_tool_result_with_none():
"""Test FunctionResultContent with None result."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=None)])
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
assert events[0].type == "TOOL_CALL_END"
assert events[1].type == "TOOL_CALL_RESULT"
assert events[1].content == ""
async def test_multiple_tool_results_in_sequence():
"""Test multiple tool results processed sequentially."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentRunResponseUpdate(
contents=[
FunctionResultContent(call_id="call_1", result="Result 1"),
FunctionResultContent(call_id="call_2", result="Result 2"),
]
)
events = await bridge.from_agent_run_update(update)
# Each result emits: ToolCallEndEvent + ToolCallResultEvent = 4 events total
assert len(events) == 4
assert events[0].tool_call_id == "call_1"
assert events[1].tool_call_id == "call_1"
assert events[2].tool_call_id == "call_2"
assert events[3].tool_call_id == "call_2"
async def test_function_approval_request_basic():
"""Test FunctionApprovalRequestContent conversion."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
func_call = FunctionCallContent(
call_id="call_123",
name="send_email",
arguments={"to": "user@example.com", "subject": "Test"},
)
approval = FunctionApprovalRequestContent(
id="approval_001",
function_call=func_call,
)
update = AgentRunResponseUpdate(contents=[approval])
events = await bridge.from_agent_run_update(update)
# Should emit: ToolCallEndEvent + CustomEvent
assert len(events) == 2
# First: ToolCallEndEvent to close the tool call
assert events[0].type == "TOOL_CALL_END"
assert events[0].tool_call_id == "call_123"
# Second: CustomEvent with approval details
assert events[1].type == "CUSTOM"
assert events[1].name == "function_approval_request"
assert events[1].value["id"] == "approval_001"
assert events[1].value["function_call"]["name"] == "send_email"
async def test_empty_predict_state_config():
"""Test behavior with no predictive state configuration."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
predict_state_config={}, # Empty config
)
# Tool call with arguments
update = AgentRunResponseUpdate(
contents=[
FunctionCallContent(name="write_doc", call_id="call_1", arguments='{"content": "test"}'),
FunctionResultContent(call_id="call_1", result="Done"),
]
)
events = await bridge.from_agent_run_update(update)
# Should NOT emit StateDeltaEvent or confirm_changes
event_types = [e.type for e in events]
assert "STATE_DELTA" not in event_types
assert "STATE_SNAPSHOT" not in event_types
# Should have: ToolCallStart, ToolCallArgs, ToolCallEnd, ToolCallResult, MessagesSnapshot
# MessagesSnapshotEvent is emitted after tool results to track the conversation
assert event_types == [
"TOOL_CALL_START",
"TOOL_CALL_ARGS",
"TOOL_CALL_END",
"TOOL_CALL_RESULT",
"MESSAGES_SNAPSHOT",
]
async def test_tool_not_in_predict_state_config():
"""Test tool that doesn't match any predict_state_config entry."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
predict_state_config={
"document": {"tool": "write_document", "tool_argument": "content"},
},
)
# Different tool name
update = AgentRunResponseUpdate(
contents=[
FunctionCallContent(name="search_web", call_id="call_1", arguments='{"query": "AI"}'),
FunctionResultContent(call_id="call_1", result="Results"),
]
)
events = await bridge.from_agent_run_update(update)
# Should NOT emit StateDeltaEvent or confirm_changes
event_types = [e.type for e in events]
assert "STATE_DELTA" not in event_types
assert "STATE_SNAPSHOT" not in event_types
async def test_state_management_tracking():
"""Test current_state and pending_state_updates tracking."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
initial_state = {"document": ""}
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
predict_state_config={
"document": {"tool": "write_doc", "tool_argument": "content"},
},
current_state=initial_state,
)
# Streaming tool call
update1 = AgentRunResponseUpdate(
contents=[
FunctionCallContent(name="write_doc", call_id="call_1"),
FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Hello"}'),
]
)
await bridge.from_agent_run_update(update1)
# Check pending_state_updates was populated
assert "document" in bridge.pending_state_updates
assert bridge.pending_state_updates["document"] == "Hello"
# Tool result should update current_state
update2 = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")])
await bridge.from_agent_run_update(update2)
# current_state should be updated
assert bridge.current_state["document"] == "Hello"
# pending_state_updates should be cleared
assert len(bridge.pending_state_updates) == 0
async def test_wildcard_tool_argument():
"""Test tool_argument='*' uses all arguments as state value."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
predict_state_config={
"recipe": {"tool": "create_recipe", "tool_argument": "*"},
},
current_state={},
)
# Complete tool call with dict arguments
update = AgentRunResponseUpdate(
contents=[
FunctionCallContent(
name="create_recipe",
call_id="call_1",
arguments={"title": "Pasta", "ingredients": ["pasta", "sauce"]},
),
FunctionResultContent(call_id="call_1", result="Created"),
]
)
events = await bridge.from_agent_run_update(update)
# Find StateDeltaEvent
delta_events = [e for e in events if e.type == "STATE_DELTA"]
assert len(delta_events) > 0
# Value should be the entire arguments dict
delta = delta_events[0].delta[0]
assert delta["path"] == "/recipe"
assert delta["value"] == {"title": "Pasta", "ingredients": ["pasta", "sauce"]}
async def test_run_lifecycle_events():
"""Test RunStartedEvent and RunFinishedEvent creation."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
started = bridge.create_run_started_event()
assert started.type == "RUN_STARTED"
assert started.run_id == "test_run"
assert started.thread_id == "test_thread"
finished = bridge.create_run_finished_event(result={"status": "complete"})
assert finished.type == "RUN_FINISHED"
assert finished.run_id == "test_run"
assert finished.thread_id == "test_thread"
assert finished.result == {"status": "complete"}
async def test_message_lifecycle_events():
"""Test TextMessageStartEvent and TextMessageEndEvent creation."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
start = bridge.create_message_start_event("msg_123", role="assistant")
assert start.type == "TEXT_MESSAGE_START"
assert start.message_id == "msg_123"
assert start.role == "assistant"
end = bridge.create_message_end_event("msg_123")
assert end.type == "TEXT_MESSAGE_END"
assert end.message_id == "msg_123"
async def test_state_event_creation():
"""Test StateSnapshotEvent and StateDeltaEvent creation helpers."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
# StateSnapshotEvent
snapshot = bridge.create_state_snapshot_event({"document": "content"})
assert snapshot.type == "STATE_SNAPSHOT"
assert snapshot.snapshot == {"document": "content"}
# StateDeltaEvent with JSON Patch
delta = bridge.create_state_delta_event([{"op": "replace", "path": "/document", "value": "new content"}])
assert delta.type == "STATE_DELTA"
assert len(delta.delta) == 1
assert delta.delta[0]["op"] == "replace"
assert delta.delta[0]["path"] == "/document"
assert delta.delta[0]["value"] == "new content"
async def test_state_snapshot_after_tool_result():
"""Test StateSnapshotEvent emission after tool result with pending updates."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
predict_state_config={
"document": {"tool": "write_doc", "tool_argument": "content"},
},
current_state={"document": ""},
)
# Tool call with streaming args
update1 = AgentRunResponseUpdate(
contents=[
FunctionCallContent(name="write_doc", call_id="call_1"),
FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Test"}'),
]
)
await bridge.from_agent_run_update(update1)
# Tool result should trigger StateSnapshotEvent
update2 = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")])
events = await bridge.from_agent_run_update(update2)
# Should have: ToolCallEnd, ToolCallResult, StateSnapshot, ToolCallStart (confirm_changes), ToolCallArgs, ToolCallEnd
snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"]
assert len(snapshot_events) == 1
assert snapshot_events[0].snapshot["document"] == "Test"
async def test_message_id_persistence_across_chunks():
"""Test that message_id persists across multiple text chunks."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
# First chunk
update1 = AgentRunResponseUpdate(contents=[TextContent(text="Hello ")])
events1 = await bridge.from_agent_run_update(update1)
message_id = events1[0].message_id
# Second chunk
update2 = AgentRunResponseUpdate(contents=[TextContent(text="world")])
events2 = await bridge.from_agent_run_update(update2)
# Should use same message_id
assert events2[0].message_id == message_id
assert bridge.current_message_id == message_id
async def test_tool_call_id_tracking():
"""Test tool_call_id tracking across streaming chunks."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
# First chunk with name
update1 = AgentRunResponseUpdate(contents=[FunctionCallContent(name="search", call_id="call_1")])
await bridge.from_agent_run_update(update1)
assert bridge.current_tool_call_id == "call_1"
assert bridge.current_tool_call_name == "search"
# Second chunk with args but no name
update2 = AgentRunResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_1", arguments='{"q":"AI"}')])
events2 = await bridge.from_agent_run_update(update2)
# Should still track same tool call
assert bridge.current_tool_call_id == "call_1"
assert events2[0].tool_call_id == "call_1"
async def test_tool_name_reset_after_result():
"""Test current_tool_call_name is reset after tool result."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
predict_state_config={
"document": {"tool": "write_doc", "tool_argument": "content"},
},
)
# Tool call
update1 = AgentRunResponseUpdate(
contents=[
FunctionCallContent(name="write_doc", call_id="call_1"),
FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Test"}'),
]
)
await bridge.from_agent_run_update(update1)
assert bridge.current_tool_call_name == "write_doc"
# Tool result with predictive state (should trigger confirm_changes and reset)
update2 = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")])
await bridge.from_agent_run_update(update2)
# Tool name should be reset
assert bridge.current_tool_call_name is None
async def test_function_approval_with_wildcard_argument():
"""Test function approval with wildcard * argument."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
predict_state_config={
"payload": {"tool": "submit", "tool_argument": "*"},
},
)
approval_content = FunctionApprovalRequestContent(
id="approval_1",
function_call=FunctionCallContent(
name="submit", call_id="call_1", arguments='{"key1": "value1", "key2": "value2"}'
),
)
update = AgentRunResponseUpdate(contents=[approval_content])
events = await bridge.from_agent_run_update(update)
# Should emit StateSnapshotEvent with entire parsed args as value
snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"]
assert len(snapshot_events) == 1
assert snapshot_events[0].snapshot["payload"] == {"key1": "value1", "key2": "value2"}
async def test_function_approval_missing_argument():
"""Test function approval when specified argument is not in parsed args."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
predict_state_config={
"data": {"tool": "process", "tool_argument": "missing_field"},
},
)
approval_content = FunctionApprovalRequestContent(
id="approval_1",
function_call=FunctionCallContent(name="process", call_id="call_1", arguments='{"other_field": "value"}'),
)
update = AgentRunResponseUpdate(contents=[approval_content])
events = await bridge.from_agent_run_update(update)
# Should not emit StateSnapshotEvent since argument not found
snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"]
assert len(snapshot_events) == 0
async def test_empty_predict_state_config_no_deltas():
"""Test with empty predict_state_config (no predictive updates)."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread", predict_state_config={})
# Tool call with arguments
update = AgentRunResponseUpdate(
contents=[
FunctionCallContent(name="search", call_id="call_1"),
FunctionCallContent(name="", call_id="call_1", arguments='{"query": "test"}'),
]
)
events = await bridge.from_agent_run_update(update)
# Should not emit any StateDeltaEvents
delta_events = [e for e in events if e.type == "STATE_DELTA"]
assert len(delta_events) == 0
async def test_tool_with_no_matching_config():
"""Test tool call for tool not in predict_state_config."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
predict_state_config={"document": {"tool": "write_doc", "tool_argument": "content"}},
)
# Tool call for different tool
update = AgentRunResponseUpdate(
contents=[
FunctionCallContent(name="search_web", call_id="call_1"),
FunctionCallContent(name="", call_id="call_1", arguments='{"query": "test"}'),
]
)
events = await bridge.from_agent_run_update(update)
# Should not emit StateDeltaEvents
delta_events = [e for e in events if e.type == "STATE_DELTA"]
assert len(delta_events) == 0
async def test_tool_call_without_name_or_id():
"""Test handling FunctionCallContent with no name and no call_id."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
# This should not crash but log an error
update = AgentRunResponseUpdate(contents=[FunctionCallContent(name="", call_id="", arguments='{"arg": "val"}')])
events = await bridge.from_agent_run_update(update)
# Should emit ToolCallArgsEvent with generated ID
assert len(events) >= 1
async def test_state_delta_count_logging():
"""Test that state delta count increments and logs at intervals."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
predict_state_config={"doc": {"tool": "write", "tool_argument": "text"}},
)
# Emit multiple state deltas with different content each time
for i in range(15):
update = AgentRunResponseUpdate(
contents=[
FunctionCallContent(name="", call_id="call_1", arguments=f'{{"text": "Content variation {i}"}}'),
]
)
# Set the tool name to match config
bridge.current_tool_call_name = "write"
await bridge.from_agent_run_update(update)
# State delta count should have incremented (one per unique state update)
assert bridge.state_delta_count >= 1
@@ -0,0 +1,96 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for human in the loop (function approval requests)."""
from agent_framework import FunctionApprovalRequestContent, FunctionCallContent
from agent_framework._types import AgentRunResponseUpdate
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
async def test_function_approval_request_emission():
"""Test that CustomEvent is emitted for FunctionApprovalRequestContent."""
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
)
# Create approval request
func_call = FunctionCallContent(
call_id="call_123",
name="send_email",
arguments={"to": "user@example.com", "subject": "Test"},
)
approval_request = FunctionApprovalRequestContent(
id="approval_001",
function_call=func_call,
)
update = AgentRunResponseUpdate(contents=[approval_request])
events = await bridge.from_agent_run_update(update)
# Should emit ToolCallEndEvent + CustomEvent for approval request
assert len(events) == 2
# First event: ToolCallEndEvent to close the tool call
assert events[0].type == "TOOL_CALL_END"
assert events[0].tool_call_id == "call_123"
# Second event: CustomEvent with approval details
event = events[1]
assert event.type == "CUSTOM"
assert event.name == "function_approval_request"
assert event.value["id"] == "approval_001"
assert event.value["function_call"]["call_id"] == "call_123"
assert event.value["function_call"]["name"] == "send_email"
assert event.value["function_call"]["arguments"]["to"] == "user@example.com"
assert event.value["function_call"]["arguments"]["subject"] == "Test"
async def test_multiple_approval_requests():
"""Test handling multiple approval requests in one update."""
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
)
func_call_1 = FunctionCallContent(
call_id="call_1",
name="create_event",
arguments={"title": "Meeting"},
)
approval_1 = FunctionApprovalRequestContent(
id="approval_1",
function_call=func_call_1,
)
func_call_2 = FunctionCallContent(
call_id="call_2",
name="book_room",
arguments={"room": "Conference A"},
)
approval_2 = FunctionApprovalRequestContent(
id="approval_2",
function_call=func_call_2,
)
update = AgentRunResponseUpdate(contents=[approval_1, approval_2])
events = await bridge.from_agent_run_update(update)
# Should emit ToolCallEndEvent + CustomEvent for each approval (4 events total)
assert len(events) == 4
# Events should alternate: End, Custom, End, Custom
assert events[0].type == "TOOL_CALL_END"
assert events[0].tool_call_id == "call_1"
assert events[1].type == "CUSTOM"
assert events[1].name == "function_approval_request"
assert events[1].value["id"] == "approval_1"
assert events[2].type == "TOOL_CALL_END"
assert events[2].tool_call_id == "call_2"
assert events[3].type == "CUSTOM"
assert events[3].name == "function_approval_request"
assert events[3].value["id"] == "approval_2"
@@ -0,0 +1,249 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for message adapters."""
import pytest
from agent_framework import ChatMessage, FunctionCallContent, Role, TextContent
from agent_framework_ag_ui._message_adapters import (
agent_framework_messages_to_agui,
agui_messages_to_agent_framework,
extract_text_from_contents,
)
@pytest.fixture
def sample_agui_message():
"""Create a sample AG-UI message."""
return {"role": "user", "content": "Hello", "id": "msg-123"}
@pytest.fixture
def sample_agent_framework_message():
"""Create a sample Agent Framework message."""
return ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")], message_id="msg-123")
def test_agui_to_agent_framework_basic(sample_agui_message):
"""Test converting AG-UI message to Agent Framework."""
messages = agui_messages_to_agent_framework([sample_agui_message])
assert len(messages) == 1
assert messages[0].role == Role.USER
assert messages[0].message_id == "msg-123"
def test_agent_framework_to_agui_basic(sample_agent_framework_message):
"""Test converting Agent Framework message to AG-UI."""
messages = agent_framework_messages_to_agui([sample_agent_framework_message])
assert len(messages) == 1
assert messages[0]["role"] == "user"
assert messages[0]["content"] == "Hello"
assert messages[0]["id"] == "msg-123"
def test_agui_tool_result_to_agent_framework():
"""Test converting AG-UI tool result message to Agent Framework."""
tool_result_message = {
"role": "tool",
"content": '{"accepted": true, "steps": []}',
"toolCallId": "call_123",
"id": "msg_456",
}
messages = agui_messages_to_agent_framework([tool_result_message])
assert len(messages) == 1
message = messages[0]
assert message.role == Role.USER
assert len(message.contents) == 1
assert isinstance(message.contents[0], TextContent)
assert message.contents[0].text == '{"accepted": true, "steps": []}'
assert hasattr(message, "metadata")
assert message.metadata is not None
assert message.metadata.get("is_tool_result") is True
assert message.metadata.get("tool_call_id") == "call_123"
def test_agui_multiple_messages_to_agent_framework():
"""Test converting multiple AG-UI messages."""
messages_input = [
{"role": "user", "content": "First message", "id": "msg-1"},
{"role": "assistant", "content": "Second message", "id": "msg-2"},
{"role": "user", "content": "Third message", "id": "msg-3"},
]
messages = agui_messages_to_agent_framework(messages_input)
assert len(messages) == 3
assert messages[0].role == Role.USER
assert messages[1].role == Role.ASSISTANT
assert messages[2].role == Role.USER
def test_agui_empty_messages():
"""Test handling of empty messages list."""
messages = agui_messages_to_agent_framework([])
assert len(messages) == 0
def test_agui_function_approvals():
"""Test converting function approvals from AG-UI to Agent Framework."""
agui_msg = {
"role": "user",
"function_approvals": [
{
"call_id": "call-1",
"name": "search",
"arguments": {"query": "test"},
"approved": True,
"id": "approval-1",
},
{
"call_id": "call-2",
"name": "update",
"arguments": {"value": 42},
"approved": False,
"id": "approval-2",
},
],
"id": "msg-123",
}
messages = agui_messages_to_agent_framework([agui_msg])
assert len(messages) == 1
msg = messages[0]
assert msg.role == Role.USER
assert len(msg.contents) == 2
from agent_framework import FunctionApprovalResponseContent
assert isinstance(msg.contents[0], FunctionApprovalResponseContent)
assert msg.contents[0].approved is True
assert msg.contents[0].id == "approval-1"
assert msg.contents[0].function_call.name == "search"
assert msg.contents[0].function_call.call_id == "call-1"
assert isinstance(msg.contents[1], FunctionApprovalResponseContent)
assert msg.contents[1].approved is False
def test_agui_system_role():
"""Test converting system role messages."""
messages = agui_messages_to_agent_framework([{"role": "system", "content": "System prompt"}])
assert len(messages) == 1
assert messages[0].role == Role.SYSTEM
def test_agui_non_string_content():
"""Test handling non-string content."""
messages = agui_messages_to_agent_framework([{"role": "user", "content": {"nested": "object"}}])
assert len(messages) == 1
assert len(messages[0].contents) == 1
assert isinstance(messages[0].contents[0], TextContent)
assert "nested" in messages[0].contents[0].text
def test_agui_message_without_id():
"""Test message without ID field."""
messages = agui_messages_to_agent_framework([{"role": "user", "content": "No ID"}])
assert len(messages) == 1
assert messages[0].message_id is None
def test_agent_framework_to_agui_with_tool_calls():
"""Test converting Agent Framework message with tool calls to AG-UI."""
msg = ChatMessage(
role=Role.ASSISTANT,
contents=[
TextContent(text="Calling tool"),
FunctionCallContent(call_id="call-123", name="search", arguments={"query": "test"}),
],
message_id="msg-456",
)
messages = agent_framework_messages_to_agui([msg])
assert len(messages) == 1
agui_msg = messages[0]
assert agui_msg["role"] == "assistant"
assert agui_msg["content"] == "Calling tool"
assert "tool_calls" in agui_msg
assert len(agui_msg["tool_calls"]) == 1
assert agui_msg["tool_calls"][0]["id"] == "call-123"
assert agui_msg["tool_calls"][0]["type"] == "function"
assert agui_msg["tool_calls"][0]["function"]["name"] == "search"
assert agui_msg["tool_calls"][0]["function"]["arguments"] == {"query": "test"}
def test_agent_framework_to_agui_multiple_text_contents():
"""Test concatenating multiple text contents."""
msg = ChatMessage(
role=Role.ASSISTANT,
contents=[TextContent(text="Part 1 "), TextContent(text="Part 2")],
)
messages = agent_framework_messages_to_agui([msg])
assert len(messages) == 1
assert messages[0]["content"] == "Part 1 Part 2"
def test_agent_framework_to_agui_no_message_id():
"""Test message without message_id."""
msg = ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])
messages = agent_framework_messages_to_agui([msg])
assert len(messages) == 1
assert "id" not in messages[0]
def test_agent_framework_to_agui_system_role():
"""Test system role conversion."""
msg = ChatMessage(role=Role.SYSTEM, contents=[TextContent(text="System")])
messages = agent_framework_messages_to_agui([msg])
assert len(messages) == 1
assert messages[0]["role"] == "system"
def test_extract_text_from_contents():
"""Test extracting text from contents list."""
contents = [TextContent(text="Hello "), TextContent(text="World")]
result = extract_text_from_contents(contents)
assert result == "Hello World"
def test_extract_text_from_empty_contents():
"""Test extracting text from empty contents."""
result = extract_text_from_contents([])
assert result == ""
class CustomTextContent:
"""Custom content with text attribute."""
def __init__(self, text: str):
self.text = text
def test_extract_text_from_custom_contents():
"""Test extracting text from custom content objects."""
contents = [CustomTextContent(text="Custom "), TextContent(text="Mixed")]
result = extract_text_from_contents(contents)
assert result == "Custom Mixed"
@@ -0,0 +1,109 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for shared state management."""
import pytest
from ag_ui.core import StateSnapshotEvent
from agent_framework import ChatAgent, TextContent
from agent_framework._types import ChatResponseUpdate
from agent_framework_ag_ui._agent import AgentFrameworkAgent
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
@pytest.fixture
def mock_agent():
"""Create a mock agent for testing."""
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="Hello!")])
return ChatAgent(
name="test_agent",
instructions="Test agent",
chat_client=MockChatClient(),
)
def test_state_snapshot_event():
"""Test creating state snapshot events."""
bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread")
state = {
"recipe": {
"name": "Chocolate Chip Cookies",
"ingredients": ["flour", "sugar", "chocolate chips"],
"instructions": ["Mix ingredients", "Bake at 350°F"],
"servings": 24,
}
}
event = bridge.create_state_snapshot_event(state)
assert isinstance(event, StateSnapshotEvent)
assert event.snapshot == state
assert event.snapshot["recipe"]["name"] == "Chocolate Chip Cookies"
assert len(event.snapshot["recipe"]["ingredients"]) == 3
def test_state_delta_event():
"""Test creating state delta events using JSON Patch format."""
bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread")
# JSON Patch operations (RFC 6902)
delta = [
{"op": "add", "path": "/recipe/ingredients/-", "value": "vanilla extract"},
{"op": "replace", "path": "/recipe/servings", "value": 30},
]
event = bridge.create_state_delta_event(delta)
assert event.delta == delta
assert len(event.delta) == 2
assert event.delta[0]["op"] == "add"
assert event.delta[1]["op"] == "replace"
async def test_agent_with_initial_state(mock_agent):
"""Test agent emits state snapshot when initial state provided."""
state_schema = {"recipe": {"type": "object", "properties": {"name": {"type": "string"}}}}
agent = AgentFrameworkAgent(
agent=mock_agent,
state_schema=state_schema,
)
initial_state = {"recipe": {"name": "Test Recipe"}}
input_data = {
"messages": [{"role": "user", "content": "Hello"}],
"state": initial_state,
}
events = []
async for event in agent.run_agent(input_data):
events.append(event)
# Should have RunStartedEvent, StateSnapshotEvent, RunFinishedEvent at minimum
snapshot_events = [e for e in events if isinstance(e, StateSnapshotEvent)]
assert len(snapshot_events) == 1
assert snapshot_events[0].snapshot == initial_state
async def test_agent_without_state_schema(mock_agent):
"""Test agent doesn't emit state events without state schema."""
agent = AgentFrameworkAgent(agent=mock_agent)
input_data = {
"messages": [{"role": "user", "content": "Hello"}],
"state": {"some": "state"},
}
events = []
async for event in agent.run_agent(input_data):
events.append(event)
# Should NOT have any StateSnapshotEvent
snapshot_events = [e for e in events if isinstance(e, StateSnapshotEvent)]
assert len(snapshot_events) == 0
@@ -0,0 +1,257 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for structured output handling in _agent.py."""
import json
from typing import Any
from agent_framework import ChatAgent, ChatOptions, TextContent
from agent_framework._types import ChatResponseUpdate
from pydantic import BaseModel
class RecipeOutput(BaseModel):
"""Test Pydantic model for recipe output."""
recipe: dict[str, Any]
message: str | None = None
class StepsOutput(BaseModel):
"""Test Pydantic model for steps output."""
steps: list[dict[str, Any]]
message: str | None = None
class GenericOutput(BaseModel):
"""Test Pydantic model for generic data."""
data: dict[str, Any]
async def test_structured_output_with_recipe():
"""Test structured output processing with recipe state."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
# Simulate structured output
yield ChatResponseUpdate(
contents=[TextContent(text='{"recipe": {"name": "Pasta"}, "message": "Here is your recipe"}')]
)
agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient())
agent.chat_options = ChatOptions(response_format=RecipeOutput)
wrapper = AgentFrameworkAgent(
agent=agent,
state_schema={"recipe": {"type": "object"}},
)
input_data = {"messages": [{"role": "user", "content": "Make pasta"}]}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Should emit StateSnapshotEvent with recipe
snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"]
assert len(snapshot_events) >= 1
# Find snapshot with recipe
recipe_snapshots = [e for e in snapshot_events if "recipe" in e.snapshot]
assert len(recipe_snapshots) >= 1
assert recipe_snapshots[0].snapshot["recipe"] == {"name": "Pasta"}
# Should also emit message as text
text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"]
assert any("Here is your recipe" in e.delta for e in text_events)
async def test_structured_output_with_steps():
"""Test structured output processing with steps state."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
steps_data = {
"steps": [
{"id": "1", "description": "Step 1", "status": "pending"},
{"id": "2", "description": "Step 2", "status": "pending"},
]
}
yield ChatResponseUpdate(contents=[TextContent(text=json.dumps(steps_data))])
agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient())
agent.chat_options = ChatOptions(response_format=StepsOutput)
wrapper = AgentFrameworkAgent(
agent=agent,
state_schema={"steps": {"type": "array"}},
)
input_data = {"messages": [{"role": "user", "content": "Do steps"}]}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Should emit StateSnapshotEvent with steps
snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"]
assert len(snapshot_events) >= 1
# Snapshot should contain steps
steps_snapshots = [e for e in snapshot_events if "steps" in e.snapshot]
assert len(steps_snapshots) >= 1
assert len(steps_snapshots[0].snapshot["steps"]) == 2
assert steps_snapshots[0].snapshot["steps"][0]["id"] == "1"
async def test_structured_output_with_no_schema_match():
"""Test structured output when response fields don't match state_schema keys."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
# Response has "data" field but schema expects "result" field
yield ChatResponseUpdate(contents=[TextContent(text='{"data": {"key": "value"}}')])
agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient())
agent.chat_options = ChatOptions(response_format=GenericOutput)
wrapper = AgentFrameworkAgent(
agent=agent,
state_schema={"result": {"type": "object"}}, # Schema expects "result", not "data"
)
input_data = {"messages": [{"role": "user", "content": "Generate data"}]}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Should emit StateSnapshotEvent but with no state updates since no schema fields match
snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"]
# Initial state snapshot from state_schema initialization
assert len(snapshot_events) >= 1
async def test_structured_output_without_schema():
"""Test structured output without state_schema treats all fields as state."""
from agent_framework_ag_ui import AgentFrameworkAgent
class DataOutput(BaseModel):
"""Output with data and info fields."""
data: dict[str, Any]
info: str
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text='{"data": {"key": "value"}, "info": "processed"}')])
agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient())
agent.chat_options = ChatOptions(response_format=DataOutput)
wrapper = AgentFrameworkAgent(
agent=agent,
# No state_schema - all non-message fields treated as state
)
input_data = {"messages": [{"role": "user", "content": "Generate data"}]}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Should emit StateSnapshotEvent with both data and info fields
snapshot_events = [e for e in events if e.type == "STATE_SNAPSHOT"]
assert len(snapshot_events) >= 1
assert "data" in snapshot_events[0].snapshot
assert "info" in snapshot_events[0].snapshot
assert snapshot_events[0].snapshot["data"] == {"key": "value"}
assert snapshot_events[0].snapshot["info"] == "processed"
async def test_no_structured_output_when_no_response_format():
"""Test that structured output path is skipped when no response_format."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
yield ChatResponseUpdate(contents=[TextContent(text="Regular text")])
agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient())
# No response_format set
wrapper = AgentFrameworkAgent(agent=agent)
input_data = {"messages": [{"role": "user", "content": "Hi"}]}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Should emit text content normally
text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"]
assert len(text_events) > 0
assert text_events[0].delta == "Regular text"
async def test_structured_output_with_message_field():
"""Test structured output that includes a message field."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
output_data = {"recipe": {"name": "Salad"}, "message": "Fresh salad recipe ready"}
yield ChatResponseUpdate(contents=[TextContent(text=json.dumps(output_data))])
agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient())
agent.chat_options = ChatOptions(response_format=RecipeOutput)
wrapper = AgentFrameworkAgent(
agent=agent,
state_schema={"recipe": {"type": "object"}},
)
input_data = {"messages": [{"role": "user", "content": "Make salad"}]}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Should emit the message as text
text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"]
assert any("Fresh salad recipe ready" in e.delta for e in text_events)
# Should also have TextMessageStart and TextMessageEnd
start_events = [e for e in events if e.type == "TEXT_MESSAGE_START"]
end_events = [e for e in events if e.type == "TEXT_MESSAGE_END"]
assert len(start_events) >= 1
assert len(end_events) >= 1
async def test_empty_updates_no_structured_processing():
"""Test that empty updates don't trigger structured output processing."""
from agent_framework_ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
# Return nothing
if False:
yield
agent = ChatAgent(name="test", instructions="Test", chat_client=MockChatClient())
agent.chat_options = ChatOptions(response_format=RecipeOutput)
wrapper = AgentFrameworkAgent(agent=agent)
input_data = {"messages": [{"role": "user", "content": "Test"}]}
events = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Should only have start and end events
assert len(events) == 2 # RunStarted, RunFinished
+145
View File
@@ -0,0 +1,145 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for type definitions in _types.py."""
from agent_framework_ag_ui._types import AgentState, PredictStateConfig, RunMetadata
class TestPredictStateConfig:
"""Test PredictStateConfig TypedDict."""
def test_predict_state_config_creation(self) -> None:
"""Test creating a PredictStateConfig dict."""
config: PredictStateConfig = {
"state_key": "document",
"tool": "write_document",
"tool_argument": "content",
}
assert config["state_key"] == "document"
assert config["tool"] == "write_document"
assert config["tool_argument"] == "content"
def test_predict_state_config_with_none_tool_argument(self) -> None:
"""Test PredictStateConfig with None tool_argument."""
config: PredictStateConfig = {
"state_key": "status",
"tool": "update_status",
"tool_argument": None,
}
assert config["state_key"] == "status"
assert config["tool"] == "update_status"
assert config["tool_argument"] is None
def test_predict_state_config_type_validation(self) -> None:
"""Test that PredictStateConfig validates field types at runtime."""
config: PredictStateConfig = {
"state_key": "test",
"tool": "test_tool",
"tool_argument": "arg",
}
assert isinstance(config["state_key"], str)
assert isinstance(config["tool"], str)
assert isinstance(config["tool_argument"], (str, type(None)))
class TestRunMetadata:
"""Test RunMetadata TypedDict."""
def test_run_metadata_creation(self) -> None:
"""Test creating a RunMetadata dict."""
metadata: RunMetadata = {
"run_id": "run-123",
"thread_id": "thread-456",
"predict_state": [
{
"state_key": "document",
"tool": "write_document",
"tool_argument": "content",
}
],
}
assert metadata["run_id"] == "run-123"
assert metadata["thread_id"] == "thread-456"
assert metadata["predict_state"] is not None
assert len(metadata["predict_state"]) == 1
assert metadata["predict_state"][0]["state_key"] == "document"
def test_run_metadata_with_none_predict_state(self) -> None:
"""Test RunMetadata with None predict_state."""
metadata: RunMetadata = {
"run_id": "run-789",
"thread_id": "thread-012",
"predict_state": None,
}
assert metadata["run_id"] == "run-789"
assert metadata["thread_id"] == "thread-012"
assert metadata["predict_state"] is None
def test_run_metadata_empty_predict_state(self) -> None:
"""Test RunMetadata with empty predict_state list."""
metadata: RunMetadata = {
"run_id": "run-345",
"thread_id": "thread-678",
"predict_state": [],
}
assert metadata["run_id"] == "run-345"
assert metadata["thread_id"] == "thread-678"
assert metadata["predict_state"] == []
class TestAgentState:
"""Test AgentState TypedDict."""
def test_agent_state_creation(self) -> None:
"""Test creating an AgentState dict."""
state: AgentState = {
"messages": [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there"},
]
}
assert state["messages"] is not None
assert len(state["messages"]) == 2
assert state["messages"][0]["role"] == "user"
assert state["messages"][1]["role"] == "assistant"
def test_agent_state_with_none_messages(self) -> None:
"""Test AgentState with None messages."""
state: AgentState = {"messages": None}
assert state["messages"] is None
def test_agent_state_empty_messages(self) -> None:
"""Test AgentState with empty messages list."""
state: AgentState = {"messages": []}
assert state["messages"] == []
def test_agent_state_complex_messages(self) -> None:
"""Test AgentState with complex message structures."""
state: AgentState = {
"messages": [
{
"role": "user",
"content": "Test",
"metadata": {"timestamp": "2025-10-30"},
},
{
"role": "assistant",
"content": "Response",
"tool_calls": [{"name": "search", "args": {}}],
},
]
}
assert state["messages"] is not None
assert len(state["messages"]) == 2
assert "metadata" in state["messages"][0]
assert "tool_calls" in state["messages"][1]
+199
View File
@@ -0,0 +1,199 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for utilities."""
from dataclasses import dataclass
from datetime import date, datetime
from agent_framework_ag_ui._utils import generate_event_id, make_json_safe, merge_state
def test_generate_event_id():
"""Test event ID generation."""
id1 = generate_event_id()
id2 = generate_event_id()
assert id1 != id2
assert isinstance(id1, str)
assert len(id1) > 0
def test_merge_state():
"""Test state merging."""
current = {"a": 1, "b": 2}
update = {"b": 3, "c": 4}
result = merge_state(current, update)
assert result["a"] == 1
assert result["b"] == 3
assert result["c"] == 4
def test_merge_state_empty_update():
"""Test merging with empty update."""
current = {"x": 10, "y": 20}
update = {}
result = merge_state(current, update)
assert result == current
assert result is not current
def test_merge_state_empty_current():
"""Test merging with empty current state."""
current = {}
update = {"a": 1, "b": 2}
result = merge_state(current, update)
assert result == update
def test_merge_state_deep_copy():
"""Test that merge_state creates a deep copy preventing mutation of original."""
current = {"recipe": {"name": "Cake", "ingredients": ["flour", "sugar"]}}
update = {"other": "value"}
result = merge_state(current, update)
result["recipe"]["ingredients"].append("eggs")
assert "eggs" not in current["recipe"]["ingredients"]
assert current["recipe"]["ingredients"] == ["flour", "sugar"]
assert result["recipe"]["ingredients"] == ["flour", "sugar", "eggs"]
def test_make_json_safe_basic():
"""Test JSON serialization of basic types."""
assert make_json_safe("text") == "text"
assert make_json_safe(123) == 123
assert make_json_safe(None) is None
assert make_json_safe(3.14) == 3.14
assert make_json_safe(True) is True
assert make_json_safe(False) is False
def test_make_json_safe_datetime():
"""Test datetime serialization."""
dt = datetime(2025, 10, 30, 12, 30, 45)
result = make_json_safe(dt)
assert result == "2025-10-30T12:30:45"
def test_make_json_safe_date():
"""Test date serialization."""
d = date(2025, 10, 30)
result = make_json_safe(d)
assert result == "2025-10-30"
@dataclass
class SampleDataclass:
"""Sample dataclass for testing."""
name: str
value: int
def test_make_json_safe_dataclass():
"""Test dataclass serialization."""
obj = SampleDataclass(name="test", value=42)
result = make_json_safe(obj)
assert result == {"name": "test", "value": 42}
class ModelDumpObject:
"""Object with model_dump method."""
def model_dump(self):
return {"type": "model", "data": "dump"}
def test_make_json_safe_model_dump():
"""Test object with model_dump method."""
obj = ModelDumpObject()
result = make_json_safe(obj)
assert result == {"type": "model", "data": "dump"}
class DictObject:
"""Object with dict method."""
def dict(self):
return {"type": "dict", "method": "call"}
def test_make_json_safe_dict_method():
"""Test object with dict method."""
obj = DictObject()
result = make_json_safe(obj)
assert result == {"type": "dict", "method": "call"}
class CustomObject:
"""Custom object with __dict__."""
def __init__(self):
self.field1 = "value1"
self.field2 = 123
def test_make_json_safe_dict_attribute():
"""Test object with __dict__ attribute."""
obj = CustomObject()
result = make_json_safe(obj)
assert result == {"field1": "value1", "field2": 123}
def test_make_json_safe_list():
"""Test list serialization."""
lst = [1, "text", None, {"key": "value"}]
result = make_json_safe(lst)
assert result == [1, "text", None, {"key": "value"}]
def test_make_json_safe_tuple():
"""Test tuple serialization."""
tpl = (1, 2, 3)
result = make_json_safe(tpl)
assert result == [1, 2, 3]
def test_make_json_safe_dict():
"""Test dict serialization."""
d = {"a": 1, "b": {"c": 2}}
result = make_json_safe(d)
assert result == {"a": 1, "b": {"c": 2}}
def test_make_json_safe_nested():
"""Test nested structure serialization."""
obj = {
"datetime": datetime(2025, 10, 30),
"list": [1, 2, CustomObject()],
"nested": {"value": SampleDataclass(name="nested", value=99)},
}
result = make_json_safe(obj)
assert result["datetime"] == "2025-10-30T00:00:00"
assert result["list"][0] == 1
assert result["list"][2] == {"field1": "value1", "field2": 123}
assert result["nested"]["value"] == {"name": "nested", "value": 99}
class UnserializableObject:
"""Object that can't be serialized by standard methods."""
def __init__(self):
# Add attribute to trigger __dict__ fallback path
pass
def test_make_json_safe_fallback():
"""Test fallback to dict for objects with __dict__."""
obj = UnserializableObject()
result = make_json_safe(obj)
# Objects with __dict__ return their __dict__ dict
assert isinstance(result, dict)
+3
View File
@@ -0,0 +1,3 @@
chatkit-python
openai-chatkit-advanced-samples
chatkit-js
+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.
+87
View File
@@ -0,0 +1,87 @@
# Agent Framework and ChatKit Integration
This package provides an integration layer between Microsoft Agent Framework
and [OpenAI ChatKit (Python)](https://github.com/openai/chatkit-python/).
Specifically, it mirrors the [Agent SDK integration](https://github.com/openai/chatkit-python/blob/main/docs/server.md#agents-sdk-integration), and provides the following helpers:
- `stream_agent_response`: A helper to convert a streamed `AgentRunResponseUpdate`
from a Microsoft Agent Framework agent that implements `AgentProtocol` to ChatKit events.
- `ThreadItemConverter`: A extendable helper class to convert ChatKit thread items to
`ChatMessage` objects that can be consumed by an Agent Framework agent.
- `simple_to_agent_input`: A helper function that uses the default implementation
of `ThreadItemConverter` to convert a ChatKit thread to a list of `ChatMessage`,
useful for getting started quickly.
## Installation
```bash
pip install agent-framework-chatkit --pre
```
This will install `agent-framework-core` and `openai-chatkit` as dependencies.
## Example Usage
Here's a minimal example showing how to integrate Agent Framework with ChatKit:
```python
from collections.abc import AsyncIterator
from typing import Any
from azure.identity import AzureCliCredential
from fastapi import FastAPI, Request
from fastapi.responses import Response, StreamingResponse
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.chatkit import simple_to_agent_input, stream_agent_response
from chatkit.server import ChatKitServer
from chatkit.types import ThreadMetadata, UserMessageItem, ThreadStreamEvent
# You'll need to implement a Store - see the sample for a SQLiteStore implementation
from your_store import YourStore # type: ignore[import-not-found] # Replace with your Store implementation
# Define your agent with tools
agent = ChatAgent(
chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant.",
tools=[], # Add your tools here
)
# Create a ChatKit server that uses your agent
class MyChatKitServer(ChatKitServer[dict[str, Any]]):
async def respond(
self,
thread: ThreadMetadata,
input_user_message: UserMessageItem | None,
context: dict[str, Any],
) -> AsyncIterator[ThreadStreamEvent]:
if input_user_message is None:
return
# Convert ChatKit message to Agent Framework format
agent_messages = await simple_to_agent_input(input_user_message)
# Run the agent and stream responses
response_stream = agent.run_stream(agent_messages)
# Convert agent responses back to ChatKit events
async for event in stream_agent_response(response_stream, thread.id):
yield event
# Set up FastAPI endpoint
app = FastAPI()
chatkit_server = MyChatKitServer(YourStore()) # type: ignore[misc]
@app.post("/chatkit")
async def chatkit_endpoint(request: Request):
result = await chatkit_server.process(await request.body(), {"request": request})
if hasattr(result, '__aiter__'): # Streaming
return StreamingResponse(result, media_type="text/event-stream") # type: ignore[arg-type]
else: # Non-streaming
return Response(content=result.json, media_type="application/json") # type: ignore[union-attr]
```
For a complete end-to-end example with a full frontend, see the [weather agent sample](../../samples/demos/chatkit-integration/README.md).
@@ -0,0 +1,25 @@
# Copyright (c) Microsoft. All rights reserved.
"""Agent Framework and ChatKit Integration.
This package provides an integration layer between Microsoft Agent Framework
and OpenAI ChatKit (Python). It mirrors the Agent SDK integration and provides
helpers to convert between Agent Framework and ChatKit types.
"""
import importlib.metadata
from ._converter import ThreadItemConverter, simple_to_agent_input
from ._streaming import stream_agent_response
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"ThreadItemConverter",
"__version__",
"simple_to_agent_input",
"stream_agent_response",
]
@@ -0,0 +1,603 @@
# Copyright (c) Microsoft. All rights reserved.
"""Converter utilities for converting ChatKit thread items to Agent Framework messages."""
import logging
import sys
from collections.abc import Awaitable, Callable, Sequence
if sys.version_info >= (3, 11):
from typing import assert_never
else:
from typing_extensions import assert_never
from agent_framework import (
ChatMessage,
DataContent,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
UriContent,
)
from chatkit.types import (
AssistantMessageItem,
Attachment,
ClientToolCallItem,
EndOfTurnItem,
HiddenContextItem,
ImageAttachment,
TaskItem,
ThreadItem,
UserMessageItem,
UserMessageTagContent,
UserMessageTextContent,
WidgetItem,
WorkflowItem,
)
logger = logging.getLogger(__name__)
class ThreadItemConverter:
"""Helper class to convert ChatKit thread items to Agent Framework ChatMessage objects.
This class provides a base implementation for converting ChatKit thread items
to Agent Framework messages. It can be extended to handle attachments,
@-mentions, hidden context items, and custom thread item formats.
Args:
attachment_data_fetcher: Optional async function to fetch attachment binary data.
If provided, it should take an attachment ID and return the binary data as bytes.
If not provided, attachments will be converted to UriContent using available URLs.
"""
def __init__(
self,
attachment_data_fetcher: Callable[[str], Awaitable[bytes]] | None = None,
) -> None:
"""Initialize the converter.
Args:
attachment_data_fetcher: Optional async function to fetch attachment data by ID.
"""
self.attachment_data_fetcher = attachment_data_fetcher
async def user_message_to_input(
self, item: UserMessageItem, is_last_message: bool = True
) -> ChatMessage | list[ChatMessage] | None:
"""Convert a ChatKit UserMessageItem to Agent Framework ChatMessage(s).
This method is called internally by `to_agent_input()`. Override this method
to customize how user messages are converted.
Args:
item: The ChatKit user message item to convert.
is_last_message: Whether this is the last message in the thread (used for quoted_text handling).
Returns:
A ChatMessage, list of messages, or None to skip.
Note:
Instead of calling this method directly, use `to_agent_input()` which handles
all ThreadItem types and provides proper message ordering.
"""
# Extract text content from the user message
text_content = ""
if item.content:
for content_part in item.content:
if isinstance(content_part, UserMessageTextContent):
text_content += content_part.text
# Convert attachments to DataContent or UriContent
data_contents: list[DataContent | UriContent] = []
if item.attachments:
for attachment in item.attachments:
content = await self.attachment_to_message_content(attachment)
if content is not None:
data_contents.append(content)
# Create the message with text and attachments
if not text_content.strip() and not data_contents:
return None
# If only text and no attachments, use text parameter for simplicity
if text_content.strip() and not data_contents:
user_message = ChatMessage(role=Role.USER, text=text_content.strip())
else:
# Build contents list with both text and attachments
contents: list[TextContent | DataContent | UriContent] = []
if text_content.strip():
contents.append(TextContent(text=text_content.strip()))
contents.extend(data_contents)
user_message = ChatMessage(role=Role.USER, contents=contents)
# Handle quoted text if this is the last message
messages = [user_message]
if item.quoted_text and is_last_message:
quoted_context = ChatMessage(
role=Role.USER,
text=f"The user is referring to this in particular:\n{item.quoted_text}",
)
# Prepend quoted context before the main message
messages.insert(0, quoted_context)
return messages
async def attachment_to_message_content(self, attachment: Attachment) -> DataContent | UriContent | None:
"""Convert a ChatKit attachment to Agent Framework content.
This method is called internally by `user_message_to_input()` to handle attachments.
Override this method to customize attachment handling for your storage backend.
The default implementation provides two strategies:
1. If an attachment_data_fetcher was provided, it fetches the binary data
and creates a DataContent object
2. Otherwise, for ImageAttachment with preview_url, it creates a UriContent object
For FileAttachment without a data fetcher, returns None (attachment is skipped).
Args:
attachment: The ChatKit attachment to convert (FileAttachment or ImageAttachment).
Returns:
DataContent if binary data is available, UriContent if only URL is available,
or None if the attachment cannot be converted.
Note:
Instead of calling this method directly, use `to_agent_input()` which handles
all ThreadItem types including attachments within user messages.
Examples:
.. code-block:: python
# With data fetcher
async def fetch_data(attachment_id: str) -> bytes:
return await my_storage.get_file(attachment_id)
converter = ThreadItemConverter(attachment_data_fetcher=fetch_data)
messages = await converter.to_agent_input(thread_items)
# Without data fetcher (uses URLs for images)
converter = ThreadItemConverter()
messages = await converter.to_agent_input(thread_items)
"""
# If we have a data fetcher, use it to get binary data
if self.attachment_data_fetcher is not None:
try:
data = await self.attachment_data_fetcher(attachment.id)
return DataContent(data=data, media_type=attachment.mime_type)
except Exception as e:
# If fetch fails, fall through to URL-based approach
logger.debug(f"Failed to fetch attachment data for {attachment.id}: {e}")
# For ImageAttachment, try to use preview_url
if isinstance(attachment, ImageAttachment) and attachment.preview_url:
return UriContent(uri=str(attachment.preview_url), media_type=attachment.mime_type)
# For FileAttachment without data fetcher, skip the attachment
# Subclasses can override this method to provide custom handling
return None
def hidden_context_to_input(self, item: HiddenContextItem) -> ChatMessage | list[ChatMessage] | None:
"""Convert a ChatKit HiddenContextItem to Agent Framework ChatMessage(s).
This method is called internally by `to_agent_input()`. Override this method
to customize how hidden context is converted.
The default implementation wraps the hidden context in XML tags and returns
a system message. This allows the model to distinguish hidden context from
regular conversation.
Args:
item: The ChatKit hidden context item to convert.
Returns:
A ChatMessage with system role, a list of messages, or None to skip.
Note:
Instead of calling this method directly, use `to_agent_input()` which handles
all ThreadItem types and provides proper message ordering.
Examples:
.. code-block:: python
# Default behavior
converter = ThreadItemConverter()
hidden_item = HiddenContextItem(
id="ctx_1",
thread_id="thread_1",
created_at=datetime.now(),
content="User's email: user@example.com",
)
message = converter.hidden_context_to_input(hidden_item)
# Returns: ChatMessage(role=SYSTEM, text="<HIDDEN_CONTEXT>User's email: ...</HIDDEN_CONTEXT>")
"""
return ChatMessage(role=Role.SYSTEM, text=f"<HIDDEN_CONTEXT>{item.content}</HIDDEN_CONTEXT>")
def tag_to_message_content(self, tag: UserMessageTagContent) -> TextContent:
"""Convert a ChatKit tag (@-mention) to Agent Framework content.
This method is called internally by `user_message_to_input()` to handle tags.
Override this method to customize tag conversion for your application.
The default implementation extracts the tag's display name and wraps it in
XML tags to provide context to the model about the @-mention.
Args:
tag: The ChatKit tag content to convert.
Returns:
TextContent with the tag information.
Note:
Instead of calling this method directly, use `to_agent_input()` which handles
all ThreadItem types including tags within user messages.
Examples:
.. code-block:: python
# Default behavior
converter = ThreadItemConverter()
tag = UserMessageTagContent(
type="input_tag", id="tag_1", text="john", data={"name": "John Doe"}, interactive=False
)
content = converter.tag_to_message_content(tag)
# Returns: TextContent(text="<TAG>Name:John Doe</TAG>")
"""
name = getattr(tag.data, "name", tag.text if hasattr(tag, "text") else "unknown")
return TextContent(text=f"<TAG>Name:{name}</TAG>")
def task_to_input(self, item: TaskItem) -> ChatMessage | list[ChatMessage] | None:
"""Convert a ChatKit TaskItem to Agent Framework ChatMessage(s).
This method is called internally by `to_agent_input()`. Override this method
to customize how tasks are converted.
The default implementation converts custom tasks with title/content into
a user message explaining what task was displayed to the user.
Args:
item: The ChatKit task item to convert.
Returns:
A ChatMessage, a list of messages, or None to skip the task.
Note:
Instead of calling this method directly, use `to_agent_input()` which handles
all ThreadItem types and provides proper message ordering.
Examples:
.. code-block:: python
# Task with both title and content
from chatkit.types import Task
task_item = TaskItem(
id="task_1",
thread_id="thread_1",
created_at=datetime.now(),
task=Task(type="custom", title="Data Analysis", content="Analyzed sales data"),
)
message = converter.task_to_input(task_item)
# Returns message explaining the task was performed
"""
if item.task.type != "custom" or (not item.task.title and not item.task.content):
return None
title = item.task.title or ""
content = item.task.content or ""
task_text = f"{title}: {content}" if title and content else title or content
text = (
f"A message was displayed to the user that the following task was performed:\n<Task>\n{task_text}\n</Task>"
)
return ChatMessage(role=Role.USER, text=text)
def workflow_to_input(self, item: WorkflowItem) -> ChatMessage | list[ChatMessage] | None:
"""Convert a ChatKit WorkflowItem to Agent Framework ChatMessage(s).
This method is called internally by `to_agent_input()`. Override this method
to customize how workflows are converted.
The default implementation converts each custom task in the workflow into
a separate user message explaining what tasks were performed.
Args:
item: The ChatKit workflow item to convert.
Returns:
A list of ChatMessages (one per task), a single message, or None to skip.
Note:
Instead of calling this method directly, use `to_agent_input()` which handles
all ThreadItem types and provides proper message ordering.
Examples:
.. code-block:: python
# Workflow with multiple tasks
from chatkit.types import Workflow, Task
workflow_item = WorkflowItem(
id="wf_1",
thread_id="thread_1",
created_at=datetime.now(),
workflow=Workflow(
type="custom",
tasks=[
Task(type="custom", title="Step 1", content="Gathered data"),
Task(type="custom", title="Step 2", content="Analyzed results"),
],
),
)
messages = converter.workflow_to_input(workflow_item)
# Returns list of messages for each task
"""
messages: list[ChatMessage] = []
for task in item.workflow.tasks:
if task.type != "custom" or (not task.title and not task.content):
continue
title = task.title or ""
content = task.content or ""
task_text = f"{title}: {content}" if title and content else title or content
text = (
"A message was displayed to the user that the following task was performed:\n"
f"<Task>\n{task_text}\n</Task>"
)
messages.append(ChatMessage(role=Role.USER, text=text))
return messages if messages else None
def widget_to_input(self, item: WidgetItem) -> ChatMessage | list[ChatMessage] | None:
"""Convert a ChatKit WidgetItem to Agent Framework ChatMessage(s).
This method is called internally by `to_agent_input()`. Override this method
to customize how widgets are converted.
The default implementation converts the widget to a JSON representation
and includes it in a user message, allowing the model to understand what
UI element was displayed to the user.
Args:
item: The ChatKit widget item to convert.
Returns:
A ChatMessage describing the widget, or None to skip.
Note:
Instead of calling this method directly, use `to_agent_input()` which handles
all ThreadItem types and provides proper message ordering.
Examples:
.. code-block:: python
# Widget item
from chatkit.widgets import Card, Text
widget_item = WidgetItem(
id="widget_1",
thread_id="thread_1",
created_at=datetime.now(),
widget=Card(children=[Text(value="Hello")]),
)
message = converter.widget_to_input(widget_item)
# Returns message with JSON representation of the widget
"""
try:
widget_json = item.widget.model_dump_json(exclude_unset=True, exclude_none=True)
text = f"The following graphical UI widget (id: {item.id}) was displayed to the user:{widget_json}"
return ChatMessage(role=Role.USER, text=text)
except Exception:
# If JSON serialization fails, skip the widget
return None
async def assistant_message_to_input(self, item: AssistantMessageItem) -> ChatMessage | list[ChatMessage] | None:
"""Convert a ChatKit AssistantMessageItem to Agent Framework ChatMessage(s).
The default implementation extracts text from all content parts and creates
an assistant message.
Args:
item: The ChatKit assistant message item to convert.
Returns:
A ChatMessage with assistant role, or None to skip.
Note:
Instead of calling this method directly, use `to_agent_input()` which handles
all ThreadItem types and provides proper message ordering.
"""
# Extract text from all content parts
text_parts = [content.text for content in item.content]
if not text_parts:
return None
return ChatMessage(role=Role.ASSISTANT, text="".join(text_parts))
async def client_tool_call_to_input(self, item: ClientToolCallItem) -> ChatMessage | list[ChatMessage] | None:
"""Convert a ChatKit ClientToolCallItem to Agent Framework ChatMessage(s).
The default implementation converts completed tool calls into function call
and result content.
Args:
item: The ChatKit client tool call item to convert.
Returns:
A list containing function call and result messages, or None for pending calls.
Note:
Instead of calling this method directly, use `to_agent_input()` which handles
all ThreadItem types and provides proper message ordering.
"""
if item.status == "pending":
# Skip pending tool calls - they cannot be sent to the model
return None
import json
# Create function call message
function_call_msg = ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
call_id=item.call_id,
name=item.name,
arguments=json.dumps(item.arguments),
)
],
)
# Create function result message
function_result_msg = ChatMessage(
role=Role.TOOL,
contents=[
FunctionResultContent(
call_id=item.call_id,
result=json.dumps(item.output) if item.output is not None else "",
)
],
)
return [function_call_msg, function_result_msg]
async def end_of_turn_to_input(self, item: EndOfTurnItem) -> ChatMessage | list[ChatMessage] | None:
"""Convert a ChatKit EndOfTurnItem to Agent Framework ChatMessage(s).
The default implementation skips end-of-turn markers as they are only UI hints.
Args:
item: The ChatKit end-of-turn item to convert.
Returns:
None (end-of-turn items are not converted).
Note:
Instead of calling this method directly, use `to_agent_input()` which handles
all ThreadItem types and provides proper message ordering.
"""
# End-of-turn is only used for UI hints - skip it
return None
async def _thread_item_to_input_item(
self,
item: ThreadItem,
is_last_message: bool = True,
) -> list[ChatMessage]:
"""Internal method to convert a single ThreadItem to ChatMessage(s).
Args:
item: The thread item to convert.
is_last_message: Whether this is the last item in the thread.
Returns:
A list of ChatMessage objects (may be empty).
"""
match item:
case UserMessageItem():
out = await self.user_message_to_input(item, is_last_message) or []
return out if isinstance(out, list) else [out]
case AssistantMessageItem():
out = await self.assistant_message_to_input(item) or []
return out if isinstance(out, list) else [out]
case ClientToolCallItem():
out = await self.client_tool_call_to_input(item) or []
return out if isinstance(out, list) else [out]
case EndOfTurnItem():
out = await self.end_of_turn_to_input(item) or []
return out if isinstance(out, list) else [out]
case WidgetItem():
out = self.widget_to_input(item) or []
return out if isinstance(out, list) else [out]
case WorkflowItem():
out = self.workflow_to_input(item) or []
return out if isinstance(out, list) else [out]
case TaskItem():
out = self.task_to_input(item) or []
return out if isinstance(out, list) else [out]
case HiddenContextItem():
out = self.hidden_context_to_input(item) or []
return out if isinstance(out, list) else [out]
case _:
assert_never(item)
async def to_agent_input(
self,
thread_items: Sequence[ThreadItem] | ThreadItem,
) -> list[ChatMessage]:
"""Convert ChatKit thread items to Agent Framework ChatMessages.
This is the main entry point for converting ChatKit thread items. It handles
all ThreadItem types (UserMessageItem, AssistantMessageItem, TaskItem, etc.)
and calls the appropriate conversion method for each.
Args:
thread_items: A single ThreadItem or a sequence of ThreadItems to convert.
Returns:
A list of ChatMessage objects that can be sent to an Agent Framework agent.
Examples:
.. code-block:: python
from agent_framework_chatkit import ThreadItemConverter
converter = ThreadItemConverter()
# Convert a single thread item
messages = await converter.to_agent_input(user_message_item)
# Convert multiple thread items
messages = await converter.to_agent_input([user_message_item, assistant_message_item, task_item])
# Use with agent
from agent_framework import ChatAgent
agent = ChatAgent(...)
response = await agent.run_stream(messages)
"""
thread_items = list(thread_items) if isinstance(thread_items, Sequence) else [thread_items]
output: list[ChatMessage] = []
for item in thread_items:
output.extend(
await self._thread_item_to_input_item(
item,
is_last_message=item is thread_items[-1],
)
)
return output
# Default converter instance
_DEFAULT_CONVERTER = ThreadItemConverter()
async def simple_to_agent_input(thread_items: Sequence[ThreadItem] | ThreadItem) -> list[ChatMessage]:
"""Helper function that uses the default ThreadItemConverter.
This function provides a quick way to get started with ChatKit integration
without needing to create a custom ThreadItemConverter instance.
Args:
thread_items: A single ThreadItem or a sequence of ThreadItems to convert.
Returns:
A list of ChatMessage objects that can be sent to an Agent Framework agent.
Examples:
.. code-block:: python
from agent_framework_chatkit import simple_to_agent_input
# Convert a single item
messages = await simple_to_agent_input(user_message_item)
# Convert multiple items
messages = await simple_to_agent_input([user_message_item, assistant_message_item, task_item])
"""
return await _DEFAULT_CONVERTER.to_agent_input(thread_items)
@@ -0,0 +1,104 @@
# Copyright (c) Microsoft. All rights reserved.
"""Streaming utilities for converting Agent Framework responses to ChatKit events."""
import uuid
from collections.abc import AsyncIterable, AsyncIterator, Callable
from datetime import datetime
from agent_framework import AgentRunResponseUpdate, TextContent
from chatkit.types import (
AssistantMessageContent,
AssistantMessageContentPartTextDelta,
AssistantMessageItem,
ThreadItemAddedEvent,
ThreadItemDoneEvent,
ThreadItemUpdated,
ThreadStreamEvent,
)
async def stream_agent_response(
response_stream: AsyncIterable[AgentRunResponseUpdate],
thread_id: str,
generate_id: Callable[[str], str] | None = None,
) -> AsyncIterator[ThreadStreamEvent]:
"""Convert a streamed AgentRunResponseUpdate from Agent Framework to ChatKit events.
This helper function takes a stream of AgentRunResponseUpdate objects from
a Microsoft Agent Framework agent and converts them to ChatKit ThreadStreamEvent
objects that can be consumed by the ChatKit UI.
The function supports real-time token-by-token streaming by emitting
ThreadItemUpdated events with AssistantMessageContentPartTextDelta for each
text chunk as it arrives from the agent.
Args:
response_stream: An async iterable of AgentRunResponseUpdate objects
from an Agent Framework agent.
thread_id: The ChatKit thread ID for the conversation.
generate_id: Optional function to generate IDs for ChatKit items.
If not provided, simple incremental IDs will be used.
Yields:
ThreadStreamEvent: ChatKit events representing the agent's response,
including incremental text deltas for streaming display.
"""
# Use provided ID generator or create default one
if generate_id is None:
def _default_id_generator(item_type: str) -> str:
return f"{item_type}_{uuid.uuid4().hex[:8]}"
message_id = _default_id_generator("msg")
else:
message_id = generate_id("msg")
# Track if we've started the message
message_started = False
accumulated_text = ""
content_index = 0
async for update in response_stream:
# Start the assistant message if not already started
if not message_started:
assistant_message = AssistantMessageItem(
id=message_id,
thread_id=thread_id,
type="assistant_message",
content=[],
created_at=datetime.now(),
)
yield ThreadItemAddedEvent(type="thread.item.added", item=assistant_message)
message_started = True
# Process the update content
if update.contents:
for content in update.contents:
# Handle text content - only TextContent has a text attribute
if isinstance(content, TextContent) and content.text is not None:
# Yield incremental text delta for streaming display
yield ThreadItemUpdated(
type="thread.item.updated",
item_id=message_id,
update=AssistantMessageContentPartTextDelta(
content_index=content_index,
delta=content.text,
),
)
accumulated_text += content.text
# Finalize the message
if message_started:
final_message = AssistantMessageItem(
id=message_id,
thread_id=thread_id,
type="assistant_message",
content=[AssistantMessageContent(type="output_text", text=accumulated_text, annotations=[])]
if accumulated_text
else [],
created_at=datetime.now(),
)
yield ThreadItemDoneEvent(type="thread.item.done", item=final_message)
+89
View File
@@ -0,0 +1,89 @@
[project]
name = "agent-framework-chatkit"
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.0b251001"
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",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core",
"openai-chatkit>=1.1.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.ruff.lint]
ignore = ["RUF029"]
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extend = "../../pyproject.toml"
exclude = ['tests', 'chatkit-python', 'openai-chatkit-advanced-samples']
[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_chatkit"]
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_chatkit"
test = "pytest --cov=agent_framework_chatkit --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,426 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for ChatKit to Agent Framework converter utilities."""
from unittest.mock import Mock
import pytest
from agent_framework import ChatMessage, Role, TextContent
from chatkit.types import UserMessageTextContent
from agent_framework_chatkit import ThreadItemConverter, simple_to_agent_input
class TestThreadItemConverter:
"""Tests for ThreadItemConverter class."""
@pytest.fixture
def converter(self):
"""Create a ThreadItemConverter instance for testing."""
return ThreadItemConverter()
async def test_to_agent_input_none(self, converter):
"""Test converting empty list returns empty list."""
result = await converter.to_agent_input([])
assert result == []
async def test_to_agent_input_with_text(self, converter):
"""Test converting user message with text content."""
from datetime import datetime
from chatkit.types import UserMessageItem
input_item = UserMessageItem(
id="msg_1",
thread_id="thread_1",
created_at=datetime.now(),
type="user_message",
content=[UserMessageTextContent(text="Hello, how can you help me?")],
attachments=[],
inference_options={},
)
result = await converter.to_agent_input(input_item)
assert len(result) == 1
assert isinstance(result[0], ChatMessage)
assert result[0].role == Role.USER
assert result[0].text == "Hello, how can you help me?"
async def test_to_agent_input_empty_text(self, converter):
"""Test converting user message with empty or whitespace-only text."""
from datetime import datetime
from chatkit.types import UserMessageItem
input_item = UserMessageItem(
id="msg_1",
thread_id="thread_1",
created_at=datetime.now(),
type="user_message",
content=[UserMessageTextContent(text=" ")],
attachments=[],
inference_options={},
)
result = await converter.to_agent_input(input_item)
assert result == []
async def test_to_agent_input_no_content(self, converter):
"""Test converting user message with no content."""
from datetime import datetime
from chatkit.types import UserMessageItem
input_item = UserMessageItem(
id="msg_1",
thread_id="thread_1",
created_at=datetime.now(),
type="user_message",
content=[],
attachments=[],
inference_options={},
)
result = await converter.to_agent_input(input_item)
assert result == []
async def test_to_agent_input_multiple_content_parts(self, converter):
"""Test converting user message with multiple text content parts."""
from datetime import datetime
from chatkit.types import UserMessageItem
input_item = UserMessageItem(
id="msg_1",
thread_id="thread_1",
created_at=datetime.now(),
type="user_message",
content=[
UserMessageTextContent(text="Hello "),
UserMessageTextContent(text="world!"),
],
attachments=[],
inference_options={},
)
result = await converter.to_agent_input(input_item)
assert len(result) == 1
assert result[0].text == "Hello world!"
def test_hidden_context_to_input(self, converter):
"""Test converting hidden context item to ChatMessage."""
hidden_item = Mock()
hidden_item.content = "This is hidden context information"
result = converter.hidden_context_to_input(hidden_item)
assert isinstance(result, ChatMessage)
assert result.role == Role.SYSTEM
assert result.text == "<HIDDEN_CONTEXT>This is hidden context information</HIDDEN_CONTEXT>"
def test_tag_to_message_content(self, converter):
"""Test converting tag to message content."""
from chatkit.types import UserMessageTagContent
tag = UserMessageTagContent(
type="input_tag",
id="tag_1",
text="john",
data={"name": "John Doe"},
interactive=False,
)
result = converter.tag_to_message_content(tag)
assert isinstance(result, TextContent)
# Since data is a dict, getattr won't work, so it will fall back to text
assert result.text == "<TAG>Name:john</TAG>"
def test_tag_to_message_content_no_name(self, converter):
"""Test converting tag with no name to message content."""
from chatkit.types import UserMessageTagContent
tag = UserMessageTagContent(
type="input_tag",
id="tag_2",
text="jane",
data={},
interactive=False,
)
result = converter.tag_to_message_content(tag)
assert isinstance(result, TextContent)
assert result.text == "<TAG>Name:jane</TAG>"
async def test_attachment_to_message_content_file_without_fetcher(self, converter):
"""Test that FileAttachment without data fetcher returns None."""
from chatkit.types import FileAttachment
attachment = FileAttachment(
id="file_123",
name="document.pdf",
mime_type="application/pdf",
type="file",
)
result = await converter.attachment_to_message_content(attachment)
assert result is None
async def test_attachment_to_message_content_image_with_preview_url(self, converter):
"""Test that ImageAttachment with preview_url creates UriContent."""
from agent_framework import UriContent
from chatkit.types import ImageAttachment
attachment = ImageAttachment(
id="img_123",
name="photo.jpg",
mime_type="image/jpeg",
type="image",
preview_url="https://example.com/photo.jpg",
)
result = await converter.attachment_to_message_content(attachment)
assert isinstance(result, UriContent)
assert result.uri == "https://example.com/photo.jpg"
assert result.media_type == "image/jpeg"
async def test_attachment_to_message_content_with_data_fetcher(self):
"""Test attachment conversion with data fetcher."""
from agent_framework import DataContent
from chatkit.types import FileAttachment
# Mock data fetcher
async def fetch_data(attachment_id: str) -> bytes:
return b"file content data"
converter = ThreadItemConverter(attachment_data_fetcher=fetch_data)
attachment = FileAttachment(
id="file_123",
name="document.pdf",
mime_type="application/pdf",
type="file",
)
result = await converter.attachment_to_message_content(attachment)
assert isinstance(result, DataContent)
assert result.media_type == "application/pdf"
async def test_to_agent_input_with_image_attachment(self):
"""Test converting user message with text and image attachment."""
from datetime import datetime
from agent_framework import UriContent
from chatkit.types import ImageAttachment, UserMessageItem
attachment = ImageAttachment(
id="img_123",
name="photo.jpg",
mime_type="image/jpeg",
type="image",
preview_url="https://example.com/photo.jpg",
)
input_item = UserMessageItem(
id="msg_1",
thread_id="thread_1",
created_at=datetime.now(),
type="user_message",
content=[UserMessageTextContent(text="Check out this photo!")],
attachments=[attachment],
inference_options={},
)
converter = ThreadItemConverter()
result = await converter.to_agent_input(input_item)
assert len(result) == 1
message = result[0]
assert message.role == Role.USER
assert len(message.contents) == 2
# First content should be text
assert isinstance(message.contents[0], TextContent)
assert message.contents[0].text == "Check out this photo!"
# Second content should be UriContent for the image
assert isinstance(message.contents[1], UriContent)
assert message.contents[1].uri == "https://example.com/photo.jpg"
assert message.contents[1].media_type == "image/jpeg"
async def test_to_agent_input_with_file_attachment_and_fetcher(self):
"""Test converting user message with file attachment using data fetcher."""
from datetime import datetime
from agent_framework import DataContent
from chatkit.types import FileAttachment, UserMessageItem
attachment = FileAttachment(
id="file_123",
name="report.pdf",
mime_type="application/pdf",
type="file",
)
input_item = UserMessageItem(
id="msg_1",
thread_id="thread_1",
created_at=datetime.now(),
type="user_message",
content=[UserMessageTextContent(text="Here's the document")],
attachments=[attachment],
inference_options={},
)
# Create converter with data fetcher
async def fetch_data(attachment_id: str) -> bytes:
return b"PDF content data"
converter = ThreadItemConverter(attachment_data_fetcher=fetch_data)
result = await converter.to_agent_input(input_item)
assert len(result) == 1
message = result[0]
assert len(message.contents) == 2
# First content should be text
assert isinstance(message.contents[0], TextContent)
# Second content should be DataContent for the file
assert isinstance(message.contents[1], DataContent)
assert message.contents[1].media_type == "application/pdf"
def test_task_to_input(self, converter):
"""Test converting TaskItem to ChatMessage."""
from datetime import datetime
from chatkit.types import CustomTask, TaskItem
task_item = TaskItem(
id="task_1",
thread_id="thread_1",
created_at=datetime.now(),
type="task",
task=CustomTask(type="custom", title="Analysis", content="Analyzed the data"),
)
result = converter.task_to_input(task_item)
assert isinstance(result, ChatMessage)
assert result.role == Role.USER
assert "Analysis: Analyzed the data" in result.text
assert "<Task>" in result.text
def test_task_to_input_no_custom_task(self, converter):
"""Test that non-custom tasks return None."""
from datetime import datetime
from chatkit.types import TaskItem, ThoughtTask
task_item = TaskItem(
id="task_1",
thread_id="thread_1",
created_at=datetime.now(),
type="task",
task=ThoughtTask(type="thought", title="Think", content="Thinking..."),
)
result = converter.task_to_input(task_item)
assert result is None
def test_workflow_to_input(self, converter):
"""Test converting WorkflowItem to ChatMessages."""
from datetime import datetime
from chatkit.types import CustomTask, Workflow, WorkflowItem
workflow_item = WorkflowItem(
id="wf_1",
thread_id="thread_1",
created_at=datetime.now(),
type="workflow",
workflow=Workflow(
type="custom",
tasks=[
CustomTask(type="custom", title="Step 1", content="First step"),
CustomTask(type="custom", title="Step 2", content="Second step"),
],
),
)
result = converter.workflow_to_input(workflow_item)
assert isinstance(result, list)
assert len(result) == 2
assert all(isinstance(msg, ChatMessage) for msg in result)
assert "Step 1: First step" in result[0].text
assert "Step 2: Second step" in result[1].text
def test_workflow_to_input_empty(self, converter):
"""Test that workflows with no custom tasks return None."""
from datetime import datetime
from chatkit.types import Workflow, WorkflowItem
workflow_item = WorkflowItem(
id="wf_1",
thread_id="thread_1",
created_at=datetime.now(),
type="workflow",
workflow=Workflow(type="custom", tasks=[]),
)
result = converter.workflow_to_input(workflow_item)
assert result is None
def test_widget_to_input(self, converter):
"""Test converting WidgetItem to ChatMessage."""
from datetime import datetime
from chatkit.types import WidgetItem
from chatkit.widgets import Card, Text
widget_item = WidgetItem(
id="widget_1",
thread_id="thread_1",
created_at=datetime.now(),
type="widget",
widget=Card(key="card1", children=[Text(value="Hello")]),
)
result = converter.widget_to_input(widget_item)
assert isinstance(result, ChatMessage)
assert result.role == Role.USER
assert "widget_1" in result.text
assert "graphical UI widget" in result.text
class TestSimpleToAgentInput:
"""Tests for simple_to_agent_input helper function."""
async def test_simple_to_agent_input_empty_list(self):
"""Test simple conversion with empty list."""
result = await simple_to_agent_input([])
assert result == []
async def test_simple_to_agent_input_with_text(self):
"""Test simple conversion with text content."""
from datetime import datetime
from chatkit.types import UserMessageItem
input_item = UserMessageItem(
id="msg_1",
thread_id="thread_1",
created_at=datetime.now(),
type="user_message",
content=[UserMessageTextContent(text="Test message")],
attachments=[],
inference_options={},
)
result = await simple_to_agent_input(input_item)
assert len(result) == 1
assert isinstance(result[0], ChatMessage)
assert result[0].role == Role.USER
assert result[0].text == "Test message"
@@ -0,0 +1,142 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for Agent Framework to ChatKit streaming utilities."""
from unittest.mock import Mock
from agent_framework import AgentRunResponseUpdate, Role, TextContent
from chatkit.types import (
ThreadItemAddedEvent,
ThreadItemDoneEvent,
ThreadItemUpdated,
)
from agent_framework_chatkit import stream_agent_response
class TestStreamAgentResponse:
"""Tests for stream_agent_response function."""
async def test_stream_empty_response(self):
"""Test streaming empty response."""
async def empty_stream():
return
yield # Make it a generator
events = []
async for event in stream_agent_response(empty_stream(), thread_id="test_thread"):
events.append(event)
assert len(events) == 0
async def test_stream_single_text_update(self):
"""Test streaming single text update."""
async def single_update_stream():
yield AgentRunResponseUpdate(role=Role.ASSISTANT, contents=[TextContent(text="Hello world")])
events = []
async for event in stream_agent_response(single_update_stream(), thread_id="test_thread"):
events.append(event)
# Should have: item_added, item_updated (delta), item_done
assert len(events) == 3
# Check event types
assert isinstance(events[0], ThreadItemAddedEvent)
assert isinstance(events[1], ThreadItemUpdated)
assert isinstance(events[2], ThreadItemDoneEvent)
# Check delta event
assert events[1].update.delta == "Hello world"
# Check final message content
assert len(events[2].item.content) == 1
assert events[2].item.content[0].text == "Hello world"
async def test_stream_multiple_text_updates(self):
"""Test streaming multiple text updates."""
async def multiple_updates_stream():
yield AgentRunResponseUpdate(role=Role.ASSISTANT, contents=[TextContent(text="Hello ")])
yield AgentRunResponseUpdate(role=Role.ASSISTANT, contents=[TextContent(text="world!")])
events = []
async for event in stream_agent_response(multiple_updates_stream(), thread_id="test_thread"):
events.append(event)
# Should have: item_added, item_updated (delta 1), item_updated (delta 2), item_done
assert len(events) == 4
# Check event types
assert isinstance(events[0], ThreadItemAddedEvent)
assert isinstance(events[1], ThreadItemUpdated)
assert isinstance(events[2], ThreadItemUpdated)
assert isinstance(events[3], ThreadItemDoneEvent)
# Check delta events
assert events[1].update.delta == "Hello "
assert events[2].update.delta == "world!"
# Check final accumulated text
final_message_event = events[-1]
assert isinstance(final_message_event, ThreadItemDoneEvent)
assert final_message_event.item.content[0].text == "Hello world!"
async def test_stream_with_custom_id_generator(self):
"""Test streaming with custom ID generator."""
def custom_id_generator(item_type: str) -> str:
return f"custom_{item_type}_123"
async def single_update_stream():
yield AgentRunResponseUpdate(role=Role.ASSISTANT, contents=[TextContent(text="Test")])
events = []
async for event in stream_agent_response(
single_update_stream(), thread_id="test_thread", generate_id=custom_id_generator
):
events.append(event)
# Check that custom IDs are used
message_added_event = events[0]
assert message_added_event.item.id == "custom_msg_123"
async def test_stream_empty_content_updates(self):
"""Test streaming updates with empty content."""
async def empty_content_stream():
yield AgentRunResponseUpdate(role=Role.ASSISTANT, contents=[])
yield AgentRunResponseUpdate(role=Role.ASSISTANT, contents=None)
events = []
async for event in stream_agent_response(empty_content_stream(), thread_id="test_thread"):
events.append(event)
# Should have item_added and item_done
assert len(events) == 2
assert isinstance(events[0], ThreadItemAddedEvent)
assert isinstance(events[1], ThreadItemDoneEvent)
# Final message should have empty content
assert len(events[1].item.content) == 0
async def test_stream_non_text_content(self):
"""Test streaming updates with non-text content."""
# Mock a content object without text attribute
non_text_content = Mock()
# Don't set text attribute
del non_text_content.text
async def non_text_stream():
yield AgentRunResponseUpdate(role=Role.ASSISTANT, contents=[non_text_content])
events = []
async for event in stream_agent_response(non_text_stream(), thread_id="test_thread"):
events.append(event)
# Should have item_added and item_done, but no content since no text
assert len(events) == 2
assert isinstance(events[0], ThreadItemAddedEvent)
assert isinstance(events[1], ThreadItemDoneEvent)
@@ -0,0 +1,23 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib
from typing import Any
PACKAGE_NAME = "agent_framework_chatkit"
PACKAGE_EXTRA = "chatkit"
_IMPORTS = ["__version__", "ThreadItemConverter", "simple_to_agent_input", "stream_agent_response"]
def __getattr__(name: str) -> Any:
if name in _IMPORTS:
try:
return getattr(importlib.import_module(PACKAGE_NAME), name)
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
f"The '{PACKAGE_EXTRA}' extra is not installed, please do `pip install agent-framework-{PACKAGE_EXTRA}`"
) from exc
raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.")
def __dir__() -> list[str]:
return _IMPORTS
@@ -0,0 +1,10 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework_chatkit import (
ThreadItemConverter,
__version__,
simple_to_agent_input,
stream_agent_response,
)
__all__ = ["ThreadItemConverter", "__version__", "simple_to_agent_input", "stream_agent_response"]
+6
View File
@@ -24,8 +24,10 @@ classifiers = [
dependencies = [
"agent-framework-core",
"agent-framework-a2a",
"agent-framework-ag-ui",
"agent-framework-anthropic",
"agent-framework-azure-ai",
"agent-framework-chatkit",
"agent-framework-copilotstudio",
"agent-framework-devui",
"agent-framework-lab",
@@ -88,7 +90,9 @@ members = [ "packages/*" ]
agent-framework = { workspace = true }
agent-framework-core = { workspace = true }
agent-framework-a2a = { workspace = true }
agent-framework-ag-ui = { workspace = true }
agent-framework-azure-ai = { workspace = true }
agent-framework-chatkit = { workspace = true }
agent-framework-copilotstudio = { workspace = true }
agent-framework-lab = { workspace = true }
agent-framework-mem0 = { workspace = true }
@@ -239,7 +243,9 @@ cmd = """
pytest --import-mode=importlib
--cov=agent_framework
--cov=agent_framework_a2a
--cov=agent_framework_ag_ui
--cov=agent_framework_azure_ai
--cov=agent_framework_chatkit
--cov=agent_framework_copilotstudio
--cov=agent_framework_mem0
--cov=agent_framework_redis
@@ -0,0 +1,4 @@
*.db
*.db-shm
*.db-wal
uploads/
@@ -0,0 +1,268 @@
# ChatKit Integration Sample with Weather Agent and Image Analysis
This sample demonstrates how to integrate Microsoft Agent Framework with OpenAI ChatKit. It provides a complete implementation of a weather assistant with interactive widget visualization, image analysis, and file upload support.
**Features:**
- Weather information with interactive widgets
- Image analysis using vision models
- Current time queries
- File upload with attachment storage
- Chat interface with streaming responses
- City selector widget with one-click weather
## Architecture
```mermaid
graph TB
subgraph Frontend["React Frontend (ChatKit UI)"]
UI[ChatKit Components]
Upload[File Upload]
end
subgraph Backend["FastAPI Server"]
FastAPI[FastAPI Endpoints]
subgraph ChatKit["WeatherChatKitServer"]
Respond[respond method]
Action[action method]
end
subgraph Stores["Data & Storage Layer"]
SQLite[SQLiteStore<br/>Store Protocol]
AttStore[FileBasedAttachmentStore<br/>AttachmentStore Protocol]
DB[(SQLite DB<br/>chatkit_demo.db)]
Files[/uploads directory/]
end
subgraph Integration["Agent Framework Integration"]
Converter[ThreadItemConverter]
Streamer[stream_agent_response]
Agent[ChatAgent]
end
Widgets[Widget Rendering<br/>render_weather_widget<br/>render_city_selector_widget]
end
subgraph Azure["Azure AI"]
Foundry[GPT-5<br/>with Vision]
end
UI -->|HTTP POST /chatkit| FastAPI
Upload -->|HTTP POST /upload/id| FastAPI
FastAPI --> ChatKit
ChatKit -->|save/load threads| SQLite
ChatKit -->|save/load attachments| AttStore
ChatKit -->|convert messages| Converter
SQLite -.->|persist| DB
AttStore -.->|save files| Files
AttStore -.->|save metadata| SQLite
Converter -->|ChatMessage array| Agent
Agent -->|AgentRunResponseUpdate| Streamer
Streamer -->|ThreadStreamEvent| ChatKit
ChatKit --> Widgets
Widgets -->|WidgetItem| ChatKit
Agent <-->|Chat Completions API| Foundry
ChatKit -->|ThreadStreamEvent| FastAPI
FastAPI -->|SSE Stream| UI
style ChatKit fill:#e1f5ff
style Stores fill:#fff4e1
style Integration fill:#f0e1ff
style Azure fill:#e1ffe1
```
### Server Implementation
The sample implements a ChatKit server using the `ChatKitServer` base class from the `chatkit` package:
**Core Components:**
- **`WeatherChatKitServer`**: Custom ChatKit server implementation that:
- Extends `ChatKitServer[dict[str, Any]]`
- Uses Agent Framework's `ChatAgent` with Azure OpenAI
- Converts ChatKit messages to Agent Framework format using `ThreadItemConverter`
- Streams responses back to ChatKit using `stream_agent_response`
- Creates and streams interactive widgets after agent responses
- **`SQLiteStore`**: Data persistence layer that:
- Implements the `Store[dict[str, Any]]` protocol from ChatKit
- Persists threads, messages, and attachment metadata in SQLite
- Provides thread management and item history
- Stores attachment metadata for the upload lifecycle
- **`FileBasedAttachmentStore`**: File storage implementation that:
- Implements the `AttachmentStore[dict[str, Any]]` protocol from ChatKit
- Stores uploaded files on the local filesystem (in `./uploads` directory)
- Generates upload URLs for two-phase file upload
- Saves attachment metadata to the data store for upload tracking
- Provides preview URLs for images
**Key Integration Points:**
```python
# Converting ChatKit messages to Agent Framework
converter = ThreadItemConverter(
attachment_data_fetcher=self._fetch_attachment_data
)
agent_messages = await converter.to_agent_input(user_message_item)
# Running agent and streaming back to ChatKit
async for event in stream_agent_response(
self.weather_agent.run_stream(agent_messages),
thread_id=thread.id,
):
yield event
# Streaming widgets
widget = render_weather_widget(weather_data)
async for event in stream_widget(thread_id=thread.id, widget=widget):
yield event
```
## Installation and Setup
### Prerequisites
- Python 3.10+
- Node.js 18.18+ and npm 9+
- Azure OpenAI service configured
- Azure CLI for authentication (`az login`)
### Backend Setup
1. **Install Python packages:**
```bash
cd python/samples/demos/chatkit-integration
pip install agent-framework-chatkit fastapi uvicorn azure-identity
```
2. **Configure Azure OpenAI:**
```bash
export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
export AZURE_OPENAI_API_VERSION="2024-06-01"
export AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="gpt-4o"
```
3. **Authenticate with Azure:**
```bash
az login
```
### Frontend Setup
Install the Node.js dependencies:
```bash
cd frontend
npm install
```
## How to Run
### Start the Backend Server
From the `chatkit-integration` directory:
```bash
python app.py
```
Or with auto-reload for development:
```bash
uvicorn app:app --host 127.0.0.1 --port 8001 --reload
```
The backend will start on `http://localhost:8001`
### Start the Frontend Development Server
In a new terminal, from the `frontend` directory:
```bash
npm run dev
```
The frontend will start on `http://localhost:5171`
### Access the Application
Open your browser and navigate to:
```
http://localhost:5171
```
You can now:
- Ask about weather in any location (weather widgets display automatically)
- Upload images for analysis using the attachment button
- Get the current time
- Ask to see available cities and click city buttons for instant weather
### Project Structure
```
chatkit-integration/
├── app.py # FastAPI backend with ChatKitServer implementation
├── store.py # SQLiteStore implementation
├── attachment_store.py # FileBasedAttachmentStore implementation
├── weather_widget.py # Widget rendering functions
├── chatkit_demo.db # SQLite database (auto-created)
├── uploads/ # Uploaded files directory (auto-created)
└── frontend/
├── package.json
├── vite.config.ts
├── index.html
└── src/
├── main.tsx
└── App.tsx # ChatKit UI integration
```
### Configuration
You can customize the application by editing constants at the top of `app.py`:
```python
# Server configuration
SERVER_HOST = "127.0.0.1" # Bind to localhost only for security (local dev)
SERVER_PORT = 8001
SERVER_BASE_URL = f"http://localhost:{SERVER_PORT}"
# Database configuration
DATABASE_PATH = "chatkit_demo.db"
# File storage configuration
UPLOADS_DIRECTORY = "./uploads"
# User context
DEFAULT_USER_ID = "demo_user"
```
### Sample Conversations
Try these example queries:
- "What's the weather like in Tokyo?"
- "Show me available cities" (displays interactive city selector)
- "What's the current time?"
- Upload an image and ask "What do you see in this image?"
## Learn More
- [Agent Framework Documentation](https://aka.ms/agent-framework)
- [ChatKit Documentation](https://platform.openai.com/docs/guides/chatkit)
- [Azure OpenAI Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/)
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,538 @@
# Copyright (c) Microsoft. All rights reserved.
"""
ChatKit Integration Sample with Weather Agent and Image Analysis
This sample demonstrates how to integrate Microsoft Agent Framework with OpenAI ChatKit
using a weather tool with widget visualization, image analysis, and Azure OpenAI. It shows
a complete ChatKit server implementation using Agent Framework agents with proper FastAPI
setup, interactive weather widgets, and vision capabilities for analyzing uploaded images.
"""
import logging
from collections.abc import AsyncIterator, Callable
from datetime import datetime, timezone
from random import randint
from typing import Annotated, Any
import uvicorn
from azure.identity import AzureCliCredential
from fastapi import FastAPI, File, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse
from pydantic import Field
# ============================================================================
# Configuration Constants
# ============================================================================
# Server configuration
SERVER_HOST = "127.0.0.1" # Bind to localhost only for security (local dev)
SERVER_PORT = 8001
SERVER_BASE_URL = f"http://localhost:{SERVER_PORT}"
# Database configuration
DATABASE_PATH = "chatkit_demo.db"
# File storage configuration
UPLOADS_DIRECTORY = "./uploads"
# User context
DEFAULT_USER_ID = "demo_user"
# Logging configuration
LOG_LEVEL = logging.INFO
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
# ============================================================================
# Logging Setup
# ============================================================================
logging.basicConfig(
level=LOG_LEVEL,
format=LOG_FORMAT,
datefmt=LOG_DATE_FORMAT,
)
logger = logging.getLogger(__name__)
# Agent Framework imports
from agent_framework import AgentRunResponseUpdate, ChatAgent, ChatMessage, FunctionResultContent, Role
from agent_framework.azure import AzureOpenAIChatClient
# Agent Framework ChatKit integration
from agent_framework_chatkit import ThreadItemConverter, stream_agent_response
# Local imports
from attachment_store import FileBasedAttachmentStore
# ChatKit imports
from chatkit.actions import Action
from chatkit.server import ChatKitServer
from chatkit.store import StoreItemType, default_generate_id
from chatkit.types import (
ThreadItemDoneEvent,
ThreadMetadata,
ThreadStreamEvent,
UserMessageItem,
WidgetItem,
)
from chatkit.widgets import WidgetRoot
from store import SQLiteStore
from weather_widget import (
WeatherData,
city_selector_copy_text,
render_city_selector_widget,
render_weather_widget,
weather_widget_copy_text,
)
class WeatherResponse(str):
"""A string response that also carries WeatherData for widget creation."""
def __new__(cls, text: str, weather_data: WeatherData):
instance = super().__new__(cls, text)
instance.weather_data = weather_data # type: ignore
return instance
async def stream_widget(
thread_id: str,
widget: WidgetRoot,
copy_text: str | None = None,
generate_id: Callable[[StoreItemType], str] = default_generate_id,
) -> AsyncIterator[ThreadStreamEvent]:
"""Stream a ChatKit widget as a ThreadStreamEvent.
This helper function creates a ChatKit widget item and yields it as a
ThreadItemDoneEvent that can be consumed by the ChatKit UI.
Args:
thread_id: The ChatKit thread ID for the conversation.
widget: The ChatKit widget to display.
copy_text: Optional text representation of the widget for copy/paste.
generate_id: Optional function to generate IDs for ChatKit items.
Yields:
ThreadStreamEvent: ChatKit event containing the widget.
"""
item_id = generate_id("message")
widget_item = WidgetItem(
id=item_id,
thread_id=thread_id,
created_at=datetime.now(),
widget=widget,
copy_text=copy_text,
)
yield ThreadItemDoneEvent(type="thread.item.done", item=widget_item)
def get_weather(
location: Annotated[str, Field(description="The location to get the weather for.")],
) -> str:
"""Get the weather for a given location.
Returns a string description with embedded WeatherData for widget creation.
"""
logger.info(f"Fetching weather for location: {location}")
conditions = ["sunny", "cloudy", "rainy", "stormy", "snowy", "foggy"]
temperature = randint(-5, 35)
condition = conditions[randint(0, len(conditions) - 1)]
# Add some realistic details
humidity = randint(30, 90)
wind_speed = randint(5, 25)
weather_data = WeatherData(
location=location,
condition=condition,
temperature=temperature,
humidity=humidity,
wind_speed=wind_speed,
)
logger.debug(f"Weather data generated: {condition}, {temperature}°C, {humidity}% humidity, {wind_speed} km/h wind")
# Return a WeatherResponse that is both a string (for the LLM) and carries structured data
text = (
f"Weather in {location}:\n"
f"• Condition: {condition.title()}\n"
f"• Temperature: {temperature}°C\n"
f"• Humidity: {humidity}%\n"
f"• Wind: {wind_speed} km/h"
)
return WeatherResponse(text, weather_data)
def get_time() -> str:
"""Get the current UTC time."""
current_time = datetime.now(timezone.utc)
logger.info("Getting current UTC time")
return f"Current UTC time: {current_time.strftime('%Y-%m-%d %H:%M:%S')} UTC"
def show_city_selector() -> str:
"""Show an interactive city selector widget to the user.
This function triggers the display of a widget that allows users
to select from popular cities to get weather information.
Returns a special marker string that will be detected to show the widget.
"""
logger.info("Activating city selector widget")
return "__SHOW_CITY_SELECTOR__"
class WeatherChatKitServer(ChatKitServer[dict[str, Any]]):
"""ChatKit server implementation using Agent Framework.
This server integrates Agent Framework agents with ChatKit's server protocol,
providing weather information with interactive widgets and time queries through Azure OpenAI.
"""
def __init__(self, data_store: SQLiteStore, attachment_store: FileBasedAttachmentStore):
super().__init__(data_store, attachment_store)
logger.info("Initializing WeatherChatKitServer")
# Create Agent Framework agent with Azure OpenAI
# For authentication, run `az login` command in terminal
try:
self.weather_agent = ChatAgent(
chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()),
instructions=(
"You are a helpful weather assistant with image analysis capabilities. "
"You can provide weather information for any location, tell the current time, "
"and analyze images that users upload. Be friendly and informative in your responses.\n\n"
"If a user asks to see a list of cities or wants to choose from available cities, "
"use the show_city_selector tool to display an interactive city selector.\n\n"
"When users upload images, you will automatically receive them and can analyze their content. "
"Describe what you see in detail and be helpful in answering questions about the images."
),
tools=[get_weather, get_time, show_city_selector],
)
logger.info("Weather agent initialized successfully with Azure OpenAI")
except Exception as e:
logger.error(f"Failed to initialize weather agent: {e}")
raise
# Create ThreadItemConverter with attachment data fetcher
self.converter = ThreadItemConverter(
attachment_data_fetcher=self._fetch_attachment_data,
)
logger.info("WeatherChatKitServer initialized")
async def _fetch_attachment_data(self, attachment_id: str) -> bytes:
"""Fetch attachment binary data for the converter.
Args:
attachment_id: The ID of the attachment to fetch.
Returns:
The binary data of the attachment.
"""
return await attachment_store.read_attachment_bytes(attachment_id)
async def respond(
self,
thread: ThreadMetadata,
input_user_message: UserMessageItem | None,
context: dict[str, Any],
) -> AsyncIterator[ThreadStreamEvent]:
"""Handle incoming user messages and generate responses.
This method converts ChatKit messages to Agent Framework format using ThreadItemConverter,
runs the agent, converts the response back to ChatKit events using stream_agent_response,
and creates interactive weather widgets when weather data is queried.
"""
from agent_framework import FunctionResultContent
if input_user_message is None:
logger.debug("Received None user message, skipping")
return
logger.info(f"Processing message for thread: {thread.id}")
try:
# Track weather data and city selector flag for this request
weather_data: WeatherData | None = None
show_city_selector = False
# Convert ChatKit user message to Agent Framework ChatMessage using ThreadItemConverter
agent_messages = await self.converter.to_agent_input(input_user_message)
if not agent_messages:
logger.warning("No messages after conversion")
return
logger.info(f"Running agent with {len(agent_messages)} message(s)")
# Run the Agent Framework agent with streaming
agent_stream = self.weather_agent.run_stream(agent_messages)
# Create an intercepting stream that extracts function results while passing through updates
async def intercept_stream() -> AsyncIterator[AgentRunResponseUpdate]:
nonlocal weather_data, show_city_selector
async for update in agent_stream:
# Check for function results in the update
if update.contents:
for content in update.contents:
if isinstance(content, FunctionResultContent):
result = content.result
# Check if it's a WeatherResponse (string subclass with weather_data attribute)
if isinstance(result, str) and hasattr(result, "weather_data"):
extracted_data = getattr(result, "weather_data", None)
if isinstance(extracted_data, WeatherData):
weather_data = extracted_data
logger.info(f"Weather data extracted: {weather_data.location}")
# Check if it's the city selector marker
elif isinstance(result, str) and result == "__SHOW_CITY_SELECTOR__":
show_city_selector = True
logger.info("City selector flag detected")
yield update
# Stream updates as ChatKit events with interception
async for event in stream_agent_response(
intercept_stream(),
thread_id=thread.id,
):
yield event
# If weather data was collected during the tool call, create a widget
if weather_data is not None and isinstance(weather_data, WeatherData):
logger.info(f"Creating weather widget for location: {weather_data.location}")
# Create weather widget
widget = render_weather_widget(weather_data)
copy_text = weather_widget_copy_text(weather_data)
# Stream the widget
async for widget_event in stream_widget(thread_id=thread.id, widget=widget, copy_text=copy_text):
yield widget_event
logger.debug("Weather widget streamed successfully")
# If city selector should be shown, create and stream that widget
if show_city_selector:
logger.info("Creating city selector widget")
# Create city selector widget
selector_widget = render_city_selector_widget()
selector_copy_text = city_selector_copy_text()
# Stream the widget
async for widget_event in stream_widget(
thread_id=thread.id, widget=selector_widget, copy_text=selector_copy_text
):
yield widget_event
logger.debug("City selector widget streamed successfully")
logger.info(f"Completed processing message for thread: {thread.id}")
except Exception as e:
logger.error(f"Error processing message for thread {thread.id}: {e}", exc_info=True)
async def action(
self,
thread: ThreadMetadata,
action: Action[str, Any],
sender: WidgetItem | None,
context: dict[str, Any],
) -> AsyncIterator[ThreadStreamEvent]:
"""Handle widget actions from the frontend.
This method processes actions triggered by interactive widgets,
such as city selection from the city selector widget.
"""
logger.info(f"Received action: {action.type} for thread: {thread.id}")
if action.type == "city_selected":
# Extract city information from the action payload
city_label = action.payload.get("city_label", "Unknown")
logger.info(f"City selected: {city_label}")
logger.debug(f"Action payload: {action.payload}")
# Track weather data for this request
weather_data: WeatherData | None = None
# Create an agent message asking about the weather
agent_messages = [ChatMessage(role=Role.USER, text=f"What's the weather in {city_label}?")]
logger.debug(f"Processing weather query: {agent_messages[0].text}")
# Run the Agent Framework agent with streaming
agent_stream = self.weather_agent.run_stream(agent_messages)
# Create an intercepting stream that extracts function results while passing through updates
async def intercept_stream() -> AsyncIterator[AgentRunResponseUpdate]:
nonlocal weather_data
async for update in agent_stream:
# Check for function results in the update
if update.contents:
for content in update.contents:
if isinstance(content, FunctionResultContent):
result = content.result
# Check if it's a WeatherResponse (string subclass with weather_data attribute)
if isinstance(result, str) and hasattr(result, "weather_data"):
extracted_data = getattr(result, "weather_data", None)
if isinstance(extracted_data, WeatherData):
weather_data = extracted_data
logger.info(f"Weather data extracted: {weather_data.location}")
yield update
# Stream updates as ChatKit events with interception
async for event in stream_agent_response(
intercept_stream(),
thread_id=thread.id,
):
yield event
# If weather data was collected during the tool call, create a widget
if weather_data is not None and isinstance(weather_data, WeatherData):
logger.info(f"Creating weather widget for: {weather_data.location}")
# Create weather widget
widget = render_weather_widget(weather_data)
copy_text = weather_widget_copy_text(weather_data)
# Stream the widget
async for widget_event in stream_widget(thread_id=thread.id, widget=widget, copy_text=copy_text):
yield widget_event
logger.debug("Weather widget created successfully from action")
else:
logger.warning("No weather data available to create widget after action")
# FastAPI application setup
app = FastAPI(
title="ChatKit Weather & Vision Agent",
description="Weather and image analysis assistant powered by Agent Framework and Azure OpenAI",
version="1.0.0",
)
# Add CORS middleware to allow frontend connections
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, specify exact origins
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Initialize data store and ChatKit server
logger.info("Initializing application components")
data_store = SQLiteStore(db_path=DATABASE_PATH)
attachment_store = FileBasedAttachmentStore(
uploads_dir=UPLOADS_DIRECTORY,
base_url=SERVER_BASE_URL,
data_store=data_store,
)
chatkit_server = WeatherChatKitServer(data_store, attachment_store)
logger.info("Application initialization complete")
@app.post("/chatkit")
async def chatkit_endpoint(request: Request):
"""Main ChatKit endpoint that handles all ChatKit requests.
This endpoint follows the ChatKit server protocol and handles both
streaming and non-streaming responses.
"""
logger.debug(f"Received ChatKit request from {request.client}")
request_body = await request.body()
# Create context following the working examples pattern
context = {"request": request}
try:
# Process the request using ChatKit server
result = await chatkit_server.process(request_body, context)
# Return appropriate response type
if hasattr(result, "__aiter__"): # StreamingResult
logger.debug("Returning streaming response")
return StreamingResponse(result, media_type="text/event-stream") # type: ignore[arg-type]
# NonStreamingResult
logger.debug("Returning non-streaming response")
return Response(content=result.json, media_type="application/json") # type: ignore[union-attr]
except Exception as e:
logger.error(f"Error processing ChatKit request: {e}", exc_info=True)
raise
@app.post("/upload/{attachment_id}")
async def upload_file(attachment_id: str, file: UploadFile = File(...)):
"""Handle file upload for two-phase upload.
The client POSTs the file bytes here after creating the attachment
via the ChatKit attachments.create endpoint.
"""
logger.info(f"Receiving file upload for attachment: {attachment_id}")
try:
# Read file contents
contents = await file.read()
# Save to disk
file_path = attachment_store.get_file_path(attachment_id)
file_path.write_bytes(contents)
logger.info(f"Saved {len(contents)} bytes to {file_path}")
# Load the attachment metadata from the data store
attachment = await data_store.load_attachment(attachment_id, {"user_id": DEFAULT_USER_ID})
# Clear the upload_url since upload is complete
attachment.upload_url = None
# Save the updated attachment back to the store
await data_store.save_attachment(attachment, {"user_id": DEFAULT_USER_ID})
# Return the attachment metadata as JSON
return JSONResponse(content=attachment.model_dump(mode="json"))
except Exception as e:
logger.error(f"Error uploading file for attachment {attachment_id}: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"error": f"Failed to upload file: {str(e)}"})
@app.get("/preview/{attachment_id}")
async def preview_image(attachment_id: str):
"""Serve image preview/thumbnail.
For simplicity, this serves the full image. In production, you should
generate and cache thumbnails.
"""
logger.debug(f"Serving preview for attachment: {attachment_id}")
try:
file_path = attachment_store.get_file_path(attachment_id)
if not file_path.exists():
return JSONResponse(status_code=404, content={"error": "File not found"})
# Determine media type from file extension or attachment metadata
# For simplicity, we'll try to load from the store
try:
attachment = await data_store.load_attachment(attachment_id, {"user_id": DEFAULT_USER_ID})
media_type = attachment.mime_type
except Exception:
# Default to binary if we can't determine
media_type = "application/octet-stream"
return FileResponse(file_path, media_type=media_type)
except Exception as e:
logger.error(f"Error serving preview for attachment {attachment_id}: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"error": str(e)})
if __name__ == "__main__":
# Run the server
logger.info(f"Starting ChatKit Weather Agent server on {SERVER_HOST}:{SERVER_PORT}")
uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT, log_level="info")
@@ -0,0 +1,121 @@
# Copyright (c) Microsoft. All rights reserved.
"""File-based AttachmentStore implementation for ChatKit.
This module provides a simple AttachmentStore implementation that stores
uploaded files on the local filesystem. In production, you should use
cloud storage like S3, Azure Blob Storage, or Google Cloud Storage.
"""
from pathlib import Path
from typing import Any, TYPE_CHECKING
from chatkit.store import AttachmentStore
from chatkit.types import Attachment, AttachmentCreateParams, FileAttachment, ImageAttachment
from pydantic import AnyUrl
if TYPE_CHECKING:
from store import SQLiteStore
class FileBasedAttachmentStore(AttachmentStore[dict[str, Any]]):
"""File-based AttachmentStore that stores files on local disk.
This implementation stores uploaded files in a local directory and provides
upload URLs that point to the FastAPI upload endpoint. It supports both
image and file attachments.
Features:
- Stores files in a local uploads directory
- Generates upload URLs for two-phase upload
- Generates preview URLs for images
- Proper cleanup on deletion
Note: This is for demonstration purposes. In production, use cloud storage
with signed URLs for better security and scalability.
"""
def __init__(
self,
uploads_dir: str = "./uploads",
base_url: str = "http://localhost:8001",
data_store: "SQLiteStore | None" = None,
):
"""Initialize the file-based attachment store.
Args:
uploads_dir: Directory where uploaded files will be stored
base_url: Base URL for generating upload and preview URLs
data_store: Optional data store to persist attachment metadata
"""
self.uploads_dir = Path(uploads_dir)
self.base_url = base_url.rstrip("/")
self.data_store = data_store
# Create uploads directory if it doesn't exist
self.uploads_dir.mkdir(parents=True, exist_ok=True)
def get_file_path(self, attachment_id: str) -> Path:
"""Get the filesystem path for an attachment."""
return self.uploads_dir / attachment_id
async def delete_attachment(self, attachment_id: str, context: dict[str, Any]) -> None:
"""Delete an attachment and its file from disk."""
file_path = self.get_file_path(attachment_id)
if file_path.exists():
file_path.unlink()
async def create_attachment(
self, input: AttachmentCreateParams, context: dict[str, Any]
) -> Attachment:
"""Create an attachment with upload URL for two-phase upload.
This creates the attachment metadata and returns upload URLs that
the client will use to POST the actual file bytes.
"""
# Generate unique ID for this attachment
attachment_id = self.generate_attachment_id(input.mime_type, context)
# Generate upload URL that points to our FastAPI upload endpoint
upload_url = f"{self.base_url}/upload/{attachment_id}"
# Create appropriate attachment type based on MIME type
if input.mime_type.startswith("image/"):
# For images, also provide a preview URL
preview_url = f"{self.base_url}/preview/{attachment_id}"
attachment = ImageAttachment(
id=attachment_id,
type="image",
mime_type=input.mime_type,
name=input.name,
upload_url=AnyUrl(upload_url),
preview_url=AnyUrl(preview_url),
)
else:
# For files, just provide upload URL
attachment = FileAttachment(
id=attachment_id,
type="file",
mime_type=input.mime_type,
name=input.name,
upload_url=AnyUrl(upload_url),
)
# Save attachment metadata to data store so it's available during upload
if self.data_store is not None:
await self.data_store.save_attachment(attachment, context)
return attachment
async def read_attachment_bytes(self, attachment_id: str) -> bytes:
"""Read the raw bytes of an uploaded attachment.
This is used by the ThreadItemConverter to create base64-encoded
content for sending to the Agent Framework.
"""
file_path = self.get_file_path(attachment_id)
if not file_path.exists():
raise FileNotFoundError(f"Attachment {attachment_id} not found on disk")
return file_path.read_bytes()
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatKit + Agent Framework Demo</title>
<script src="https://cdn.platform.openai.com/deployments/chatkit/chatkit.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
}
header {
padding: 1rem;
background: #f5f5f5;
border-bottom: 1px solid #ddd;
}
h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
p {
color: #666;
font-size: 0.9rem;
}
#root {
flex: 1;
overflow: hidden;
}
</style>
</head>
<body>
<header>
<h1>ChatKit + Agent Framework Demo</h1>
<p>Simple weather assistant powered by Agent Framework and ChatKit</p>
</header>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
{
"name": "chatkit-agent-framework-demo",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"engines": {
"node": ">=18.18",
"npm": ">=9"
},
"dependencies": {
"@openai/chatkit-react": "^0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"typescript": "^5.4.0",
"vite": "^7.1.9"
}
}
@@ -0,0 +1,33 @@
import { ChatKit, useChatKit } from "@openai/chatkit-react";
const CHATKIT_API_URL = "/chatkit";
const CHATKIT_API_DOMAIN_KEY =
import.meta.env.VITE_CHATKIT_API_DOMAIN_KEY ?? "domain_pk_localhost_dev";
export default function App() {
const chatkit = useChatKit({
api: {
url: CHATKIT_API_URL,
domainKey: CHATKIT_API_DOMAIN_KEY,
uploadStrategy: { type: "two_phase" },
},
startScreen: {
greeting: "Hello! I'm your weather and image analysis assistant. Ask me about the weather in any location or upload images for me to analyze.",
prompts: [
{ label: "Weather in New York", prompt: "What's the weather in New York?" },
{ label: "Select City to Get Weather", prompt: "Show me the city selector for weather" },
{ label: "Current Time", prompt: "What time is it?" },
{ label: "Analyze an Image", prompt: "I'll upload an image for you to analyze" },
],
},
composer: {
placeholder: "Ask about weather or upload an image...",
attachments: {
enabled: true,
accept: { "image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"] },
},
},
});
return <ChatKit control={chatkit.control} style={{ height: "100%" }} />;
}
@@ -0,0 +1,15 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
const container = document.getElementById("root");
if (!container) {
throw new Error("Root element with id 'root' not found");
}
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
);
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,24 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
const backendTarget = process.env.BACKEND_URL ?? "http://127.0.0.1:8001";
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
port: 5171,
proxy: {
"/chatkit": {
target: backendTarget,
changeOrigin: true,
},
},
// For production deployments, you need to add your public domains to this list
allowedHosts: [
// You can remove these examples added just to demonstrate how to configure the allowlist
".ngrok.io",
".trycloudflare.com",
],
},
});
@@ -0,0 +1,361 @@
# Copyright (c) Microsoft. All rights reserved.
"""SQLite-based store implementation for ChatKit data persistence.
This module provides a complete Store implementation using SQLite for data persistence.
It includes proper thread safety, user isolation, and follows the ChatKit Store protocol.
"""
import sqlite3
import uuid
from typing import Any
from chatkit.store import Store, NotFoundError
from chatkit.types import (
Attachment,
Page,
ThreadItem,
ThreadMetadata,
)
from pydantic import BaseModel
class ThreadData(BaseModel):
"""Model for serializing thread data to SQLite."""
thread: ThreadMetadata
class ItemData(BaseModel):
"""Model for serializing thread item data to SQLite."""
item: ThreadItem
class AttachmentData(BaseModel):
"""Model for serializing attachment data to SQLite."""
attachment: Attachment
class SQLiteStore(Store[dict[str, Any]]):
"""SQLite-based store implementation for ChatKit data.
This implementation follows the pattern from the ChatKit Python tests
and provides persistent storage for threads, messages, and attachments.
Features:
- Thread-safe SQLite connections with WAL mode
- User isolation for multi-tenant support
- Proper error handling and transaction management
- Complete Store protocol implementation
Note: This is for demonstration purposes. In production, you should
implement proper error handling, connection pooling, and migration strategies.
"""
def __init__(self, db_path: str | None = None):
self.db_path = db_path or "chatkit_demo.db" # Use file-based DB for demo
self._create_tables()
def _create_connection(self):
# Enable thread safety and WAL mode for better concurrent access
conn = sqlite3.connect(self.db_path, check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
return conn
def _create_tables(self):
with self._create_connection() as conn:
# Create threads table
conn.execute(
"""CREATE TABLE IF NOT EXISTS threads (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
data TEXT NOT NULL
)"""
)
# Create items table
conn.execute(
"""CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
data TEXT NOT NULL
)"""
)
# Create attachments table
conn.execute(
"""CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
data TEXT NOT NULL
)"""
)
conn.commit()
def generate_thread_id(self, context: dict[str, Any]) -> str:
return f"thr_{uuid.uuid4().hex[:8]}"
def generate_item_id(
self,
item_type: str,
thread: ThreadMetadata,
context: dict[str, Any],
) -> str:
prefix_map = {
"message": "msg",
"tool_call": "tc",
"task": "tsk",
"workflow": "wf",
"attachment": "atc",
}
prefix = prefix_map.get(item_type, "itm")
return f"{prefix}_{uuid.uuid4().hex[:8]}"
async def load_thread(self, thread_id: str, context: dict[str, Any]) -> ThreadMetadata:
user_id = context.get("user_id", "demo_user")
with self._create_connection() as conn:
cursor = conn.execute(
"SELECT data FROM threads WHERE id = ? AND user_id = ?",
(thread_id, user_id),
).fetchone()
if cursor is None:
raise NotFoundError(f"Thread {thread_id} not found")
thread_data = ThreadData.model_validate_json(cursor[0])
return thread_data.thread
async def save_thread(self, thread: ThreadMetadata, context: dict[str, Any]) -> None:
user_id = context.get("user_id", "demo_user")
with self._create_connection() as conn:
thread_data = ThreadData(thread=thread)
# Replace existing thread data
conn.execute(
"DELETE FROM threads WHERE id = ? AND user_id = ?",
(thread.id, user_id),
)
conn.execute(
"INSERT INTO threads (id, user_id, created_at, data) VALUES (?, ?, ?, ?)",
(
thread.id,
user_id,
thread.created_at.isoformat(),
thread_data.model_dump_json(),
),
)
conn.commit()
async def load_thread_items(
self,
thread_id: str,
after: str | None,
limit: int,
order: str,
context: dict[str, Any],
) -> Page[ThreadItem]:
user_id = context.get("user_id", "demo_user")
with self._create_connection() as conn:
created_after: str | None = None
if after:
after_cursor = conn.execute(
"SELECT created_at FROM items WHERE id = ? AND user_id = ?",
(after, user_id),
).fetchone()
if after_cursor is None:
raise NotFoundError(f"Item {after} not found")
created_after = after_cursor[0]
query = """
SELECT data FROM items
WHERE thread_id = ? AND user_id = ?
"""
params: list[Any] = [thread_id, user_id]
if created_after:
query += " AND created_at > ?" if order == "asc" else " AND created_at < ?"
params.append(created_after)
query += f" ORDER BY created_at {order} LIMIT ?"
params.append(limit + 1)
items_cursor = conn.execute(query, params).fetchall()
items = [
ItemData.model_validate_json(row[0]).item for row in items_cursor
]
has_more = len(items) > limit
if has_more:
items = items[:limit]
return Page[ThreadItem](
data=items,
has_more=has_more,
after=items[-1].id if items else None
)
async def save_attachment(self, attachment: Attachment, context: dict[str, Any]) -> None:
user_id = context.get("user_id", "demo_user")
with self._create_connection() as conn:
attachment_data = AttachmentData(attachment=attachment)
conn.execute(
"INSERT OR REPLACE INTO attachments (id, user_id, data) VALUES (?, ?, ?)",
(
attachment.id,
user_id,
attachment_data.model_dump_json(),
),
)
conn.commit()
async def load_attachment(self, attachment_id: str, context: dict[str, Any]) -> Attachment:
user_id = context.get("user_id", "demo_user")
with self._create_connection() as conn:
cursor = conn.execute(
"SELECT data FROM attachments WHERE id = ? AND user_id = ?",
(attachment_id, user_id),
).fetchone()
if cursor is None:
raise NotFoundError(f"Attachment {attachment_id} not found")
attachment_data = AttachmentData.model_validate_json(cursor[0])
return attachment_data.attachment
async def delete_attachment(self, attachment_id: str, context: dict[str, Any]) -> None:
user_id = context.get("user_id", "demo_user")
with self._create_connection() as conn:
conn.execute(
"DELETE FROM attachments WHERE id = ? AND user_id = ?",
(attachment_id, user_id),
)
conn.commit()
async def load_threads(
self,
limit: int,
after: str | None,
order: str,
context: dict[str, Any],
) -> Page[ThreadMetadata]:
user_id = context.get("user_id", "demo_user")
with self._create_connection() as conn:
created_after: str | None = None
if after:
after_cursor = conn.execute(
"SELECT created_at FROM threads WHERE id = ? AND user_id = ?",
(after, user_id),
).fetchone()
if after_cursor is None:
raise NotFoundError(f"Thread {after} not found")
created_after = after_cursor[0]
query = "SELECT data FROM threads WHERE user_id = ?"
params: list[Any] = [user_id]
if created_after:
query += " AND created_at > ?" if order == "asc" else " AND created_at < ?"
params.append(created_after)
query += f" ORDER BY created_at {order} LIMIT ?"
params.append(limit + 1)
threads_cursor = conn.execute(query, params).fetchall()
threads = [
ThreadData.model_validate_json(row[0]).thread for row in threads_cursor
]
has_more = len(threads) > limit
if has_more:
threads = threads[:limit]
return Page[ThreadMetadata](
data=threads,
has_more=has_more,
after=threads[-1].id if threads else None
)
async def add_thread_item(
self, thread_id: str, item: ThreadItem, context: dict[str, Any]
) -> None:
user_id = context.get("user_id", "demo_user")
with self._create_connection() as conn:
item_data = ItemData(item=item)
conn.execute(
"INSERT INTO items (id, thread_id, user_id, created_at, data) VALUES (?, ?, ?, ?, ?)",
(
item.id,
thread_id,
user_id,
item.created_at.isoformat(),
item_data.model_dump_json(),
),
)
conn.commit()
async def save_item(self, thread_id: str, item: ThreadItem, context: dict[str, Any]) -> None:
user_id = context.get("user_id", "demo_user")
with self._create_connection() as conn:
item_data = ItemData(item=item)
conn.execute(
"UPDATE items SET data = ? WHERE id = ? AND thread_id = ? AND user_id = ?",
(
item_data.model_dump_json(),
item.id,
thread_id,
user_id,
),
)
conn.commit()
async def load_item(self, thread_id: str, item_id: str, context: dict[str, Any]) -> ThreadItem:
user_id = context.get("user_id", "demo_user")
with self._create_connection() as conn:
cursor = conn.execute(
"SELECT data FROM items WHERE id = ? AND thread_id = ? AND user_id = ?",
(item_id, thread_id, user_id),
).fetchone()
if cursor is None:
raise NotFoundError(f"Item {item_id} not found in thread {thread_id}")
item_data = ItemData.model_validate_json(cursor[0])
return item_data.item
async def delete_thread(self, thread_id: str, context: dict[str, Any]) -> None:
user_id = context.get("user_id", "demo_user")
with self._create_connection() as conn:
conn.execute(
"DELETE FROM threads WHERE id = ? AND user_id = ?",
(thread_id, user_id),
)
conn.execute(
"DELETE FROM items WHERE thread_id = ? AND user_id = ?",
(thread_id, user_id),
)
conn.commit()
async def delete_thread_item(
self, thread_id: str, item_id: str, context: dict[str, Any]
) -> None:
user_id = context.get("user_id", "demo_user")
with self._create_connection() as conn:
conn.execute(
"DELETE FROM items WHERE id = ? AND thread_id = ? AND user_id = ?",
(item_id, thread_id, user_id),
)
conn.commit()
@@ -0,0 +1,437 @@
# Copyright (c) Microsoft. All rights reserved.
"""Weather widget rendering for ChatKit integration sample."""
import base64
from dataclasses import dataclass
from chatkit.actions import ActionConfig
from chatkit.widgets import Box, Button, Card, Col, Image, Row, Text, Title, WidgetRoot
WEATHER_ICON_COLOR = "#1D4ED8"
WEATHER_ICON_ACCENT = "#DBEAFE"
# Popular cities for the selector
POPULAR_CITIES = [
{"value": "seattle", "label": "Seattle, WA", "description": "Pacific Northwest"},
{"value": "new_york", "label": "New York, NY", "description": "East Coast"},
{"value": "san_francisco", "label": "San Francisco, CA", "description": "Bay Area"},
{"value": "chicago", "label": "Chicago, IL", "description": "Midwest"},
{"value": "miami", "label": "Miami, FL", "description": "Southeast"},
{"value": "austin", "label": "Austin, TX", "description": "Southwest"},
{"value": "boston", "label": "Boston, MA", "description": "New England"},
{"value": "denver", "label": "Denver, CO", "description": "Mountain West"},
{"value": "portland", "label": "Portland, OR", "description": "Pacific Northwest"},
{"value": "atlanta", "label": "Atlanta, GA", "description": "Southeast"},
]
# Mapping from city values to display names for weather queries
CITY_VALUE_TO_NAME = {city["value"]: city["label"] for city in POPULAR_CITIES}
def _sun_svg() -> str:
"""Generate SVG for sunny weather icon."""
color = WEATHER_ICON_COLOR
accent = WEATHER_ICON_ACCENT
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">'
f'<circle cx="32" cy="32" r="13" fill="{accent}" stroke="{color}" stroke-width="3"/>'
f'<g stroke="{color}" stroke-width="3" stroke-linecap="round">'
'<line x1="32" y1="8" x2="32" y2="16"/>'
'<line x1="32" y1="48" x2="32" y2="56"/>'
'<line x1="8" y1="32" x2="16" y2="32"/>'
'<line x1="48" y1="32" x2="56" y2="32"/>'
'<line x1="14.93" y1="14.93" x2="20.55" y2="20.55"/>'
'<line x1="43.45" y1="43.45" x2="49.07" y2="49.07"/>'
'<line x1="14.93" y1="49.07" x2="20.55" y2="43.45"/>'
'<line x1="43.45" y1="20.55" x2="49.07" y2="14.93"/>'
"</g>"
"</svg>"
)
def _cloud_svg() -> str:
"""Generate SVG for cloudy weather icon."""
color = WEATHER_ICON_COLOR
accent = WEATHER_ICON_ACCENT
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">'
f'<path d="M22 46H44C50.075 46 55 41.075 55 35S50.075 24 44 24H42.7C41.2 16.2 34.7 10 26.5 10 18 10 11.6 16.1 11 24.3 6.5 25.6 3 29.8 3 35s4.925 11 11 11h8Z" '
f'fill="{accent}" stroke="{color}" stroke-width="3" stroke-linejoin="round"/>'
"</svg>"
)
def _rain_svg() -> str:
"""Generate SVG for rainy weather icon."""
color = WEATHER_ICON_COLOR
accent = WEATHER_ICON_ACCENT
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">'
f'<path d="M22 40H44C50.075 40 55 35.075 55 29S50.075 18 44 18H42.7C41.2 10.2 34.7 4 26.5 4 18 4 11.6 10.1 11 18.3 6.5 19.6 3 23.8 3 29s4.925 11 11 11h8Z" '
f'fill="{accent}" stroke="{color}" stroke-width="3" stroke-linejoin="round"/>'
f'<g stroke="{color}" stroke-width="3" stroke-linecap="round">'
'<line x1="20" y1="48" x2="24" y2="56"/>'
'<line x1="30" y1="50" x2="34" y2="58"/>'
'<line x1="40" y1="48" x2="44" y2="56"/>'
"</g>"
"</svg>"
)
def _storm_svg() -> str:
"""Generate SVG for stormy weather icon."""
color = WEATHER_ICON_COLOR
accent = WEATHER_ICON_ACCENT
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">'
f'<path d="M22 40H44C50.075 40 55 35.075 55 29S50.075 18 44 18H42.7C41.2 10.2 34.7 4 26.5 4 18 4 11.6 10.1 11 18.3 6.5 19.6 3 23.8 3 29s4.925 11 11 11h8Z" '
f'fill="{accent}" stroke="{color}" stroke-width="3" stroke-linejoin="round"/>'
f'<path d="M34 46L28 56H34L30 64L42 50H36L40 46Z" '
f'fill="{color}" stroke="{color}" stroke-width="2" stroke-linejoin="round"/>'
"</svg>"
)
def _snow_svg() -> str:
"""Generate SVG for snowy weather icon."""
color = WEATHER_ICON_COLOR
accent = WEATHER_ICON_ACCENT
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">'
f'<path d="M22 40H44C50.075 40 55 35.075 55 29S50.075 18 44 18H42.7C41.2 10.2 34.7 4 26.5 4 18 4 11.6 10.1 11 18.3 6.5 19.6 3 23.8 3 29s4.925 11 11 11h8Z" '
f'fill="{accent}" stroke="{color}" stroke-width="3" stroke-linejoin="round"/>'
f'<g stroke="{color}" stroke-width="2" stroke-linecap="round">'
'<line x1="20" y1="48" x2="20" y2="56"/>'
'<line x1="17" y1="51" x2="23" y2="53"/>'
'<line x1="17" y1="53" x2="23" y2="51"/>'
'<line x1="36" y1="48" x2="36" y2="56"/>'
'<line x1="33" y1="51" x2="39" y2="53"/>'
'<line x1="33" y1="53" x2="39" y2="51"/>'
"</g>"
"</svg>"
)
def _fog_svg() -> str:
"""Generate SVG for foggy weather icon."""
color = WEATHER_ICON_COLOR
accent = WEATHER_ICON_ACCENT
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">'
f'<path d="M22 40H44C50.075 40 55 35.075 55 29S50.075 18 44 18H42.7C41.2 10.2 34.7 4 26.5 4 18 4 11.6 10.1 11 18.3 6.5 19.6 3 23.8 3 29s4.925 11 11 11h8Z" '
f'fill="{accent}" stroke="{color}" stroke-width="3" stroke-linejoin="round"/>'
f'<g stroke="{color}" stroke-width="3" stroke-linecap="round">'
'<line x1="18" y1="50" x2="42" y2="50"/>'
'<line x1="24" y1="56" x2="48" y2="56"/>'
"</g>"
"</svg>"
)
def _encode_svg(svg: str) -> str:
"""Encode SVG as base64 data URI."""
encoded = base64.b64encode(svg.encode("utf-8")).decode("ascii")
return f"data:image/svg+xml;base64,{encoded}"
# Weather condition to icon mapping
WEATHER_ICONS = {
"sunny": _encode_svg(_sun_svg()),
"cloudy": _encode_svg(_cloud_svg()),
"rainy": _encode_svg(_rain_svg()),
"stormy": _encode_svg(_storm_svg()),
"snowy": _encode_svg(_snow_svg()),
"foggy": _encode_svg(_fog_svg()),
}
DEFAULT_WEATHER_ICON = _encode_svg(_cloud_svg())
@dataclass
class WeatherData:
"""Weather data container."""
location: str
condition: str
temperature: int
humidity: int
wind_speed: int
def render_weather_widget(data: WeatherData) -> WidgetRoot:
"""Render a weather widget from weather data.
Args:
data: WeatherData containing weather information
Returns:
A ChatKit WidgetRoot (Card) displaying the weather information
"""
# Get weather icon
weather_icon_src = WEATHER_ICONS.get(data.condition.lower(), DEFAULT_WEATHER_ICON)
# Build the widget
header = Box(
padding=5,
background="surface-tertiary",
children=[
Row(
justify="between",
align="center",
children=[
Col(
align="start",
gap=1,
children=[
Text(
value=data.location,
size="lg",
weight="semibold",
),
Text(
value="Current conditions",
color="tertiary",
size="xs",
),
],
),
Box(
padding=3,
radius="full",
background="blue-100",
children=[
Image(
src=weather_icon_src,
alt=data.condition,
size=28,
fit="contain",
)
],
),
],
),
Row(
align="start",
gap=4,
children=[
Title(
value=f"{data.temperature}°C",
size="lg",
weight="semibold",
),
Col(
align="start",
gap=1,
children=[
Text(
value=data.condition.title(),
color="secondary",
size="sm",
weight="medium",
),
],
),
],
),
],
)
# Details section
details = Box(
padding=5,
gap=4,
children=[
Text(value="Weather details", weight="semibold", size="sm"),
Row(
gap=3,
wrap="wrap",
children=[
_detail_chip("Humidity", f"{data.humidity}%"),
_detail_chip("Wind", f"{data.wind_speed} km/h"),
],
),
],
)
return Card(
key="weather",
padding=0,
children=[header, details],
)
def _detail_chip(label: str, value: str) -> Box:
"""Create a detail chip widget component."""
return Box(
padding=3,
radius="xl",
background="surface-tertiary",
width=150,
minWidth=150,
maxWidth=150,
minHeight=80,
maxHeight=80,
flex="0 0 auto",
children=[
Col(
align="stretch",
gap=2,
children=[
Text(value=label, size="xs", weight="medium", color="tertiary"),
Row(
justify="center",
margin={"top": 2},
children=[Text(value=value, weight="semibold", size="lg")],
),
],
)
],
)
def weather_widget_copy_text(data: WeatherData) -> str:
"""Generate plain text representation of weather data.
Args:
data: WeatherData containing weather information
Returns:
Plain text description for copy/paste functionality
"""
return (
f"Weather in {data.location}:\n"
f"• Condition: {data.condition.title()}\n"
f"• Temperature: {data.temperature}°C\n"
f"• Humidity: {data.humidity}%\n"
f"• Wind: {data.wind_speed} km/h"
)
def render_city_selector_widget() -> WidgetRoot:
"""Render an interactive city selector widget.
This widget displays popular cities as a visual selection interface.
Users can click or ask about any city to get weather information.
Returns:
A ChatKit WidgetRoot (Card) with city selection display
"""
# Create location icon SVG
location_icon = _encode_svg(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">'
f'<path d="M32 8c-8.837 0-16 7.163-16 16 0 12 16 32 16 32s16-20 16-32c0-8.837-7.163-16-16-16z" '
f'fill="{WEATHER_ICON_ACCENT}" stroke="{WEATHER_ICON_COLOR}" stroke-width="3" stroke-linejoin="round"/>'
f'<circle cx="32" cy="24" r="6" fill="{WEATHER_ICON_COLOR}"/>'
"</svg>"
)
# Header section
header = Box(
padding=5,
background="surface-tertiary",
children=[
Row(
gap=3,
align="center",
children=[
Box(
padding=3,
radius="full",
background="blue-100",
children=[
Image(
src=location_icon,
alt="Location",
size=28,
fit="contain",
)
],
),
Col(
align="start",
gap=1,
children=[
Title(
value="Popular Cities",
size="md",
weight="semibold",
),
Text(
value="Select a city or ask about any location",
color="tertiary",
size="xs",
),
],
),
],
),
],
)
# Create city chips in a grid layout
city_chips: list[Button] = []
for city in POPULAR_CITIES:
# Create a button that sends an action to query weather for the selected city
chip = Button(
label=city["label"],
variant="outline",
size="md",
onClickAction=ActionConfig(
type="city_selected",
payload={"city_value": city["value"], "city_label": city["label"]},
handler="server", # Handle on server-side
),
)
city_chips.append(chip)
# Arrange in rows of 3
city_rows: list[Row] = []
for i in range(0, len(city_chips), 3):
row_chips: list[Button] = city_chips[i : i + 3]
city_rows.append(
Row(
gap=3,
wrap="wrap",
justify="start",
children=list(row_chips), # Convert to generic list
)
)
# Cities display section
cities_section = Box(
padding=5,
gap=3,
children=[
*city_rows,
Box(
padding=3,
radius="md",
background="blue-50",
children=[
Text(
value="đź’ˇ Click any city to get its weather, or ask about any other location!",
size="xs",
color="secondary",
),
],
),
],
)
return Card(
key="city_selector",
padding=0,
children=[header, cities_section],
)
def city_selector_copy_text() -> str:
"""Generate plain text representation of city selector.
Returns:
Plain text description for copy/paste functionality
"""
cities_list = "\n".join([f"• {city['label']}" for city in POPULAR_CITIES])
return f"Popular cities (click to get weather):\n{cities_list}\n\nYou can also ask about weather in any other location!"
+295 -163
View File
@@ -31,8 +31,10 @@ supported-markers = [
members = [
"agent-framework",
"agent-framework-a2a",
"agent-framework-ag-ui",
"agent-framework-anthropic",
"agent-framework-azure-ai",
"agent-framework-chatkit",
"agent-framework-copilotstudio",
"agent-framework-core",
"agent-framework-devui",
@@ -71,14 +73,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/00/b08f23b7d7e1e14ce01419a467b583edbb93c6cdb8654e54a9cc579cd61f/addict-2.4.0-py3-none-any.whl", hash = "sha256:249bb56bbfd3cdc2a004ea0ff4c2b6ddc84d53bc2194761636eb314d5cfa5dfc", size = 3832, upload-time = "2020-11-21T16:21:29.588Z" },
]
[[package]]
name = "ag-ui-protocol"
version = "0.1.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7b/d7/a8f8789b3b8b5f7263a902361468e8dfefd85ec63d1d5398579b9175d76d/ag_ui_protocol-0.1.9.tar.gz", hash = "sha256:94d75e3919ff75e0b608a7eed445062ea0e6f11cd33b3386a7649047e0c7abd3", size = 4988, upload-time = "2025-09-19T13:36:26.903Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/50/2bb71a2a9135f4d88706293773320d185789b592987c09f79e9bf2f4875f/ag_ui_protocol-0.1.9-py3-none-any.whl", hash = "sha256:44c1238b0576a3915b3a16e1b3855724e08e92ebc96b1ff29379fbd3bfbd400b", size = 7070, upload-time = "2025-09-19T13:36:25.791Z" },
]
[[package]]
name = "agent-framework"
version = "1.0.0b251104"
source = { virtual = "." }
dependencies = [
{ name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-ag-ui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-anthropic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-azure-ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-chatkit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-copilotstudio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-devui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -119,8 +135,10 @@ docs = [
[package.metadata]
requires-dist = [
{ name = "agent-framework-a2a", editable = "packages/a2a" },
{ name = "agent-framework-ag-ui", editable = "packages/ag-ui" },
{ name = "agent-framework-anthropic", editable = "packages/anthropic" },
{ name = "agent-framework-azure-ai", editable = "packages/azure-ai" },
{ name = "agent-framework-chatkit", editable = "packages/chatkit" },
{ name = "agent-framework-copilotstudio", editable = "packages/copilotstudio" },
{ name = "agent-framework-core", editable = "packages/core" },
{ name = "agent-framework-devui", editable = "packages/devui" },
@@ -173,6 +191,36 @@ requires-dist = [
{ name = "agent-framework-core", editable = "packages/core" },
]
[[package]]
name = "agent-framework-ag-ui"
version = "1.0.0b251105"
source = { editable = "packages/ag-ui" }
dependencies = [
{ name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "fastapi", 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'" },
]
[package.optional-dependencies]
dev = [
{ name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
[package.metadata]
requires-dist = [
{ name = "ag-ui-protocol", specifier = ">=0.1.9" },
{ name = "agent-framework-core", editable = "packages/core" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
{ name = "uvicorn", specifier = ">=0.30.0" },
]
provides-extras = ["dev"]
[[package]]
name = "agent-framework-anthropic"
version = "1.0.0b251104"
@@ -205,6 +253,21 @@ requires-dist = [
{ name = "azure-ai-projects", specifier = ">=1.0.0b11" },
]
[[package]]
name = "agent-framework-chatkit"
version = "1.0.0b251001"
source = { editable = "packages/chatkit" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "openai-chatkit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
[package.metadata]
requires-dist = [
{ name = "agent-framework-core", editable = "packages/core" },
{ name = "openai-chatkit", specifier = ">=1.1.0,<2.0.0" },
]
[[package]]
name = "agent-framework-copilotstudio"
version = "1.0.0b251104"
@@ -661,6 +724,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/a6/dc46877b911e40c00d395771ea710d5e77b6de7bacd5fdcd78d70cc5a48f/annotated_doc-0.0.3.tar.gz", hash = "sha256:e18370014c70187422c33e945053ff4c286f453a984eba84d0dbfa0c935adeda", size = 5535, upload-time = "2025-10-24T14:57:10.718Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/b7/cf592cb5de5cb3bade3357f8d2cf42bf103bbe39f459824b4939fd212911/annotated_doc-0.0.3-py3-none-any.whl", hash = "sha256:348ec6664a76f1fd3be81f43dffbee4c7e8ce931ba71ec67cc7f4ade7fbbb580", size = 5488, upload-time = "2025-10-24T14:57:09.462Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -715,14 +787,14 @@ wheels = [
[[package]]
name = "apscheduler"
version = "3.11.0"
version = "3.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzlocal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6d6814ddc19be2df62c8c898c4df6b5b1914f3bd024b780028caa392d186/apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133", size = 107347, upload-time = "2024-11-24T19:39:26.463Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/81/192db4f8471de5bc1f0d098783decffb1e6e69c4f8b4bc6711094691950b/apscheduler-3.11.1.tar.gz", hash = "sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221", size = 108044, upload-time = "2025-10-31T18:55:42.819Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" },
{ url = "https://files.pythonhosted.org/packages/58/9f/d3c76f76c73fcc959d28e9def45b8b1cc3d7722660c5003b19c1022fd7f4/apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2", size = 64278, upload-time = "2025-10-31T18:55:41.186Z" },
]
[[package]]
@@ -1632,16 +1704,17 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.115.14"
version = "0.121.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc", 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 = "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/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/e3/77a2df0946703973b9905fd0cde6172c15e0781984320123b4f5079e7113/fastapi-0.121.0.tar.gz", hash = "sha256:06663356a0b1ee93e875bbf05a31fb22314f5bed455afaaad2b2dad7f26e98fa", size = 342412, upload-time = "2025-11-03T10:25:54.818Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" },
{ url = "https://files.pythonhosted.org/packages/dd/2c/42277afc1ba1a18f8358561eee40785d27becab8f80a1f945c0a3051c6eb/fastapi-0.121.0-py3-none-any.whl", hash = "sha256:8bdf1b15a55f4e4b0d6201033da9109ea15632cb76cf156e7b8b4019f2172106", size = 109183, upload-time = "2025-11-03T10:25:53.27Z" },
]
[[package]]
@@ -2086,6 +2159,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
]
[[package]]
name = "griffe"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ec/d7/6c09dd7ce4c7837e4cdb11dce980cb45ae3cd87677298dc3b781b6bce7d3/griffe-1.14.0.tar.gz", hash = "sha256:9d2a15c1eca966d68e00517de5d69dd1bc5c9f2335ef6c1775362ba5b8651a13", size = 424684, upload-time = "2025-09-05T15:02:29.167Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/b1/9ff6578d789a89812ff21e4e0f80ffae20a65d5dd84e7a17873fe3b365be/griffe-1.14.0-py3-none-any.whl", hash = "sha256:0e9d52832cccf0f7188cfe585ba962d2674b241c01916d780925df34873bceb0", size = 144439, upload-time = "2025-09-05T15:02:27.511Z" },
]
[[package]]
name = "grpcio"
version = "1.76.0"
@@ -2234,11 +2319,11 @@ wheels = [
[[package]]
name = "httpdbg"
version = "2.1.1"
version = "2.1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/07/bdc4b46bf9cda06b4bdb5b0dea2e1c5408fd91387823e1cb2cfebd79fde4/httpdbg-2.1.1.tar.gz", hash = "sha256:11b268e9224fdeccc7e5436b350154c287a1af65406047b5f6438461fc45486c", size = 81226, upload-time = "2025-10-26T18:42:41.896Z" }
sdist = { url = "https://files.pythonhosted.org/packages/69/c0/a54d8705ae57e76679cf21dbc6dba3eb4c5cb9f99fcd9cb99e159fb12a9d/httpdbg-2.1.3.tar.gz", hash = "sha256:da32fd7cab8032927ba4717c6c9108dd4aeb0d9a42636d34a43ab11541daac26", size = 80694, upload-time = "2025-11-02T13:48:13.847Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/8e/8b0e91e4c5426f503149f86df5b2a142afb11abada57cf09a5990a933407/httpdbg-2.1.1-py3-none-any.whl", hash = "sha256:ef7137752cb2c79b3084b50a9534e7d1ba587d9ad531ac0807a3563ceb7a74e0", size = 89045, upload-time = "2025-10-26T18:42:39.017Z" },
{ url = "https://files.pythonhosted.org/packages/33/6e/567ace955933023403e4861d161de8b559d712b559e445cc6d9a95d8e26c/httpdbg-2.1.3-py3-none-any.whl", hash = "sha256:9faa4d66f308670ddde0c6b05281066cb10b56846e6c4d3eb712123c28ea019d", size = 88173, upload-time = "2025-11-02T13:48:12.466Z" },
]
[[package]]
@@ -2651,7 +2736,7 @@ wheels = [
[[package]]
name = "langfuse"
version = "3.8.1"
version = "3.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -2665,14 +2750,14 @@ 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/ca/0b/81f9c6a982f79c112b7f10bfd6f3a4871e6fa3e4fe8d078b6112abfd3c08/langfuse-3.8.1.tar.gz", hash = "sha256:2464ae3f8386d80e1252a0e7406e3be4121e792a74f1b1c21d9950f658e5168d", size = 197401, upload-time = "2025-10-22T13:35:52.572Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/c6/1bdb6c68ebc2b7d3875861cf99715e227bcd909a758df8af329f81f6e7af/langfuse-3.9.0.tar.gz", hash = "sha256:ed02744ab184a320dba5662be09be21441a467cc84db7e9a67c8bb6baec9fb5c", size = 201850, upload-time = "2025-11-03T10:25:49.577Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/f9/538af0fc4219eb2484ba319483bce3383146f7a0923d5f39e464ad9a504b/langfuse-3.8.1-py3-none-any.whl", hash = "sha256:5b94b66ec0b0de388a8ea1f078b32c1666b5825b36eab863a21fdee78c53b3bb", size = 364580, upload-time = "2025-10-22T13:35:50.597Z" },
{ url = "https://files.pythonhosted.org/packages/66/de/66ab298aecc0b50465824e7db5df77e43f872dcd8642d3c91d11be3ac6f7/langfuse-3.9.0-py3-none-any.whl", hash = "sha256:de46c47717822de46ad4a2563be5d775ca896dc4d0955a83b4d12e1ce5e249a9", size = 369620, upload-time = "2025-11-03T10:25:47.747Z" },
]
[[package]]
name = "litellm"
version = "1.79.0"
version = "1.79.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -2688,9 +2773,9 @@ dependencies = [
{ name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/52/2853febf8ea3072d8c76e3ee22d3168e6a4f97ebd8f21905e815a381c58b/litellm-1.79.0.tar.gz", hash = "sha256:f58bb751222ee0e1ffecb2d44987999f9fa94130a6d1a478e19a3e5e8b9a7414", size = 11146414, upload-time = "2025-10-26T01:20:55.247Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/12/1c30f1019892399a488ed60ebcdfed3e2603123d9591030abc8c702ff37a/litellm-1.79.1.tar.gz", hash = "sha256:c1cf0232c01e7ad4b8442d2cdd78973ce74dfda37ad1d9f0ec3c911510e26523", size = 11216675, upload-time = "2025-11-01T19:22:05.523Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/26/a5fef380af5d6a2f47cda979d88561af1e1a8efc07da2ef72c0e8cb6842c/litellm-1.79.0-py3-none-any.whl", hash = "sha256:93414b6ed55fa9e3268e8cb3100faab960c9ecd18173129ccd85471cf3db4f1a", size = 10197864, upload-time = "2025-10-26T01:20:51.75Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e4/ac5905dfe9c0c195e59c36ea431277090dd2aa1acbcc514f781fa87a5903/litellm-1.79.1-py3-none-any.whl", hash = "sha256:738f7bf36b31514ac11cc71f65718238b57696fcf22f8b3f1e57c44daf17a569", size = 10285849, upload-time = "2025-11-01T19:22:01.637Z" },
]
[package.optional-dependencies]
@@ -2731,11 +2816,11 @@ wheels = [
[[package]]
name = "litellm-proxy-extras"
version = "0.2.29"
version = "0.2.31"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/23/8b262f0301e02f7a70f299e68d06752934f6dd95d0a6b82ce871e5de4d81/litellm_proxy_extras-0.2.29.tar.gz", hash = "sha256:236c1cf8d9b0128392bb843ff8553918b0a9c299f2b3bfdc9ecc6b4547ce195e", size = 16500, upload-time = "2025-10-23T21:19:10.981Z" }
sdist = { url = "https://files.pythonhosted.org/packages/30/ce/007a87b17834c5a24e15798ae32dd156d77528b12f086f4176bb7e3f4401/litellm_proxy_extras-0.2.31.tar.gz", hash = "sha256:6d4c96dfe28fa439eaf4e8d19b73718530bc2c59cd1e4cf560388c6bce5476bb", size = 16648, upload-time = "2025-11-01T01:18:47.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/70/c8ec18235f4bbe3c8486b2909c3d5fc23cdbd08b2c7504ae8c02ed813c83/litellm_proxy_extras-0.2.29-py3-none-any.whl", hash = "sha256:27b7efc69829ed8745de7f469110c1f6a82e4f994bd8de3ac6b16dc2806a14b0", size = 33565, upload-time = "2025-10-23T21:19:09.821Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5f/6a0add2cac34a370da62d3bf7476035f5f10519740dfe78410256f8945b1/litellm_proxy_extras-0.2.31-py3-none-any.whl", hash = "sha256:7a66ae2810e451977fb1dfed6dac81971c6a4efbce7d57c896dce280b50ce359", size = 34130, upload-time = "2025-11-01T01:18:46.485Z" },
]
[[package]]
@@ -2925,7 +3010,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.19.0"
version = "1.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -2934,15 +3019,16 @@ dependencies = [
{ name = "jsonschema", 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 = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pyjwt", extra = ["crypto"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "python-multipart", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/2b/916852a5668f45d8787378461eaa1244876d77575ffef024483c94c0649c/mcp-1.19.0.tar.gz", hash = "sha256:213de0d3cd63f71bc08ffe9cc8d4409cc87acffd383f6195d2ce0457c021b5c1", size = 444163, upload-time = "2025-10-24T01:11:15.839Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/22/fae38092e6c2995c03232635028510d77e7decff31b4ae79dfa0ba99c635/mcp-1.20.0.tar.gz", hash = "sha256:9ccc09eaadbfbcbbdab1c9723cfe2e0d1d9e324d7d3ce7e332ef90b09ed35177", size = 451377, upload-time = "2025-10-30T22:14:53.421Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/a3/3e71a875a08b6a830b88c40bc413bff01f1650f1efe8a054b5e90a9d4f56/mcp-1.19.0-py3-none-any.whl", hash = "sha256:f5907fe1c0167255f916718f376d05f09a830a215327a3ccdd5ec8a519f2e572", size = 170105, upload-time = "2025-10-24T01:11:14.151Z" },
{ url = "https://files.pythonhosted.org/packages/df/00/76fc92f4892d47fecb37131d0e95ea69259f077d84c68f6793a0d96cfe80/mcp-1.20.0-py3-none-any.whl", hash = "sha256:d0dc06f93653f7432ff89f694721c87f79876b6f93741bf628ad1e48f7ac5e5d", size = 173136, upload-time = "2025-10-30T22:14:51.078Z" },
]
[package.optional-dependencies]
@@ -2979,31 +3065,31 @@ wheels = [
[[package]]
name = "microsoft-agents-activity"
version = "0.5.1"
version = "0.5.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/87/96/4416c5b3f13309d7503f3db3c2bfc321824366b68a240ed71e8145634c3d/microsoft_agents_activity-0.5.1.tar.gz", hash = "sha256:07be29aca58ea9d8279155cfa4c00261e3a18bdf718c8164c1d87e3e57ad527b", size = 55830, upload-time = "2025-10-28T19:27:03.938Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/51/2698980f425cda122f5b755a957c3c2db604c0b9a787c6add5aa4649c237/microsoft_agents_activity-0.5.3.tar.gz", hash = "sha256:d80b055591df561df8cebda9e1712012352581a396b36459133a951982b3a760", size = 55892, upload-time = "2025-10-31T15:40:49.332Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/47/333591538c134b5b4637ffc8ab4f5d0bf1c1b6310e3cfb5adc4002aa5940/microsoft_agents_activity-0.5.1-py3-none-any.whl", hash = "sha256:07562064125f2bc8066c2c8e9a60ff6f038f7413ccd01a9d9b0aa426e47467cd", size = 127817, upload-time = "2025-10-28T19:27:12.382Z" },
{ url = "https://files.pythonhosted.org/packages/75/3d/9618243e7b6f1f6295642c4e2dfca65b3a37794efbe1bdec15f0a93827d9/microsoft_agents_activity-0.5.3-py3-none-any.whl", hash = "sha256:5ae2447ac47c32f03c614694f520817cd225c9c502ec08b90d448311fb5bf3b4", size = 127861, upload-time = "2025-10-31T15:40:57.628Z" },
]
[[package]]
name = "microsoft-agents-copilotstudio-client"
version = "0.5.1"
version = "0.5.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "microsoft-agents-hosting-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/53/e6dde964b677358ec7c177c4aa2d408cd31acba4abe3d24c4728c2607b3d/microsoft_agents_copilotstudio_client-0.5.1.tar.gz", hash = "sha256:0b730045b4f8e8f61291279e64e0669868ace39beb63688ec38ba181020f5c3f", size = 11153, upload-time = "2025-10-28T19:27:06.247Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/22/109164fb585c4baee40d2372c5d76254ec4a28219908f11cd27ac92aa6c1/microsoft_agents_copilotstudio_client-0.5.3.tar.gz", hash = "sha256:a57ea6b3cb47dbb5ad22e59c986208ace6479e35da3f644e6346f4dfd85db57c", size = 11161, upload-time = "2025-10-31T15:40:51.444Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/cd/50576ae8cbb2cd7fe0ebaa3ae882fc69cfb29183a5304cda29ba09084faa/microsoft_agents_copilotstudio_client-0.5.1-py3-none-any.whl", hash = "sha256:115f6aff0e44b97fd23128b7d4d53b6ed10ec54f93494c569c1cb48ac2b8a468", size = 11091, upload-time = "2025-10-28T19:27:14.469Z" },
{ url = "https://files.pythonhosted.org/packages/c4/65/984e139c85657ff0c8df0ed98a167c8b9434f4fd4f32862b4a6490b8c714/microsoft_agents_copilotstudio_client-0.5.3-py3-none-any.whl", hash = "sha256:6a36fce5c8c1a2df6f5142e35b12c69be80959ecff6d60cc309661018c40f00a", size = 11091, upload-time = "2025-10-31T15:40:59.718Z" },
]
[[package]]
name = "microsoft-agents-hosting-core"
version = "0.5.1"
version = "0.5.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -3012,9 +3098,9 @@ dependencies = [
{ name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f2/0b/71bc8f2fd673de9f8a0d7e9bef30dd15892d8539c4557129a5aead2c5882/microsoft_agents_hosting_core-0.5.1.tar.gz", hash = "sha256:d9b64095bf7624d4fc9f1d48cea5a3c66cc2dee9e1c3fb6ea3e9b6dfc03ace8f", size = 81277, upload-time = "2025-10-28T19:27:08.623Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b1/98/7755c07b2ae5faf3e4dc14b17e44680a600c8b840b3003fb326d5720dea1/microsoft_agents_hosting_core-0.5.3.tar.gz", hash = "sha256:b113d4ea5c9e555bbf61037bb2a1a7a3ce7e5e4a7a0f681a3bd4719ba72ff821", size = 81672, upload-time = "2025-10-31T15:40:53.557Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/2c/bcb8d66ebfe59cf6093c5eac1fc19a7797b5b80ce3ceaec07f2954a21493/microsoft_agents_hosting_core-0.5.1-py3-none-any.whl", hash = "sha256:10a1f394d8e444f6e2e74ab935f5c0a04ebfa43d136be4658fbaccab1321c37e", size = 120190, upload-time = "2025-10-28T19:27:16.263Z" },
{ url = "https://files.pythonhosted.org/packages/95/57/c9e98475971c9da9cc9ff88195bbfcfae90dba511ebe14610be79f23ab3f/microsoft_agents_hosting_core-0.5.3-py3-none-any.whl", hash = "sha256:8c228a8814dcf1a86dd60e4c7574a2e86078962695fabd693a118097e703e982", size = 120668, upload-time = "2025-10-31T15:41:01.691Z" },
]
[[package]]
@@ -3288,11 +3374,11 @@ wheels = [
[[package]]
name = "narwhals"
version = "2.10.0"
version = "2.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/56/e5/ef07d31c2e07d99eecac8e14ace5c20aeb00ecba4ed5bb00343136380524/narwhals-2.10.0.tar.gz", hash = "sha256:1c05bbef2048a4045263de7d98c3d06140583eb13d796dd733b2157f05d24485", size = 582423, upload-time = "2025-10-27T17:55:55.632Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c5/dc/8db74daf8c2690ec696c1d772a33cc01511559ee8a9e92d7ed85a18e3c22/narwhals-2.10.2.tar.gz", hash = "sha256:ff738a08bc993cbb792266bec15346c1d85cc68fdfe82a23283c3713f78bd354", size = 584954, upload-time = "2025-11-04T16:36:42.281Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/13/024ae0586d901f8a6f99e2d29b4ae217e8ef11d3fd944cdfc3bbde5f2a08/narwhals-2.10.0-py3-none-any.whl", hash = "sha256:baed44e8fc38e800e3a585e3fa9843a7079a6fad5fbffbecee4348d6ac52298c", size = 418077, upload-time = "2025-10-27T17:55:53.709Z" },
{ url = "https://files.pythonhosted.org/packages/47/a9/9e02fa97e421a355fc5e818e9c488080fce04a8e0eebb3ed75a84f041c4a/narwhals-2.10.2-py3-none-any.whl", hash = "sha256:059cd5c6751161b97baedcaf17a514c972af6a70f36a89af17de1a0caf519c43", size = 419573, upload-time = "2025-11-04T16:36:40.574Z" },
]
[[package]]
@@ -3497,6 +3583,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/2a/7dd3d207ec669cacc1f186fd856a0f61dbc255d24f6fdc1a6715d6051b0f/openai-1.109.1-py3-none-any.whl", hash = "sha256:6bcaf57086cf59159b8e27447e4e7dd019db5d29a438072fbd49c290c7e65315", size = 948627, upload-time = "2025-09-24T13:00:50.754Z" },
]
[[package]]
name = "openai-agents"
version = "0.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "griffe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "mcp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "openai", 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 = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "types-requests", 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/a4/37/2b4f828840d3ff32d82b813c3371ec9ee26b3b8dc6b4acbb7a4a579f617a/openai_agents-0.3.3.tar.gz", hash = "sha256:b016381a6890e1cb6879eb23c53c35f8c2312be1117f1cd4e4b5e2463150839f", size = 1816230, upload-time = "2025-09-30T23:20:24.22Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/59/fd49fd2c3184c0d5fedb8c9c456ae9852154828bca7ee69dce004ea83188/openai_agents-0.3.3-py3-none-any.whl", hash = "sha256:aa2c74e010b923c09f166e63a51fae8c850c62df8581b84bafcbe5bd208d1505", size = 210893, upload-time = "2025-09-30T23:20:22.037Z" },
]
[[package]]
name = "openai-chatkit"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "openai-agents", 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 = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4a/19/9948f2996c224aff01f6ef415784042c3d710c1e950937b16d9a2c07e47e/openai_chatkit-1.1.0.tar.gz", hash = "sha256:5594341aab29b56fd3396e8d3ad1962ebdb8c44f062a8e315663ac8cf1371c6b", size = 49480, upload-time = "2025-11-03T22:50:05.089Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/82/07db74ee63d54f3cadab3baaa1534bef0d3699a94d2618c76050cccb0cfe/openai_chatkit-1.1.0-py3-none-any.whl", hash = "sha256:e78f021899fbef1323f3adc3a686f9fe5ee184cd997799a917e9013833e760ba", size = 35424, upload-time = "2025-11-03T22:50:03.788Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.38.0"
@@ -3940,15 +4059,15 @@ wheels = [
[[package]]
name = "plotly"
version = "6.3.1"
version = "6.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "narwhals", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0c/63/961d47c9ffd592a575495891cdcf7875dc0903ebb33ac238935714213789/plotly-6.3.1.tar.gz", hash = "sha256:dd896e3d940e653a7ce0470087e82c2bd903969a55e30d1b01bb389319461bb0", size = 6956460, upload-time = "2025-10-02T16:10:34.16Z" }
sdist = { url = "https://files.pythonhosted.org/packages/06/e6/b768650072837505804bed4790c5449ba348a3b720e27ca7605414e998cd/plotly-6.4.0.tar.gz", hash = "sha256:68c6db2ed2180289ef978f087841148b7efda687552276da15a6e9b92107052a", size = 7012379, upload-time = "2025-11-04T17:59:26.45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/93/023955c26b0ce614342d11cc0652f1e45e32393b6ab9d11a664a60e9b7b7/plotly-6.3.1-py3-none-any.whl", hash = "sha256:8b4420d1dcf2b040f5983eed433f95732ed24930e496d36eb70d211923532e64", size = 9833698, upload-time = "2025-10-02T16:10:22.584Z" },
{ url = "https://files.pythonhosted.org/packages/78/ae/89b45ccccfeebc464c9233de5675990f75241b8ee4cd63227800fdf577d1/plotly-6.4.0-py3-none-any.whl", hash = "sha256:a1062eafbdc657976c2eedd276c90e184ccd6c21282a5e9ee8f20efca9c9a4c5", size = 9892458, upload-time = "2025-11-04T17:59:22.622Z" },
]
[[package]]
@@ -4023,7 +4142,7 @@ wheels = [
[[package]]
name = "posthog"
version = "6.7.11"
version = "6.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -4033,9 +4152,9 @@ dependencies = [
{ name = "six", 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/cb/32/3668d5e0f8b852fad81770743ee17893854fd8e5f7cea897a0a9199b0370/posthog-6.7.11.tar.gz", hash = "sha256:62db3e97cbd95351fe081c1ea8805393293de6fabad6d2e9024bf940aca4ddbf", size = 120407, upload-time = "2025-10-28T13:06:18.335Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6a/26/fbd8a29d094c1b3df109b79f7165ddb20dc37ec1e5b55717585de9ee9b65/posthog-6.8.0.tar.gz", hash = "sha256:40bc3bffe4818d37de63a4f4f13d2e90a78efe14f0d808c962f0ffebc3b15256", size = 122781, upload-time = "2025-11-04T19:43:34.651Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/00/bf284e0aae5dec7c217c176f291867cfac2f7bfd5692c9ce041e80986fa7/posthog-6.7.11-py3-none-any.whl", hash = "sha256:31421a88437cef2ce20f60c14ee8d298b2e765a6de0617cb95d1fcef54170749", size = 138713, upload-time = "2025-10-28T13:06:17.018Z" },
{ url = "https://files.pythonhosted.org/packages/bb/9a/970fe48b888c53de5768f67524444c2adf2ea86fba97a672434deb8db971/posthog-6.8.0-py3-none-any.whl", hash = "sha256:b30b3cb06234d9177cecabe6f3e04e34e1e15fe7b60428771a67be57920a6308", size = 141210, upload-time = "2025-11-04T19:43:33.375Z" },
]
[[package]]
@@ -4853,109 +4972,109 @@ wheels = [
[[package]]
name = "regex"
version = "2025.10.23"
version = "2025.11.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/c8/1d2160d36b11fbe0a61acb7c3c81ab032d9ec8ad888ac9e0a61b85ab99dd/regex-2025.10.23.tar.gz", hash = "sha256:8cbaf8ceb88f96ae2356d01b9adf5e6306fa42fa6f7eab6b97794e37c959ac26", size = 401266, upload-time = "2025-10-21T15:58:20.23Z" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/a9/546676f25e573a4cf00fe8e119b78a37b6a8fe2dc95cda877b30889c9c45/regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01", size = 414669, upload-time = "2025-11-03T21:34:22.089Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/11/849d5d23633a77047465eaae4cc0cbf24ded7aa496c02e8b9710e28b1687/regex-2025.10.23-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:17bbcde374bef1c5fad9b131f0e28a6a24856dd90368d8c0201e2b5a69533daa", size = 487957, upload-time = "2025-10-21T15:54:26.151Z" },
{ url = "https://files.pythonhosted.org/packages/87/12/5985386e7e3200a0d6a6417026d2c758d783a932428a5efc0a42ca1ddf74/regex-2025.10.23-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b4e10434279cc8567f99ca6e018e9025d14f2fded2a603380b6be2090f476426", size = 290419, upload-time = "2025-10-21T15:54:28.804Z" },
{ url = "https://files.pythonhosted.org/packages/67/cf/a8615923f962f8fdc41a3a6093a48726955e8b1993f4614b26a41d249f9b/regex-2025.10.23-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9c9bb421cbe7012c744a5a56cf4d6c80829c72edb1a2991677299c988d6339c8", size = 288285, upload-time = "2025-10-21T15:54:30.47Z" },
{ url = "https://files.pythonhosted.org/packages/4e/3d/6a3a1e12c86354cd0b3cbf8c3dd6acbe853609ee3b39d47ecd3ce95caf84/regex-2025.10.23-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:275cd1c2ed8c4a78ebfa489618d7aee762e8b4732da73573c3e38236ec5f65de", size = 781458, upload-time = "2025-10-21T15:54:31.978Z" },
{ url = "https://files.pythonhosted.org/packages/46/47/76a8da004489f2700361754859e373b87a53d043de8c47f4d1583fd39d78/regex-2025.10.23-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b426ae7952f3dc1e73a86056d520bd4e5f021397484a6835902fc5648bcacce", size = 850605, upload-time = "2025-10-21T15:54:33.753Z" },
{ url = "https://files.pythonhosted.org/packages/67/05/fa886461f97d45a6f4b209699cb994dc6d6212d6e219d29444dac5005775/regex-2025.10.23-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5cdaf5b6d37c7da1967dbe729d819461aab6a98a072feef65bbcff0a6e60649", size = 898563, upload-time = "2025-10-21T15:54:35.431Z" },
{ url = "https://files.pythonhosted.org/packages/2d/db/3ddd8d01455f23cabad7499f4199de0df92f5e96d39633203ff9d0b592dc/regex-2025.10.23-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bfeff0b08f296ab28b4332a7e03ca31c437ee78b541ebc874bbf540e5932f8d", size = 791535, upload-time = "2025-10-21T15:54:37.269Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ae/0fa5cbf41ca92b6ec3370222fcb6c68b240d68ab10e803d086c03a19fd9e/regex-2025.10.23-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f97236a67307b775f30a74ef722b64b38b7ab7ba3bb4a2508518a5de545459c", size = 782461, upload-time = "2025-10-21T15:54:39.187Z" },
{ url = "https://files.pythonhosted.org/packages/d4/23/70af22a016df11af4def27870eb175c2c7235b72d411ecf75a4b4a422cb6/regex-2025.10.23-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:be19e7de499940cd72475fb8e46ab2ecb1cf5906bebdd18a89f9329afb1df82f", size = 774583, upload-time = "2025-10-21T15:54:41.018Z" },
{ url = "https://files.pythonhosted.org/packages/7a/ee/a54a6851f6905f33d3c4ed64e8737b1d85ed01b5724712530ddc0f9abdb1/regex-2025.10.23-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:883df76ee42d9ecb82b37ff8d01caea5895b3f49630a64d21111078bbf8ef64c", size = 845649, upload-time = "2025-10-21T15:54:42.615Z" },
{ url = "https://files.pythonhosted.org/packages/80/7d/c3ec1cae14e01fab00e38c41ed35f47a853359e95e9c023e9a4381bb122c/regex-2025.10.23-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2e9117d1d35fc2addae6281019ecc70dc21c30014b0004f657558b91c6a8f1a7", size = 836037, upload-time = "2025-10-21T15:54:44.63Z" },
{ url = "https://files.pythonhosted.org/packages/15/ae/45771140dd43c4d67c87b54d3728078ed6a96599d9fc7ba6825086236782/regex-2025.10.23-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0ff1307f531a5d8cf5c20ea517254551ff0a8dc722193aab66c656c5a900ea68", size = 779705, upload-time = "2025-10-21T15:54:46.08Z" },
{ url = "https://files.pythonhosted.org/packages/b8/95/074e2581760eafce7c816a352b7d3a322536e5b68c346d1a8bacd895545c/regex-2025.10.23-cp310-cp310-win32.whl", hash = "sha256:7888475787cbfee4a7cd32998eeffe9a28129fa44ae0f691b96cb3939183ef41", size = 265663, upload-time = "2025-10-21T15:54:47.854Z" },
{ url = "https://files.pythonhosted.org/packages/f7/c7/a25f56a718847e34d3f1608c72eadeb67653bff1a0411da023dd8f4c647b/regex-2025.10.23-cp310-cp310-win_amd64.whl", hash = "sha256:ec41a905908496ce4906dab20fb103c814558db1d69afc12c2f384549c17936a", size = 277587, upload-time = "2025-10-21T15:54:49.571Z" },
{ url = "https://files.pythonhosted.org/packages/d3/e5/63eb17c6b5deaefd93c2bbb1feae7c0a8d2157da25883a6ca2569cf7a663/regex-2025.10.23-cp310-cp310-win_arm64.whl", hash = "sha256:b2b7f19a764d5e966d5a62bf2c28a8b4093cc864c6734510bdb4aeb840aec5e6", size = 269979, upload-time = "2025-10-21T15:54:51.375Z" },
{ url = "https://files.pythonhosted.org/packages/82/e5/74b7cd5cd76b4171f9793042045bb1726f7856dd56e582fc3e058a7a8a5e/regex-2025.10.23-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6c531155bf9179345e85032052a1e5fe1a696a6abf9cea54b97e8baefff970fd", size = 487960, upload-time = "2025-10-21T15:54:53.253Z" },
{ url = "https://files.pythonhosted.org/packages/b9/08/854fa4b3b20471d1df1c71e831b6a1aa480281e37791e52a2df9641ec5c6/regex-2025.10.23-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:912e9df4e89d383681268d38ad8f5780d7cccd94ba0e9aa09ca7ab7ab4f8e7eb", size = 290425, upload-time = "2025-10-21T15:54:55.21Z" },
{ url = "https://files.pythonhosted.org/packages/ab/d3/6272b1dd3ca1271661e168762b234ad3e00dbdf4ef0c7b9b72d2d159efa7/regex-2025.10.23-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4f375c61bfc3138b13e762fe0ae76e3bdca92497816936534a0177201666f44f", size = 288278, upload-time = "2025-10-21T15:54:56.862Z" },
{ url = "https://files.pythonhosted.org/packages/14/8f/c7b365dd9d9bc0a36e018cb96f2ffb60d2ba8deb589a712b437f67de2920/regex-2025.10.23-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e248cc9446081119128ed002a3801f8031e0c219b5d3c64d3cc627da29ac0a33", size = 793289, upload-time = "2025-10-21T15:54:58.352Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fb/b8fbe9aa16cf0c21f45ec5a6c74b4cecbf1a1c0deb7089d4a6f83a9c1caa/regex-2025.10.23-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b52bf9282fdf401e4f4e721f0f61fc4b159b1307244517789702407dd74e38ca", size = 860321, upload-time = "2025-10-21T15:54:59.813Z" },
{ url = "https://files.pythonhosted.org/packages/b0/81/bf41405c772324926a9bd8a640dedaa42da0e929241834dfce0733070437/regex-2025.10.23-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c084889ab2c59765a0d5ac602fd1c3c244f9b3fcc9a65fdc7ba6b74c5287490", size = 907011, upload-time = "2025-10-21T15:55:01.968Z" },
{ url = "https://files.pythonhosted.org/packages/a4/fb/5ad6a8b92d3f88f3797b51bb4ef47499acc2d0b53d2fbe4487a892f37a73/regex-2025.10.23-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d80e8eb79009bdb0936658c44ca06e2fbbca67792013e3818eea3f5f228971c2", size = 800312, upload-time = "2025-10-21T15:55:04.15Z" },
{ url = "https://files.pythonhosted.org/packages/42/48/b4efba0168a2b57f944205d823f8e8a3a1ae6211a34508f014ec2c712f4f/regex-2025.10.23-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6f259118ba87b814a8ec475380aee5f5ae97a75852a3507cf31d055b01b5b40", size = 782839, upload-time = "2025-10-21T15:55:05.641Z" },
{ url = "https://files.pythonhosted.org/packages/13/2a/c9efb4c6c535b0559c1fa8e431e0574d229707c9ca718600366fcfef6801/regex-2025.10.23-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9b8c72a242683dcc72d37595c4f1278dfd7642b769e46700a8df11eab19dfd82", size = 854270, upload-time = "2025-10-21T15:55:07.27Z" },
{ url = "https://files.pythonhosted.org/packages/34/2d/68eecc1bdaee020e8ba549502291c9450d90d8590d0552247c9b543ebf7b/regex-2025.10.23-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a8d7b7a0a3df9952f9965342159e0c1f05384c0f056a47ce8b61034f8cecbe83", size = 845771, upload-time = "2025-10-21T15:55:09.477Z" },
{ url = "https://files.pythonhosted.org/packages/a5/cd/a1ae499cf9b87afb47a67316bbf1037a7c681ffe447c510ed98c0aa2c01c/regex-2025.10.23-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:413bfea20a484c524858125e92b9ce6ffdd0a4b97d4ff96b5859aa119b0f1bdd", size = 788778, upload-time = "2025-10-21T15:55:11.396Z" },
{ url = "https://files.pythonhosted.org/packages/38/f9/70765e63f5ea7d43b2b6cd4ee9d3323f16267e530fb2a420d92d991cf0fc/regex-2025.10.23-cp311-cp311-win32.whl", hash = "sha256:f76deef1f1019a17dad98f408b8f7afc4bd007cbe835ae77b737e8c7f19ae575", size = 265666, upload-time = "2025-10-21T15:55:13.306Z" },
{ url = "https://files.pythonhosted.org/packages/9c/1a/18e9476ee1b63aaec3844d8e1cb21842dc19272c7e86d879bfc0dcc60db3/regex-2025.10.23-cp311-cp311-win_amd64.whl", hash = "sha256:59bba9f7125536f23fdab5deeea08da0c287a64c1d3acc1c7e99515809824de8", size = 277600, upload-time = "2025-10-21T15:55:15.087Z" },
{ url = "https://files.pythonhosted.org/packages/1d/1b/c019167b1f7a8ec77251457e3ff0339ed74ca8bce1ea13138dc98309c923/regex-2025.10.23-cp311-cp311-win_arm64.whl", hash = "sha256:b103a752b6f1632ca420225718d6ed83f6a6ced3016dd0a4ab9a6825312de566", size = 269974, upload-time = "2025-10-21T15:55:16.841Z" },
{ url = "https://files.pythonhosted.org/packages/f6/57/eeb274d83ab189d02d778851b1ac478477522a92b52edfa6e2ae9ff84679/regex-2025.10.23-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7a44d9c00f7a0a02d3b777429281376370f3d13d2c75ae74eb94e11ebcf4a7fc", size = 489187, upload-time = "2025-10-21T15:55:18.322Z" },
{ url = "https://files.pythonhosted.org/packages/55/5c/7dad43a9b6ea88bf77e0b8b7729a4c36978e1043165034212fd2702880c6/regex-2025.10.23-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b83601f84fde939ae3478bb32a3aef36f61b58c3208d825c7e8ce1a735f143f2", size = 291122, upload-time = "2025-10-21T15:55:20.2Z" },
{ url = "https://files.pythonhosted.org/packages/66/21/38b71e6f2818f0f4b281c8fba8d9d57cfca7b032a648fa59696e0a54376a/regex-2025.10.23-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ec13647907bb9d15fd192bbfe89ff06612e098a5709e7d6ecabbdd8f7908fc45", size = 288797, upload-time = "2025-10-21T15:55:21.932Z" },
{ url = "https://files.pythonhosted.org/packages/be/95/888f069c89e7729732a6d7cca37f76b44bfb53a1e35dda8a2c7b65c1b992/regex-2025.10.23-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78d76dd2957d62501084e7012ddafc5fcd406dd982b7a9ca1ea76e8eaaf73e7e", size = 798442, upload-time = "2025-10-21T15:55:23.747Z" },
{ url = "https://files.pythonhosted.org/packages/76/70/4f903c608faf786627a8ee17c06e0067b5acade473678b69c8094b248705/regex-2025.10.23-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8668e5f067e31a47699ebb354f43aeb9c0ef136f915bd864243098524482ac43", size = 864039, upload-time = "2025-10-21T15:55:25.656Z" },
{ url = "https://files.pythonhosted.org/packages/62/19/2df67b526bf25756c7f447dde554fc10a220fd839cc642f50857d01e4a7b/regex-2025.10.23-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a32433fe3deb4b2d8eda88790d2808fed0dc097e84f5e683b4cd4f42edef6cca", size = 912057, upload-time = "2025-10-21T15:55:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/99/14/9a39b7c9e007968411bc3c843cc14cf15437510c0a9991f080cab654fd16/regex-2025.10.23-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d97d73818c642c938db14c0668167f8d39520ca9d983604575ade3fda193afcc", size = 803374, upload-time = "2025-10-21T15:55:28.9Z" },
{ url = "https://files.pythonhosted.org/packages/d4/f7/3495151dd3ca79949599b6d069b72a61a2c5e24fc441dccc79dcaf708fe6/regex-2025.10.23-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bca7feecc72ee33579e9f6ddf8babbe473045717a0e7dbc347099530f96e8b9a", size = 787714, upload-time = "2025-10-21T15:55:30.628Z" },
{ url = "https://files.pythonhosted.org/packages/28/65/ee882455e051131869957ee8597faea45188c9a98c0dad724cfb302d4580/regex-2025.10.23-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7e24af51e907d7457cc4a72691ec458320b9ae67dc492f63209f01eecb09de32", size = 858392, upload-time = "2025-10-21T15:55:32.322Z" },
{ url = "https://files.pythonhosted.org/packages/53/25/9287fef5be97529ebd3ac79d256159cb709a07eb58d4be780d1ca3885da8/regex-2025.10.23-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d10bcde58bbdf18146f3a69ec46dd03233b94a4a5632af97aa5378da3a47d288", size = 850484, upload-time = "2025-10-21T15:55:34.037Z" },
{ url = "https://files.pythonhosted.org/packages/f3/b4/b49b88b4fea2f14dc73e5b5842755e782fc2e52f74423d6f4adc130d5880/regex-2025.10.23-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:44383bc0c933388516c2692c9a7503e1f4a67e982f20b9a29d2fb70c6494f147", size = 789634, upload-time = "2025-10-21T15:55:35.958Z" },
{ url = "https://files.pythonhosted.org/packages/b6/3c/2f8d199d0e84e78bcd6bdc2be9b62410624f6b796e2893d1837ae738b160/regex-2025.10.23-cp312-cp312-win32.whl", hash = "sha256:6040a86f95438a0114bba16e51dfe27f1bc004fd29fe725f54a586f6d522b079", size = 266060, upload-time = "2025-10-21T15:55:37.902Z" },
{ url = "https://files.pythonhosted.org/packages/d7/67/c35e80969f6ded306ad70b0698863310bdf36aca57ad792f45ddc0e2271f/regex-2025.10.23-cp312-cp312-win_amd64.whl", hash = "sha256:436b4c4352fe0762e3bfa34a5567079baa2ef22aa9c37cf4d128979ccfcad842", size = 276931, upload-time = "2025-10-21T15:55:39.502Z" },
{ url = "https://files.pythonhosted.org/packages/f5/a1/4ed147de7d2b60174f758412c87fa51ada15cd3296a0ff047f4280aaa7ca/regex-2025.10.23-cp312-cp312-win_arm64.whl", hash = "sha256:f4b1b1991617055b46aff6f6db24888c1f05f4db9801349d23f09ed0714a9335", size = 270103, upload-time = "2025-10-21T15:55:41.24Z" },
{ url = "https://files.pythonhosted.org/packages/28/c6/195a6217a43719d5a6a12cc192a22d12c40290cecfa577f00f4fb822f07d/regex-2025.10.23-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b7690f95404a1293923a296981fd943cca12c31a41af9c21ba3edd06398fc193", size = 488956, upload-time = "2025-10-21T15:55:42.887Z" },
{ url = "https://files.pythonhosted.org/packages/4c/93/181070cd1aa2fa541ff2d3afcf763ceecd4937b34c615fa92765020a6c90/regex-2025.10.23-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a32d77aeaea58a13230100dd8797ac1a84c457f3af2fdf0d81ea689d5a9105b", size = 290997, upload-time = "2025-10-21T15:55:44.53Z" },
{ url = "https://files.pythonhosted.org/packages/b6/c5/9d37fbe3a40ed8dda78c23e1263002497540c0d1522ed75482ef6c2000f0/regex-2025.10.23-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b24b29402f264f70a3c81f45974323b41764ff7159655360543b7cabb73e7d2f", size = 288686, upload-time = "2025-10-21T15:55:46.186Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e7/db610ff9f10c2921f9b6ac0c8d8be4681b28ddd40fc0549429366967e61f/regex-2025.10.23-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:563824a08c7c03d96856d84b46fdb3bbb7cfbdf79da7ef68725cda2ce169c72a", size = 798466, upload-time = "2025-10-21T15:55:48.24Z" },
{ url = "https://files.pythonhosted.org/packages/90/10/aab883e1fa7fe2feb15ac663026e70ca0ae1411efa0c7a4a0342d9545015/regex-2025.10.23-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0ec8bdd88d2e2659c3518087ee34b37e20bd169419ffead4240a7004e8ed03b", size = 863996, upload-time = "2025-10-21T15:55:50.478Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b0/8f686dd97a51f3b37d0238cd00a6d0f9ccabe701f05b56de1918571d0d61/regex-2025.10.23-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b577601bfe1d33913fcd9276d7607bbac827c4798d9e14d04bf37d417a6c41cb", size = 912145, upload-time = "2025-10-21T15:55:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ca/639f8cd5b08797bca38fc5e7e07f76641a428cf8c7fca05894caf045aa32/regex-2025.10.23-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c9f2c68ac6cb3de94eea08a437a75eaa2bd33f9e97c84836ca0b610a5804368", size = 803370, upload-time = "2025-10-21T15:55:53.944Z" },
{ url = "https://files.pythonhosted.org/packages/0d/1e/a40725bb76959eddf8abc42a967bed6f4851b39f5ac4f20e9794d7832aa5/regex-2025.10.23-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:89f8b9ea3830c79468e26b0e21c3585f69f105157c2154a36f6b7839f8afb351", size = 787767, upload-time = "2025-10-21T15:55:56.004Z" },
{ url = "https://files.pythonhosted.org/packages/3d/d8/8ee9858062936b0f99656dce390aa667c6e7fb0c357b1b9bf76fb5e2e708/regex-2025.10.23-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:98fd84c4e4ea185b3bb5bf065261ab45867d8875032f358a435647285c722673", size = 858335, upload-time = "2025-10-21T15:55:58.185Z" },
{ url = "https://files.pythonhosted.org/packages/d8/0a/ed5faaa63fa8e3064ab670e08061fbf09e3a10235b19630cf0cbb9e48c0a/regex-2025.10.23-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1e11d3e5887b8b096f96b4154dfb902f29c723a9556639586cd140e77e28b313", size = 850402, upload-time = "2025-10-21T15:56:00.023Z" },
{ url = "https://files.pythonhosted.org/packages/79/14/d05f617342f4b2b4a23561da500ca2beab062bfcc408d60680e77ecaf04d/regex-2025.10.23-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f13450328a6634348d47a88367e06b64c9d84980ef6a748f717b13f8ce64e87", size = 789739, upload-time = "2025-10-21T15:56:01.967Z" },
{ url = "https://files.pythonhosted.org/packages/f9/7b/e8ce8eef42a15f2c3461f8b3e6e924bbc86e9605cb534a393aadc8d3aff8/regex-2025.10.23-cp313-cp313-win32.whl", hash = "sha256:37be9296598a30c6a20236248cb8b2c07ffd54d095b75d3a2a2ee5babdc51df1", size = 266054, upload-time = "2025-10-21T15:56:05.291Z" },
{ url = "https://files.pythonhosted.org/packages/71/2d/55184ed6be6473187868d2f2e6a0708195fc58270e62a22cbf26028f2570/regex-2025.10.23-cp313-cp313-win_amd64.whl", hash = "sha256:ea7a3c283ce0f06fe789365841e9174ba05f8db16e2fd6ae00a02df9572c04c0", size = 276917, upload-time = "2025-10-21T15:56:07.303Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d4/927eced0e2bd45c45839e556f987f8c8f8683268dd3c00ad327deb3b0172/regex-2025.10.23-cp313-cp313-win_arm64.whl", hash = "sha256:d9a4953575f300a7bab71afa4cd4ac061c7697c89590a2902b536783eeb49a4f", size = 270105, upload-time = "2025-10-21T15:56:09.857Z" },
{ url = "https://files.pythonhosted.org/packages/3e/b3/95b310605285573341fc062d1d30b19a54f857530e86c805f942c4ff7941/regex-2025.10.23-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7d6606524fa77b3912c9ef52a42ef63c6cfbfc1077e9dc6296cd5da0da286044", size = 491850, upload-time = "2025-10-21T15:56:11.685Z" },
{ url = "https://files.pythonhosted.org/packages/a4/8f/207c2cec01e34e56db1eff606eef46644a60cf1739ecd474627db90ad90b/regex-2025.10.23-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c037aadf4d64bdc38af7db3dbd34877a057ce6524eefcb2914d6d41c56f968cc", size = 292537, upload-time = "2025-10-21T15:56:13.963Z" },
{ url = "https://files.pythonhosted.org/packages/98/3b/025240af4ada1dc0b5f10d73f3e5122d04ce7f8908ab8881e5d82b9d61b6/regex-2025.10.23-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:99018c331fb2529084a0c9b4c713dfa49fafb47c7712422e49467c13a636c656", size = 290904, upload-time = "2025-10-21T15:56:16.016Z" },
{ url = "https://files.pythonhosted.org/packages/81/8e/104ac14e2d3450c43db18ec03e1b96b445a94ae510b60138f00ce2cb7ca1/regex-2025.10.23-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd8aba965604d70306eb90a35528f776e59112a7114a5162824d43b76fa27f58", size = 807311, upload-time = "2025-10-21T15:56:17.818Z" },
{ url = "https://files.pythonhosted.org/packages/19/63/78aef90141b7ce0be8a18e1782f764f6997ad09de0e05251f0d2503a914a/regex-2025.10.23-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:238e67264b4013e74136c49f883734f68656adf8257bfa13b515626b31b20f8e", size = 873241, upload-time = "2025-10-21T15:56:19.941Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a8/80eb1201bb49ae4dba68a1b284b4211ed9daa8e74dc600018a10a90399fb/regex-2025.10.23-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b2eb48bd9848d66fd04826382f5e8491ae633de3233a3d64d58ceb4ecfa2113a", size = 914794, upload-time = "2025-10-21T15:56:22.488Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d5/1984b6ee93281f360a119a5ca1af6a8ca7d8417861671388bf750becc29b/regex-2025.10.23-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d36591ce06d047d0c0fe2fc5f14bfbd5b4525d08a7b6a279379085e13f0e3d0e", size = 812581, upload-time = "2025-10-21T15:56:24.319Z" },
{ url = "https://files.pythonhosted.org/packages/c4/39/11ebdc6d9927172a64ae237d16763145db6bd45ebb4055c17b88edab72a7/regex-2025.10.23-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5d4ece8628d6e364302006366cea3ee887db397faebacc5dacf8ef19e064cf8", size = 795346, upload-time = "2025-10-21T15:56:26.232Z" },
{ url = "https://files.pythonhosted.org/packages/3b/b4/89a591bcc08b5e436af43315284bd233ba77daf0cf20e098d7af12f006c1/regex-2025.10.23-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:39a7e8083959cb1c4ff74e483eecb5a65d3b3e1d821b256e54baf61782c906c6", size = 868214, upload-time = "2025-10-21T15:56:28.597Z" },
{ url = "https://files.pythonhosted.org/packages/3d/ff/58ba98409c1dbc8316cdb20dafbc63ed267380a07780cafecaf5012dabc9/regex-2025.10.23-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:842d449a8fefe546f311656cf8c0d6729b08c09a185f1cad94c756210286d6a8", size = 854540, upload-time = "2025-10-21T15:56:30.875Z" },
{ url = "https://files.pythonhosted.org/packages/9a/f2/4a9e9338d67626e2071b643f828a482712ad15889d7268e11e9a63d6f7e9/regex-2025.10.23-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d614986dc68506be8f00474f4f6960e03e4ca9883f7df47744800e7d7c08a494", size = 799346, upload-time = "2025-10-21T15:56:32.725Z" },
{ url = "https://files.pythonhosted.org/packages/63/be/543d35c46bebf6f7bf2be538cca74d6585f25714700c36f37f01b92df551/regex-2025.10.23-cp313-cp313t-win32.whl", hash = "sha256:a5b7a26b51a9df473ec16a1934d117443a775ceb7b39b78670b2e21893c330c9", size = 268657, upload-time = "2025-10-21T15:56:34.577Z" },
{ url = "https://files.pythonhosted.org/packages/14/9f/4dd6b7b612037158bb2c9bcaa710e6fb3c40ad54af441b9c53b3a137a9f1/regex-2025.10.23-cp313-cp313t-win_amd64.whl", hash = "sha256:ce81c5544a5453f61cb6f548ed358cfb111e3b23f3cd42d250a4077a6be2a7b6", size = 280075, upload-time = "2025-10-21T15:56:36.767Z" },
{ url = "https://files.pythonhosted.org/packages/81/7a/5bd0672aa65d38c8da6747c17c8b441bdb53d816c569e3261013af8e83cf/regex-2025.10.23-cp313-cp313t-win_arm64.whl", hash = "sha256:e9bf7f6699f490e4e43c44757aa179dab24d1960999c84ab5c3d5377714ed473", size = 271219, upload-time = "2025-10-21T15:56:39.033Z" },
{ url = "https://files.pythonhosted.org/packages/73/f6/0caf29fec943f201fbc8822879c99d31e59c1d51a983d9843ee5cf398539/regex-2025.10.23-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5b5cb5b6344c4c4c24b2dc87b0bfee78202b07ef7633385df70da7fcf6f7cec6", size = 488960, upload-time = "2025-10-21T15:56:40.849Z" },
{ url = "https://files.pythonhosted.org/packages/8e/7d/ebb7085b8fa31c24ce0355107cea2b92229d9050552a01c5d291c42aecea/regex-2025.10.23-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a6ce7973384c37bdf0f371a843f95a6e6f4e1489e10e0cf57330198df72959c5", size = 290932, upload-time = "2025-10-21T15:56:42.875Z" },
{ url = "https://files.pythonhosted.org/packages/27/41/43906867287cbb5ca4cee671c3cc8081e15deef86a8189c3aad9ac9f6b4d/regex-2025.10.23-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2ee3663f2c334959016b56e3bd0dd187cbc73f948e3a3af14c3caaa0c3035d10", size = 288766, upload-time = "2025-10-21T15:56:44.894Z" },
{ url = "https://files.pythonhosted.org/packages/ab/9e/ea66132776700fc77a39b1056e7a5f1308032fead94507e208dc6716b7cd/regex-2025.10.23-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2003cc82a579107e70d013482acce8ba773293f2db534fb532738395c557ff34", size = 798884, upload-time = "2025-10-21T15:56:47.178Z" },
{ url = "https://files.pythonhosted.org/packages/d5/99/aed1453687ab63819a443930770db972c5c8064421f0d9f5da9ad029f26b/regex-2025.10.23-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:182c452279365a93a9f45874f7f191ec1c51e1f1eb41bf2b16563f1a40c1da3a", size = 864768, upload-time = "2025-10-21T15:56:49.793Z" },
{ url = "https://files.pythonhosted.org/packages/99/5d/732fe747a1304805eb3853ce6337eea16b169f7105a0d0dd9c6a5ffa9948/regex-2025.10.23-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b1249e9ff581c5b658c8f0437f883b01f1edcf424a16388591e7c05e5e9e8b0c", size = 911394, upload-time = "2025-10-21T15:56:52.186Z" },
{ url = "https://files.pythonhosted.org/packages/5e/48/58a1f6623466522352a6efa153b9a3714fc559d9f930e9bc947b4a88a2c3/regex-2025.10.23-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b841698f93db3ccc36caa1900d2a3be281d9539b822dc012f08fc80b46a3224", size = 803145, upload-time = "2025-10-21T15:56:55.142Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f6/7dea79be2681a5574ab3fc237aa53b2c1dfd6bd2b44d4640b6c76f33f4c1/regex-2025.10.23-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:956d89e0c92d471e8f7eee73f73fdff5ed345886378c45a43175a77538a1ffe4", size = 787831, upload-time = "2025-10-21T15:56:57.203Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ad/07b76950fbbe65f88120ca2d8d845047c401450f607c99ed38862904671d/regex-2025.10.23-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5c259cb363299a0d90d63b5c0d7568ee98419861618a95ee9d91a41cb9954462", size = 859162, upload-time = "2025-10-21T15:56:59.195Z" },
{ url = "https://files.pythonhosted.org/packages/41/87/374f3b2021b22aa6a4fc0b750d63f9721e53d1631a238f7a1c343c1cd288/regex-2025.10.23-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:185d2b18c062820b3a40d8fefa223a83f10b20a674bf6e8c4a432e8dfd844627", size = 849899, upload-time = "2025-10-21T15:57:01.747Z" },
{ url = "https://files.pythonhosted.org/packages/12/4a/7f7bb17c5a5a9747249807210e348450dab9212a46ae6d23ebce86ba6a2b/regex-2025.10.23-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:281d87fa790049c2b7c1b4253121edd80b392b19b5a3d28dc2a77579cb2a58ec", size = 789372, upload-time = "2025-10-21T15:57:04.018Z" },
{ url = "https://files.pythonhosted.org/packages/c9/dd/9c7728ff544fea09bbc8635e4c9e7c423b11c24f1a7a14e6ac4831466709/regex-2025.10.23-cp314-cp314-win32.whl", hash = "sha256:63b81eef3656072e4ca87c58084c7a9c2b81d41a300b157be635a8a675aacfb8", size = 271451, upload-time = "2025-10-21T15:57:06.266Z" },
{ url = "https://files.pythonhosted.org/packages/48/f8/ef7837ff858eb74079c4804c10b0403c0b740762e6eedba41062225f7117/regex-2025.10.23-cp314-cp314-win_amd64.whl", hash = "sha256:0967c5b86f274800a34a4ed862dfab56928144d03cb18821c5153f8777947796", size = 280173, upload-time = "2025-10-21T15:57:08.206Z" },
{ url = "https://files.pythonhosted.org/packages/8e/d0/d576e1dbd9885bfcd83d0e90762beea48d9373a6f7ed39170f44ed22e336/regex-2025.10.23-cp314-cp314-win_arm64.whl", hash = "sha256:c70dfe58b0a00b36aa04cdb0f798bf3e0adc31747641f69e191109fd8572c9a9", size = 273206, upload-time = "2025-10-21T15:57:10.367Z" },
{ url = "https://files.pythonhosted.org/packages/a6/d0/2025268315e8b2b7b660039824cb7765a41623e97d4cd421510925400487/regex-2025.10.23-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1f5799ea1787aa6de6c150377d11afad39a38afd033f0c5247aecb997978c422", size = 491854, upload-time = "2025-10-21T15:57:12.526Z" },
{ url = "https://files.pythonhosted.org/packages/44/35/5681c2fec5e8b33454390af209c4353dfc44606bf06d714b0b8bd0454ffe/regex-2025.10.23-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a9639ab7540cfea45ef57d16dcbea2e22de351998d614c3ad2f9778fa3bdd788", size = 292542, upload-time = "2025-10-21T15:57:15.158Z" },
{ url = "https://files.pythonhosted.org/packages/5d/17/184eed05543b724132e4a18149e900f5189001fcfe2d64edaae4fbaf36b4/regex-2025.10.23-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:08f52122c352eb44c3421dab78b9b73a8a77a282cc8314ae576fcaa92b780d10", size = 290903, upload-time = "2025-10-21T15:57:17.108Z" },
{ url = "https://files.pythonhosted.org/packages/25/d0/5e3347aa0db0de382dddfa133a7b0ae72f24b4344f3989398980b44a3924/regex-2025.10.23-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebf1baebef1c4088ad5a5623decec6b52950f0e4d7a0ae4d48f0a99f8c9cb7d7", size = 807546, upload-time = "2025-10-21T15:57:19.179Z" },
{ url = "https://files.pythonhosted.org/packages/d2/bb/40c589bbdce1be0c55e9f8159789d58d47a22014f2f820cf2b517a5cd193/regex-2025.10.23-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:16b0f1c2e2d566c562d5c384c2b492646be0a19798532fdc1fdedacc66e3223f", size = 873322, upload-time = "2025-10-21T15:57:21.36Z" },
{ url = "https://files.pythonhosted.org/packages/fe/56/a7e40c01575ac93360e606278d359f91829781a9f7fb6e5aa435039edbda/regex-2025.10.23-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7ada5d9dceafaab92646aa00c10a9efd9b09942dd9b0d7c5a4b73db92cc7e61", size = 914855, upload-time = "2025-10-21T15:57:24.044Z" },
{ url = "https://files.pythonhosted.org/packages/5c/4b/d55587b192763db3163c3f508b3b67b31bb6f5e7a0e08b83013d0a59500a/regex-2025.10.23-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a36b4005770044bf08edecc798f0e41a75795b9e7c9c12fe29da8d792ef870c", size = 812724, upload-time = "2025-10-21T15:57:26.123Z" },
{ url = "https://files.pythonhosted.org/packages/33/20/18bac334955fbe99d17229f4f8e98d05e4a501ac03a442be8facbb37c304/regex-2025.10.23-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:af7b2661dcc032da1fae82069b5ebf2ac1dfcd5359ef8b35e1367bfc92181432", size = 795439, upload-time = "2025-10-21T15:57:28.497Z" },
{ url = "https://files.pythonhosted.org/packages/67/46/c57266be9df8549c7d85deb4cb82280cb0019e46fff677534c5fa1badfa4/regex-2025.10.23-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1cb976810ac1416a67562c2e5ba0accf6f928932320fef302e08100ed681b38e", size = 868336, upload-time = "2025-10-21T15:57:30.867Z" },
{ url = "https://files.pythonhosted.org/packages/b8/f3/bd5879e41ef8187fec5e678e94b526a93f99e7bbe0437b0f2b47f9101694/regex-2025.10.23-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:1a56a54be3897d62f54290190fbcd754bff6932934529fbf5b29933da28fcd43", size = 854567, upload-time = "2025-10-21T15:57:33.062Z" },
{ url = "https://files.pythonhosted.org/packages/e6/57/2b6bbdbd2f24dfed5b028033aa17ad8f7d86bb28f1a892cac8b3bc89d059/regex-2025.10.23-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8f3e6d202fb52c2153f532043bbcf618fd177df47b0b306741eb9b60ba96edc3", size = 799565, upload-time = "2025-10-21T15:57:35.153Z" },
{ url = "https://files.pythonhosted.org/packages/c7/ba/a6168f542ba73b151ed81237adf6b869c7b2f7f8d51618111296674e20ee/regex-2025.10.23-cp314-cp314t-win32.whl", hash = "sha256:1fa1186966b2621b1769fd467c7b22e317e6ba2d2cdcecc42ea3089ef04a8521", size = 274428, upload-time = "2025-10-21T15:57:37.996Z" },
{ url = "https://files.pythonhosted.org/packages/ef/a0/c84475e14a2829e9b0864ebf77c3f7da909df9d8acfe2bb540ff0072047c/regex-2025.10.23-cp314-cp314t-win_amd64.whl", hash = "sha256:08a15d40ce28362eac3e78e83d75475147869c1ff86bc93285f43b4f4431a741", size = 284140, upload-time = "2025-10-21T15:57:40.027Z" },
{ url = "https://files.pythonhosted.org/packages/51/33/6a08ade0eee5b8ba79386869fa6f77afeb835b60510f3525db987e2fffc4/regex-2025.10.23-cp314-cp314t-win_arm64.whl", hash = "sha256:a93e97338e1c8ea2649e130dcfbe8cd69bba5e1e163834752ab64dcb4de6d5ed", size = 274497, upload-time = "2025-10-21T15:57:42.389Z" },
{ url = "https://files.pythonhosted.org/packages/8a/d6/d788d52da01280a30a3f6268aef2aa71043bff359c618fea4c5b536654d5/regex-2025.11.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2b441a4ae2c8049106e8b39973bfbddfb25a179dda2bdb99b0eeb60c40a6a3af", size = 488087, upload-time = "2025-11-03T21:30:47.317Z" },
{ url = "https://files.pythonhosted.org/packages/69/39/abec3bd688ec9bbea3562de0fd764ff802976185f5ff22807bf0a2697992/regex-2025.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2fa2eed3f76677777345d2f81ee89f5de2f5745910e805f7af7386a920fa7313", size = 290544, upload-time = "2025-11-03T21:30:49.912Z" },
{ url = "https://files.pythonhosted.org/packages/39/b3/9a231475d5653e60002508f41205c61684bb2ffbf2401351ae2186897fc4/regex-2025.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d8b4a27eebd684319bdf473d39f1d79eed36bf2cd34bd4465cdb4618d82b3d56", size = 288408, upload-time = "2025-11-03T21:30:51.344Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c5/1929a0491bd5ac2d1539a866768b88965fa8c405f3e16a8cef84313098d6/regex-2025.11.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cf77eac15bd264986c4a2c63353212c095b40f3affb2bc6b4ef80c4776c1a28", size = 781584, upload-time = "2025-11-03T21:30:52.596Z" },
{ url = "https://files.pythonhosted.org/packages/ce/fd/16aa16cf5d497ef727ec966f74164fbe75d6516d3d58ac9aa989bc9cdaad/regex-2025.11.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f9ee819f94c6abfa56ec7b1dbab586f41ebbdc0a57e6524bd5e7f487a878c7", size = 850733, upload-time = "2025-11-03T21:30:53.825Z" },
{ url = "https://files.pythonhosted.org/packages/e6/49/3294b988855a221cb6565189edf5dc43239957427df2d81d4a6b15244f64/regex-2025.11.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:838441333bc90b829406d4a03cb4b8bf7656231b84358628b0406d803931ef32", size = 898691, upload-time = "2025-11-03T21:30:55.575Z" },
{ url = "https://files.pythonhosted.org/packages/14/62/b56d29e70b03666193369bdbdedfdc23946dbe9f81dd78ce262c74d988ab/regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfe6d3f0c9e3b7e8c0c694b24d25e677776f5ca26dce46fd6b0489f9c8339391", size = 791662, upload-time = "2025-11-03T21:30:57.262Z" },
{ url = "https://files.pythonhosted.org/packages/15/fc/e4c31d061eced63fbf1ce9d853975f912c61a7d406ea14eda2dd355f48e7/regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2ab815eb8a96379a27c3b6157fcb127c8f59c36f043c1678110cea492868f1d5", size = 782587, upload-time = "2025-11-03T21:30:58.788Z" },
{ url = "https://files.pythonhosted.org/packages/b2/bb/5e30c7394bcf63f0537121c23e796be67b55a8847c3956ae6068f4c70702/regex-2025.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:728a9d2d173a65b62bdc380b7932dd8e74ed4295279a8fe1021204ce210803e7", size = 774709, upload-time = "2025-11-03T21:31:00.081Z" },
{ url = "https://files.pythonhosted.org/packages/c5/c4/fce773710af81b0cb37cb4ff0947e75d5d17dee304b93d940b87a67fc2f4/regex-2025.11.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:509dc827f89c15c66a0c216331260d777dd6c81e9a4e4f830e662b0bb296c313", size = 845773, upload-time = "2025-11-03T21:31:01.583Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5e/9466a7ec4b8ec282077095c6eb50a12a389d2e036581134d4919e8ca518c/regex-2025.11.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:849202cd789e5f3cf5dcc7822c34b502181b4824a65ff20ce82da5524e45e8e9", size = 836164, upload-time = "2025-11-03T21:31:03.244Z" },
{ url = "https://files.pythonhosted.org/packages/95/18/82980a60e8ed1594eb3c89eb814fb276ef51b9af7caeab1340bfd8564af6/regex-2025.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b6f78f98741dcc89607c16b1e9426ee46ce4bf31ac5e6b0d40e81c89f3481ea5", size = 779832, upload-time = "2025-11-03T21:31:04.876Z" },
{ url = "https://files.pythonhosted.org/packages/03/cc/90ab0fdbe6dce064a42015433f9152710139fb04a8b81b4fb57a1cb63ffa/regex-2025.11.3-cp310-cp310-win32.whl", hash = "sha256:149eb0bba95231fb4f6d37c8f760ec9fa6fabf65bab555e128dde5f2475193ec", size = 265802, upload-time = "2025-11-03T21:31:06.581Z" },
{ url = "https://files.pythonhosted.org/packages/34/9d/e9e8493a85f3b1ddc4a5014465f5c2b78c3ea1cbf238dcfde78956378041/regex-2025.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:ee3a83ce492074c35a74cc76cf8235d49e77b757193a5365ff86e3f2f93db9fd", size = 277722, upload-time = "2025-11-03T21:31:08.144Z" },
{ url = "https://files.pythonhosted.org/packages/15/c4/b54b24f553966564506dbf873a3e080aef47b356a3b39b5d5aba992b50db/regex-2025.11.3-cp310-cp310-win_arm64.whl", hash = "sha256:38af559ad934a7b35147716655d4a2f79fcef2d695ddfe06a06ba40ae631fa7e", size = 270289, upload-time = "2025-11-03T21:31:10.267Z" },
{ url = "https://files.pythonhosted.org/packages/f7/90/4fb5056e5f03a7048abd2b11f598d464f0c167de4f2a51aa868c376b8c70/regex-2025.11.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eadade04221641516fa25139273505a1c19f9bf97589a05bc4cfcd8b4a618031", size = 488081, upload-time = "2025-11-03T21:31:11.946Z" },
{ url = "https://files.pythonhosted.org/packages/85/23/63e481293fac8b069d84fba0299b6666df720d875110efd0338406b5d360/regex-2025.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:feff9e54ec0dd3833d659257f5c3f5322a12eee58ffa360984b716f8b92983f4", size = 290554, upload-time = "2025-11-03T21:31:13.387Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9d/b101d0262ea293a0066b4522dfb722eb6a8785a8c3e084396a5f2c431a46/regex-2025.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3b30bc921d50365775c09a7ed446359e5c0179e9e2512beec4a60cbcef6ddd50", size = 288407, upload-time = "2025-11-03T21:31:14.809Z" },
{ url = "https://files.pythonhosted.org/packages/0c/64/79241c8209d5b7e00577ec9dca35cd493cc6be35b7d147eda367d6179f6d/regex-2025.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f99be08cfead2020c7ca6e396c13543baea32343b7a9a5780c462e323bd8872f", size = 793418, upload-time = "2025-11-03T21:31:16.556Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e2/23cd5d3573901ce8f9757c92ca4db4d09600b865919b6d3e7f69f03b1afd/regex-2025.11.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6dd329a1b61c0ee95ba95385fb0c07ea0d3fe1a21e1349fa2bec272636217118", size = 860448, upload-time = "2025-11-03T21:31:18.12Z" },
{ url = "https://files.pythonhosted.org/packages/2a/4c/aecf31beeaa416d0ae4ecb852148d38db35391aac19c687b5d56aedf3a8b/regex-2025.11.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c5238d32f3c5269d9e87be0cf096437b7622b6920f5eac4fd202468aaeb34d2", size = 907139, upload-time = "2025-11-03T21:31:20.753Z" },
{ url = "https://files.pythonhosted.org/packages/61/22/b8cb00df7d2b5e0875f60628594d44dba283e951b1ae17c12f99e332cc0a/regex-2025.11.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10483eefbfb0adb18ee9474498c9a32fcf4e594fbca0543bb94c48bac6183e2e", size = 800439, upload-time = "2025-11-03T21:31:22.069Z" },
{ url = "https://files.pythonhosted.org/packages/02/a8/c4b20330a5cdc7a8eb265f9ce593f389a6a88a0c5f280cf4d978f33966bc/regex-2025.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:78c2d02bb6e1da0720eedc0bad578049cad3f71050ef8cd065ecc87691bed2b0", size = 782965, upload-time = "2025-11-03T21:31:23.598Z" },
{ url = "https://files.pythonhosted.org/packages/b4/4c/ae3e52988ae74af4b04d2af32fee4e8077f26e51b62ec2d12d246876bea2/regex-2025.11.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e6b49cd2aad93a1790ce9cffb18964f6d3a4b0b3dbdbd5de094b65296fce6e58", size = 854398, upload-time = "2025-11-03T21:31:25.008Z" },
{ url = "https://files.pythonhosted.org/packages/06/d1/a8b9cf45874eda14b2e275157ce3b304c87e10fb38d9fc26a6e14eb18227/regex-2025.11.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:885b26aa3ee56433b630502dc3d36ba78d186a00cc535d3806e6bfd9ed3c70ab", size = 845897, upload-time = "2025-11-03T21:31:26.427Z" },
{ url = "https://files.pythonhosted.org/packages/ea/fe/1830eb0236be93d9b145e0bd8ab499f31602fe0999b1f19e99955aa8fe20/regex-2025.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ddd76a9f58e6a00f8772e72cff8ebcff78e022be95edf018766707c730593e1e", size = 788906, upload-time = "2025-11-03T21:31:28.078Z" },
{ url = "https://files.pythonhosted.org/packages/66/47/dc2577c1f95f188c1e13e2e69d8825a5ac582ac709942f8a03af42ed6e93/regex-2025.11.3-cp311-cp311-win32.whl", hash = "sha256:3e816cc9aac1cd3cc9a4ec4d860f06d40f994b5c7b4d03b93345f44e08cc68bf", size = 265812, upload-time = "2025-11-03T21:31:29.72Z" },
{ url = "https://files.pythonhosted.org/packages/50/1e/15f08b2f82a9bbb510621ec9042547b54d11e83cb620643ebb54e4eb7d71/regex-2025.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:087511f5c8b7dfbe3a03f5d5ad0c2a33861b1fc387f21f6f60825a44865a385a", size = 277737, upload-time = "2025-11-03T21:31:31.422Z" },
{ url = "https://files.pythonhosted.org/packages/f4/fc/6500eb39f5f76c5e47a398df82e6b535a5e345f839581012a418b16f9cc3/regex-2025.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:1ff0d190c7f68ae7769cd0313fe45820ba07ffebfddfaa89cc1eb70827ba0ddc", size = 270290, upload-time = "2025-11-03T21:31:33.041Z" },
{ url = "https://files.pythonhosted.org/packages/e8/74/18f04cb53e58e3fb107439699bd8375cf5a835eec81084e0bddbd122e4c2/regex-2025.11.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8ab71e2e31b16e40868a40a69007bc305e1109bd4658eb6cad007e0bf67c41", size = 489312, upload-time = "2025-11-03T21:31:34.343Z" },
{ url = "https://files.pythonhosted.org/packages/78/3f/37fcdd0d2b1e78909108a876580485ea37c91e1acf66d3bb8e736348f441/regex-2025.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:22b29dda7e1f7062a52359fca6e58e548e28c6686f205e780b02ad8ef710de36", size = 291256, upload-time = "2025-11-03T21:31:35.675Z" },
{ url = "https://files.pythonhosted.org/packages/bf/26/0a575f58eb23b7ebd67a45fccbc02ac030b737b896b7e7a909ffe43ffd6a/regex-2025.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a91e4a29938bc1a082cc28fdea44be420bf2bebe2665343029723892eb073e1", size = 288921, upload-time = "2025-11-03T21:31:37.07Z" },
{ url = "https://files.pythonhosted.org/packages/ea/98/6a8dff667d1af907150432cf5abc05a17ccd32c72a3615410d5365ac167a/regex-2025.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b884f4226602ad40c5d55f52bf91a9df30f513864e0054bad40c0e9cf1afb7", size = 798568, upload-time = "2025-11-03T21:31:38.784Z" },
{ url = "https://files.pythonhosted.org/packages/64/15/92c1db4fa4e12733dd5a526c2dd2b6edcbfe13257e135fc0f6c57f34c173/regex-2025.11.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e0b11b2b2433d1c39c7c7a30e3f3d0aeeea44c2a8d0bae28f6b95f639927a69", size = 864165, upload-time = "2025-11-03T21:31:40.559Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e7/3ad7da8cdee1ce66c7cd37ab5ab05c463a86ffeb52b1a25fe7bd9293b36c/regex-2025.11.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87eb52a81ef58c7ba4d45c3ca74e12aa4b4e77816f72ca25258a85b3ea96cb48", size = 912182, upload-time = "2025-11-03T21:31:42.002Z" },
{ url = "https://files.pythonhosted.org/packages/84/bd/9ce9f629fcb714ffc2c3faf62b6766ecb7a585e1e885eb699bcf130a5209/regex-2025.11.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a12ab1f5c29b4e93db518f5e3872116b7e9b1646c9f9f426f777b50d44a09e8c", size = 803501, upload-time = "2025-11-03T21:31:43.815Z" },
{ url = "https://files.pythonhosted.org/packages/7c/0f/8dc2e4349d8e877283e6edd6c12bdcebc20f03744e86f197ab6e4492bf08/regex-2025.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7521684c8c7c4f6e88e35ec89680ee1aa8358d3f09d27dfbdf62c446f5d4c695", size = 787842, upload-time = "2025-11-03T21:31:45.353Z" },
{ url = "https://files.pythonhosted.org/packages/f9/73/cff02702960bc185164d5619c0c62a2f598a6abff6695d391b096237d4ab/regex-2025.11.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7fe6e5440584e94cc4b3f5f4d98a25e29ca12dccf8873679a635638349831b98", size = 858519, upload-time = "2025-11-03T21:31:46.814Z" },
{ url = "https://files.pythonhosted.org/packages/61/83/0e8d1ae71e15bc1dc36231c90b46ee35f9d52fab2e226b0e039e7ea9c10a/regex-2025.11.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8e026094aa12b43f4fd74576714e987803a315c76edb6b098b9809db5de58f74", size = 850611, upload-time = "2025-11-03T21:31:48.289Z" },
{ url = "https://files.pythonhosted.org/packages/c8/f5/70a5cdd781dcfaa12556f2955bf170cd603cb1c96a1827479f8faea2df97/regex-2025.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:435bbad13e57eb5606a68443af62bed3556de2f46deb9f7d4237bc2f1c9fb3a0", size = 789759, upload-time = "2025-11-03T21:31:49.759Z" },
{ url = "https://files.pythonhosted.org/packages/59/9b/7c29be7903c318488983e7d97abcf8ebd3830e4c956c4c540005fcfb0462/regex-2025.11.3-cp312-cp312-win32.whl", hash = "sha256:3839967cf4dc4b985e1570fd8d91078f0c519f30491c60f9ac42a8db039be204", size = 266194, upload-time = "2025-11-03T21:31:51.53Z" },
{ url = "https://files.pythonhosted.org/packages/1a/67/3b92df89f179d7c367be654ab5626ae311cb28f7d5c237b6bb976cd5fbbb/regex-2025.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:e721d1b46e25c481dc5ded6f4b3f66c897c58d2e8cfdf77bbced84339108b0b9", size = 277069, upload-time = "2025-11-03T21:31:53.151Z" },
{ url = "https://files.pythonhosted.org/packages/d7/55/85ba4c066fe5094d35b249c3ce8df0ba623cfd35afb22d6764f23a52a1c5/regex-2025.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:64350685ff08b1d3a6fff33f45a9ca183dc1d58bbfe4981604e70ec9801bbc26", size = 270330, upload-time = "2025-11-03T21:31:54.514Z" },
{ url = "https://files.pythonhosted.org/packages/e1/a7/dda24ebd49da46a197436ad96378f17df30ceb40e52e859fc42cac45b850/regex-2025.11.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c1e448051717a334891f2b9a620fe36776ebf3dd8ec46a0b877c8ae69575feb4", size = 489081, upload-time = "2025-11-03T21:31:55.9Z" },
{ url = "https://files.pythonhosted.org/packages/19/22/af2dc751aacf88089836aa088a1a11c4f21a04707eb1b0478e8e8fb32847/regex-2025.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b5aca4d5dfd7fbfbfbdaf44850fcc7709a01146a797536a8f84952e940cca76", size = 291123, upload-time = "2025-11-03T21:31:57.758Z" },
{ url = "https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a", size = 288814, upload-time = "2025-11-03T21:32:01.12Z" },
{ url = "https://files.pythonhosted.org/packages/fb/8c/f5987895bf42b8ddeea1b315c9fedcfe07cadee28b9c98cf50d00adcb14d/regex-2025.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d9903ca42bfeec4cebedba8022a7c97ad2aab22e09573ce9976ba01b65e4361", size = 798592, upload-time = "2025-11-03T21:32:03.006Z" },
{ url = "https://files.pythonhosted.org/packages/99/2a/6591ebeede78203fa77ee46a1c36649e02df9eaa77a033d1ccdf2fcd5d4e/regex-2025.11.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:639431bdc89d6429f6721625e8129413980ccd62e9d3f496be618a41d205f160", size = 864122, upload-time = "2025-11-03T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/94/d6/be32a87cf28cf8ed064ff281cfbd49aefd90242a83e4b08b5a86b38e8eb4/regex-2025.11.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f117efad42068f9715677c8523ed2be1518116d1c49b1dd17987716695181efe", size = 912272, upload-time = "2025-11-03T21:32:06.148Z" },
{ url = "https://files.pythonhosted.org/packages/62/11/9bcef2d1445665b180ac7f230406ad80671f0fc2a6ffb93493b5dd8cd64c/regex-2025.11.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aecb6f461316adf9f1f0f6a4a1a3d79e045f9b71ec76055a791affa3b285850", size = 803497, upload-time = "2025-11-03T21:32:08.162Z" },
{ url = "https://files.pythonhosted.org/packages/e5/a7/da0dc273d57f560399aa16d8a68ae7f9b57679476fc7ace46501d455fe84/regex-2025.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b3a5f320136873cc5561098dfab677eea139521cb9a9e8db98b7e64aef44cbc", size = 787892, upload-time = "2025-11-03T21:32:09.769Z" },
{ url = "https://files.pythonhosted.org/packages/da/4b/732a0c5a9736a0b8d6d720d4945a2f1e6f38f87f48f3173559f53e8d5d82/regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9", size = 858462, upload-time = "2025-11-03T21:32:11.769Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f5/a2a03df27dc4c2d0c769220f5110ba8c4084b0bfa9ab0f9b4fcfa3d2b0fc/regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b", size = 850528, upload-time = "2025-11-03T21:32:13.906Z" },
{ url = "https://files.pythonhosted.org/packages/d6/09/e1cd5bee3841c7f6eb37d95ca91cdee7100b8f88b81e41c2ef426910891a/regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7", size = 789866, upload-time = "2025-11-03T21:32:15.748Z" },
{ url = "https://files.pythonhosted.org/packages/eb/51/702f5ea74e2a9c13d855a6a85b7f80c30f9e72a95493260193c07f3f8d74/regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c", size = 266189, upload-time = "2025-11-03T21:32:17.493Z" },
{ url = "https://files.pythonhosted.org/packages/8b/00/6e29bb314e271a743170e53649db0fdb8e8ff0b64b4f425f5602f4eb9014/regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5", size = 277054, upload-time = "2025-11-03T21:32:19.042Z" },
{ url = "https://files.pythonhosted.org/packages/25/f1/b156ff9f2ec9ac441710764dda95e4edaf5f36aca48246d1eea3f1fd96ec/regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467", size = 270325, upload-time = "2025-11-03T21:32:21.338Z" },
{ url = "https://files.pythonhosted.org/packages/20/28/fd0c63357caefe5680b8ea052131acbd7f456893b69cc2a90cc3e0dc90d4/regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281", size = 491984, upload-time = "2025-11-03T21:32:23.466Z" },
{ url = "https://files.pythonhosted.org/packages/df/ec/7014c15626ab46b902b3bcc4b28a7bae46d8f281fc7ea9c95e22fcaaa917/regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39", size = 292673, upload-time = "2025-11-03T21:32:25.034Z" },
{ url = "https://files.pythonhosted.org/packages/23/ab/3b952ff7239f20d05f1f99e9e20188513905f218c81d52fb5e78d2bf7634/regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7", size = 291029, upload-time = "2025-11-03T21:32:26.528Z" },
{ url = "https://files.pythonhosted.org/packages/21/7e/3dc2749fc684f455f162dcafb8a187b559e2614f3826877d3844a131f37b/regex-2025.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f264d4bf02f3176467d90b294d59bf1db9fe53c141ff772f27a8b456b2a9ed", size = 807437, upload-time = "2025-11-03T21:32:28.363Z" },
{ url = "https://files.pythonhosted.org/packages/1b/0b/d529a85ab349c6a25d1ca783235b6e3eedf187247eab536797021f7126c6/regex-2025.11.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7be0277469bf3bd7a34a9c57c1b6a724532a0d235cd0dc4e7f4316f982c28b19", size = 873368, upload-time = "2025-11-03T21:32:30.4Z" },
{ url = "https://files.pythonhosted.org/packages/7d/18/2d868155f8c9e3e9d8f9e10c64e9a9f496bb8f7e037a88a8bed26b435af6/regex-2025.11.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d31e08426ff4b5b650f68839f5af51a92a5b51abd8554a60c2fbc7c71f25d0b", size = 914921, upload-time = "2025-11-03T21:32:32.123Z" },
{ url = "https://files.pythonhosted.org/packages/2d/71/9d72ff0f354fa783fe2ba913c8734c3b433b86406117a8db4ea2bf1c7a2f/regex-2025.11.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e43586ce5bd28f9f285a6e729466841368c4a0353f6fd08d4ce4630843d3648a", size = 812708, upload-time = "2025-11-03T21:32:34.305Z" },
{ url = "https://files.pythonhosted.org/packages/e7/19/ce4bf7f5575c97f82b6e804ffb5c4e940c62609ab2a0d9538d47a7fdf7d4/regex-2025.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f9397d561a4c16829d4e6ff75202c1c08b68a3bdbfe29dbfcdb31c9830907c6", size = 795472, upload-time = "2025-11-03T21:32:36.364Z" },
{ url = "https://files.pythonhosted.org/packages/03/86/fd1063a176ffb7b2315f9a1b08d17b18118b28d9df163132615b835a26ee/regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce", size = 868341, upload-time = "2025-11-03T21:32:38.042Z" },
{ url = "https://files.pythonhosted.org/packages/12/43/103fb2e9811205e7386366501bc866a164a0430c79dd59eac886a2822950/regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd", size = 854666, upload-time = "2025-11-03T21:32:40.079Z" },
{ url = "https://files.pythonhosted.org/packages/7d/22/e392e53f3869b75804762c7c848bd2dd2abf2b70fb0e526f58724638bd35/regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2", size = 799473, upload-time = "2025-11-03T21:32:42.148Z" },
{ url = "https://files.pythonhosted.org/packages/4f/f9/8bd6b656592f925b6845fcbb4d57603a3ac2fb2373344ffa1ed70aa6820a/regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a", size = 268792, upload-time = "2025-11-03T21:32:44.13Z" },
{ url = "https://files.pythonhosted.org/packages/e5/87/0e7d603467775ff65cd2aeabf1b5b50cc1c3708556a8b849a2fa4dd1542b/regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c", size = 280214, upload-time = "2025-11-03T21:32:45.853Z" },
{ url = "https://files.pythonhosted.org/packages/8d/d0/2afc6f8e94e2b64bfb738a7c2b6387ac1699f09f032d363ed9447fd2bb57/regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e", size = 271469, upload-time = "2025-11-03T21:32:48.026Z" },
{ url = "https://files.pythonhosted.org/packages/31/e9/f6e13de7e0983837f7b6d238ad9458800a874bf37c264f7923e63409944c/regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6", size = 489089, upload-time = "2025-11-03T21:32:50.027Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5c/261f4a262f1fa65141c1b74b255988bd2fa020cc599e53b080667d591cfc/regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4", size = 291059, upload-time = "2025-11-03T21:32:51.682Z" },
{ url = "https://files.pythonhosted.org/packages/8e/57/f14eeb7f072b0e9a5a090d1712741fd8f214ec193dba773cf5410108bb7d/regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73", size = 288900, upload-time = "2025-11-03T21:32:53.569Z" },
{ url = "https://files.pythonhosted.org/packages/3c/6b/1d650c45e99a9b327586739d926a1cd4e94666b1bd4af90428b36af66dc7/regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f", size = 799010, upload-time = "2025-11-03T21:32:55.222Z" },
{ url = "https://files.pythonhosted.org/packages/99/ee/d66dcbc6b628ce4e3f7f0cbbb84603aa2fc0ffc878babc857726b8aab2e9/regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d", size = 864893, upload-time = "2025-11-03T21:32:57.239Z" },
{ url = "https://files.pythonhosted.org/packages/bf/2d/f238229f1caba7ac87a6c4153d79947fb0261415827ae0f77c304260c7d3/regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be", size = 911522, upload-time = "2025-11-03T21:32:59.274Z" },
{ url = "https://files.pythonhosted.org/packages/bd/3d/22a4eaba214a917c80e04f6025d26143690f0419511e0116508e24b11c9b/regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db", size = 803272, upload-time = "2025-11-03T21:33:01.393Z" },
{ url = "https://files.pythonhosted.org/packages/84/b1/03188f634a409353a84b5ef49754b97dbcc0c0f6fd6c8ede505a8960a0a4/regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62", size = 787958, upload-time = "2025-11-03T21:33:03.379Z" },
{ url = "https://files.pythonhosted.org/packages/99/6a/27d072f7fbf6fadd59c64d210305e1ff865cc3b78b526fd147db768c553b/regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f", size = 859289, upload-time = "2025-11-03T21:33:05.374Z" },
{ url = "https://files.pythonhosted.org/packages/9a/70/1b3878f648e0b6abe023172dacb02157e685564853cc363d9961bcccde4e/regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02", size = 850026, upload-time = "2025-11-03T21:33:07.131Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d5/68e25559b526b8baab8e66839304ede68ff6727237a47727d240006bd0ff/regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed", size = 789499, upload-time = "2025-11-03T21:33:09.141Z" },
{ url = "https://files.pythonhosted.org/packages/fc/df/43971264857140a350910d4e33df725e8c94dd9dee8d2e4729fa0d63d49e/regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4", size = 271604, upload-time = "2025-11-03T21:33:10.9Z" },
{ url = "https://files.pythonhosted.org/packages/01/6f/9711b57dc6894a55faf80a4c1b5aa4f8649805cb9c7aef46f7d27e2b9206/regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad", size = 280320, upload-time = "2025-11-03T21:33:12.572Z" },
{ url = "https://files.pythonhosted.org/packages/f1/7e/f6eaa207d4377481f5e1775cdeb5a443b5a59b392d0065f3417d31d80f87/regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f", size = 273372, upload-time = "2025-11-03T21:33:14.219Z" },
{ url = "https://files.pythonhosted.org/packages/c3/06/49b198550ee0f5e4184271cee87ba4dfd9692c91ec55289e6282f0f86ccf/regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc", size = 491985, upload-time = "2025-11-03T21:33:16.555Z" },
{ url = "https://files.pythonhosted.org/packages/ce/bf/abdafade008f0b1c9da10d934034cb670432d6cf6cbe38bbb53a1cfd6cf8/regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49", size = 292669, upload-time = "2025-11-03T21:33:18.32Z" },
{ url = "https://files.pythonhosted.org/packages/f9/ef/0c357bb8edbd2ad8e273fcb9e1761bc37b8acbc6e1be050bebd6475f19c1/regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536", size = 291030, upload-time = "2025-11-03T21:33:20.048Z" },
{ url = "https://files.pythonhosted.org/packages/79/06/edbb67257596649b8fb088d6aeacbcb248ac195714b18a65e018bf4c0b50/regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95", size = 807674, upload-time = "2025-11-03T21:33:21.797Z" },
{ url = "https://files.pythonhosted.org/packages/f4/d9/ad4deccfce0ea336296bd087f1a191543bb99ee1c53093dcd4c64d951d00/regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009", size = 873451, upload-time = "2025-11-03T21:33:23.741Z" },
{ url = "https://files.pythonhosted.org/packages/13/75/a55a4724c56ef13e3e04acaab29df26582f6978c000ac9cd6810ad1f341f/regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9", size = 914980, upload-time = "2025-11-03T21:33:25.999Z" },
{ url = "https://files.pythonhosted.org/packages/67/1e/a1657ee15bd9116f70d4a530c736983eed997b361e20ecd8f5ca3759d5c5/regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d", size = 812852, upload-time = "2025-11-03T21:33:27.852Z" },
{ url = "https://files.pythonhosted.org/packages/b8/6f/f7516dde5506a588a561d296b2d0044839de06035bb486b326065b4c101e/regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6", size = 795566, upload-time = "2025-11-03T21:33:32.364Z" },
{ url = "https://files.pythonhosted.org/packages/d9/dd/3d10b9e170cc16fb34cb2cef91513cf3df65f440b3366030631b2984a264/regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154", size = 868463, upload-time = "2025-11-03T21:33:34.459Z" },
{ url = "https://files.pythonhosted.org/packages/f5/8e/935e6beff1695aa9085ff83195daccd72acc82c81793df480f34569330de/regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267", size = 854694, upload-time = "2025-11-03T21:33:36.793Z" },
{ url = "https://files.pythonhosted.org/packages/92/12/10650181a040978b2f5720a6a74d44f841371a3d984c2083fc1752e4acf6/regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379", size = 799691, upload-time = "2025-11-03T21:33:39.079Z" },
{ url = "https://files.pythonhosted.org/packages/67/90/8f37138181c9a7690e7e4cb388debbd389342db3c7381d636d2875940752/regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38", size = 274583, upload-time = "2025-11-03T21:33:41.302Z" },
{ url = "https://files.pythonhosted.org/packages/8f/cd/867f5ec442d56beb56f5f854f40abcfc75e11d10b11fdb1869dd39c63aaf/regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de", size = 284286, upload-time = "2025-11-03T21:33:43.324Z" },
{ url = "https://files.pythonhosted.org/packages/20/31/32c0c4610cbc070362bf1d2e4ea86d1ea29014d400a6d6c2486fcfd57766/regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801", size = 274741, upload-time = "2025-11-03T21:33:45.557Z" },
]
[[package]]
@@ -5136,28 +5255,28 @@ wheels = [
[[package]]
name = "ruff"
version = "0.14.2"
version = "0.14.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/34/8218a19b2055b80601e8fd201ec723c74c7fe1ca06d525a43ed07b6d8e85/ruff-0.14.2.tar.gz", hash = "sha256:98da787668f239313d9c902ca7c523fe11b8ec3f39345553a51b25abc4629c96", size = 5539663, upload-time = "2025-10-23T19:37:00.956Z" }
sdist = { url = "https://files.pythonhosted.org/packages/75/62/50b7727004dfe361104dfbf898c45a9a2fdfad8c72c04ae62900224d6ecf/ruff-0.14.3.tar.gz", hash = "sha256:4ff876d2ab2b161b6de0aa1f5bd714e8e9b4033dc122ee006925fbacc4f62153", size = 5558687, upload-time = "2025-10-31T00:26:26.878Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/dd/23eb2db5ad9acae7c845700493b72d3ae214dce0b226f27df89216110f2b/ruff-0.14.2-py3-none-linux_armv6l.whl", hash = "sha256:7cbe4e593505bdec5884c2d0a4d791a90301bc23e49a6b1eb642dd85ef9c64f1", size = 12533390, upload-time = "2025-10-23T19:36:18.044Z" },
{ url = "https://files.pythonhosted.org/packages/5a/8c/5f9acff43ddcf3f85130d0146d0477e28ccecc495f9f684f8f7119b74c0d/ruff-0.14.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8d54b561729cee92f8d89c316ad7a3f9705533f5903b042399b6ae0ddfc62e11", size = 12887187, upload-time = "2025-10-23T19:36:22.664Z" },
{ url = "https://files.pythonhosted.org/packages/99/fa/047646491479074029665022e9f3dc6f0515797f40a4b6014ea8474c539d/ruff-0.14.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c8753dfa44ebb2cde10ce5b4d2ef55a41fb9d9b16732a2c5df64620dbda44a3", size = 11925177, upload-time = "2025-10-23T19:36:24.778Z" },
{ url = "https://files.pythonhosted.org/packages/15/8b/c44cf7fe6e59ab24a9d939493a11030b503bdc2a16622cede8b7b1df0114/ruff-0.14.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d0bbeffb8d9f4fccf7b5198d566d0bad99a9cb622f1fc3467af96cb8773c9e3", size = 12358285, upload-time = "2025-10-23T19:36:26.979Z" },
{ url = "https://files.pythonhosted.org/packages/45/01/47701b26254267ef40369aea3acb62a7b23e921c27372d127e0f3af48092/ruff-0.14.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7047f0c5a713a401e43a88d36843d9c83a19c584e63d664474675620aaa634a8", size = 12303832, upload-time = "2025-10-23T19:36:29.192Z" },
{ url = "https://files.pythonhosted.org/packages/2d/5c/ae7244ca4fbdf2bee9d6405dcd5bc6ae51ee1df66eb7a9884b77b8af856d/ruff-0.14.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bf8d2f9aa1602599217d82e8e0af7fd33e5878c4d98f37906b7c93f46f9a839", size = 13036995, upload-time = "2025-10-23T19:36:31.861Z" },
{ url = "https://files.pythonhosted.org/packages/27/4c/0860a79ce6fd4c709ac01173f76f929d53f59748d0dcdd662519835dae43/ruff-0.14.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1c505b389e19c57a317cf4b42db824e2fca96ffb3d86766c1c9f8b96d32048a7", size = 14512649, upload-time = "2025-10-23T19:36:33.915Z" },
{ url = "https://files.pythonhosted.org/packages/7f/7f/d365de998069720a3abfc250ddd876fc4b81a403a766c74ff9bde15b5378/ruff-0.14.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a307fc45ebd887b3f26b36d9326bb70bf69b01561950cdcc6c0bdf7bb8e0f7cc", size = 14088182, upload-time = "2025-10-23T19:36:36.983Z" },
{ url = "https://files.pythonhosted.org/packages/6c/ea/d8e3e6b209162000a7be1faa41b0a0c16a133010311edc3329753cc6596a/ruff-0.14.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61ae91a32c853172f832c2f40bd05fd69f491db7289fb85a9b941ebdd549781a", size = 13599516, upload-time = "2025-10-23T19:36:39.208Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ea/c7810322086db68989fb20a8d5221dd3b79e49e396b01badca07b433ab45/ruff-0.14.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1967e40286f63ee23c615e8e7e98098dedc7301568bd88991f6e544d8ae096", size = 13272690, upload-time = "2025-10-23T19:36:41.453Z" },
{ url = "https://files.pythonhosted.org/packages/a9/39/10b05acf8c45786ef501d454e00937e1b97964f846bf28883d1f9619928a/ruff-0.14.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2877f02119cdebf52a632d743a2e302dea422bfae152ebe2f193d3285a3a65df", size = 13496497, upload-time = "2025-10-23T19:36:43.61Z" },
{ url = "https://files.pythonhosted.org/packages/59/a1/1f25f8301e13751c30895092485fada29076e5e14264bdacc37202e85d24/ruff-0.14.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e681c5bc777de5af898decdcb6ba3321d0d466f4cb43c3e7cc2c3b4e7b843a05", size = 12266116, upload-time = "2025-10-23T19:36:45.625Z" },
{ url = "https://files.pythonhosted.org/packages/5c/fa/0029bfc9ce16ae78164e6923ef392e5f173b793b26cc39aa1d8b366cf9dc/ruff-0.14.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e21be42d72e224736f0c992cdb9959a2fa53c7e943b97ef5d081e13170e3ffc5", size = 12281345, upload-time = "2025-10-23T19:36:47.618Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ab/ece7baa3c0f29b7683be868c024f0838770c16607bea6852e46b202f1ff6/ruff-0.14.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b8264016f6f209fac16262882dbebf3f8be1629777cf0f37e7aff071b3e9b92e", size = 12629296, upload-time = "2025-10-23T19:36:49.789Z" },
{ url = "https://files.pythonhosted.org/packages/a4/7f/638f54b43f3d4e48c6a68062794e5b367ddac778051806b9e235dfb7aa81/ruff-0.14.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ca36b4cb4db3067a3b24444463ceea5565ea78b95fe9a07ca7cb7fd16948770", size = 13371610, upload-time = "2025-10-23T19:36:51.882Z" },
{ url = "https://files.pythonhosted.org/packages/8d/35/3654a973ebe5b32e1fd4a08ed2d46755af7267da7ac710d97420d7b8657d/ruff-0.14.2-py3-none-win32.whl", hash = "sha256:41775927d287685e08f48d8eb3f765625ab0b7042cc9377e20e64f4eb0056ee9", size = 12415318, upload-time = "2025-10-23T19:36:53.961Z" },
{ url = "https://files.pythonhosted.org/packages/71/30/3758bcf9e0b6a4193a6f51abf84254aba00887dfa8c20aba18aa366c5f57/ruff-0.14.2-py3-none-win_amd64.whl", hash = "sha256:0df3424aa5c3c08b34ed8ce099df1021e3adaca6e90229273496b839e5a7e1af", size = 13565279, upload-time = "2025-10-23T19:36:56.578Z" },
{ url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" },
{ url = "https://files.pythonhosted.org/packages/ce/8e/0c10ff1ea5d4360ab8bfca4cb2c9d979101a391f3e79d2616c9bf348cd26/ruff-0.14.3-py3-none-linux_armv6l.whl", hash = "sha256:876b21e6c824f519446715c1342b8e60f97f93264012de9d8d10314f8a79c371", size = 12535613, upload-time = "2025-10-31T00:25:44.302Z" },
{ url = "https://files.pythonhosted.org/packages/d3/c8/6724f4634c1daf52409fbf13fefda64aa9c8f81e44727a378b7b73dc590b/ruff-0.14.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6fd8c79b457bedd2abf2702b9b472147cd860ed7855c73a5247fa55c9117654", size = 12855812, upload-time = "2025-10-31T00:25:47.793Z" },
{ url = "https://files.pythonhosted.org/packages/de/03/db1bce591d55fd5f8a08bb02517fa0b5097b2ccabd4ea1ee29aa72b67d96/ruff-0.14.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71ff6edca490c308f083156938c0c1a66907151263c4abdcb588602c6e696a14", size = 11944026, upload-time = "2025-10-31T00:25:49.657Z" },
{ url = "https://files.pythonhosted.org/packages/0b/75/4f8dbd48e03272715d12c87dc4fcaaf21b913f0affa5f12a4e9c6f8a0582/ruff-0.14.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:786ee3ce6139772ff9272aaf43296d975c0217ee1b97538a98171bf0d21f87ed", size = 12356818, upload-time = "2025-10-31T00:25:51.949Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9b/506ec5b140c11d44a9a4f284ea7c14ebf6f8b01e6e8917734a3325bff787/ruff-0.14.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cd6291d0061811c52b8e392f946889916757610d45d004e41140d81fb6cd5ddc", size = 12336745, upload-time = "2025-10-31T00:25:54.248Z" },
{ url = "https://files.pythonhosted.org/packages/c7/e1/c560d254048c147f35e7f8131d30bc1f63a008ac61595cf3078a3e93533d/ruff-0.14.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a497ec0c3d2c88561b6d90f9c29f5ae68221ac00d471f306fa21fa4264ce5fcd", size = 13101684, upload-time = "2025-10-31T00:25:56.253Z" },
{ url = "https://files.pythonhosted.org/packages/a5/32/e310133f8af5cd11f8cc30f52522a3ebccc5ea5bff4b492f94faceaca7a8/ruff-0.14.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e231e1be58fc568950a04fbe6887c8e4b85310e7889727e2b81db205c45059eb", size = 14535000, upload-time = "2025-10-31T00:25:58.397Z" },
{ url = "https://files.pythonhosted.org/packages/a2/a1/7b0470a22158c6d8501eabc5e9b6043c99bede40fa1994cadf6b5c2a61c7/ruff-0.14.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:469e35872a09c0e45fecf48dd960bfbce056b5db2d5e6b50eca329b4f853ae20", size = 14156450, upload-time = "2025-10-31T00:26:00.889Z" },
{ url = "https://files.pythonhosted.org/packages/0a/96/24bfd9d1a7f532b560dcee1a87096332e461354d3882124219bcaff65c09/ruff-0.14.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d6bc90307c469cb9d28b7cfad90aaa600b10d67c6e22026869f585e1e8a2db0", size = 13568414, upload-time = "2025-10-31T00:26:03.291Z" },
{ url = "https://files.pythonhosted.org/packages/a7/e7/138b883f0dfe4ad5b76b58bf4ae675f4d2176ac2b24bdd81b4d966b28c61/ruff-0.14.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2f8a0bbcffcfd895df39c9a4ecd59bb80dca03dc43f7fb63e647ed176b741e", size = 13315293, upload-time = "2025-10-31T00:26:05.708Z" },
{ url = "https://files.pythonhosted.org/packages/33/f4/c09bb898be97b2eb18476b7c950df8815ef14cf956074177e9fbd40b7719/ruff-0.14.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:678fdd7c7d2d94851597c23ee6336d25f9930b460b55f8598e011b57c74fd8c5", size = 13539444, upload-time = "2025-10-31T00:26:08.09Z" },
{ url = "https://files.pythonhosted.org/packages/9c/aa/b30a1db25fc6128b1dd6ff0741fa4abf969ded161599d07ca7edd0739cc0/ruff-0.14.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1ec1ac071e7e37e0221d2f2dbaf90897a988c531a8592a6a5959f0603a1ecf5e", size = 12252581, upload-time = "2025-10-31T00:26:10.297Z" },
{ url = "https://files.pythonhosted.org/packages/da/13/21096308f384d796ffe3f2960b17054110a9c3828d223ca540c2b7cc670b/ruff-0.14.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afcdc4b5335ef440d19e7df9e8ae2ad9f749352190e96d481dc501b753f0733e", size = 12307503, upload-time = "2025-10-31T00:26:12.646Z" },
{ url = "https://files.pythonhosted.org/packages/cb/cc/a350bac23f03b7dbcde3c81b154706e80c6f16b06ff1ce28ed07dc7b07b0/ruff-0.14.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7bfc42f81862749a7136267a343990f865e71fe2f99cf8d2958f684d23ce3dfa", size = 12675457, upload-time = "2025-10-31T00:26:15.044Z" },
{ url = "https://files.pythonhosted.org/packages/cb/76/46346029fa2f2078826bc88ef7167e8c198e58fe3126636e52f77488cbba/ruff-0.14.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a65e448cfd7e9c59fae8cf37f9221585d3354febaad9a07f29158af1528e165f", size = 13403980, upload-time = "2025-10-31T00:26:17.81Z" },
{ url = "https://files.pythonhosted.org/packages/9f/a4/35f1ef68c4e7b236d4a5204e3669efdeefaef21f0ff6a456792b3d8be438/ruff-0.14.3-py3-none-win32.whl", hash = "sha256:f3d91857d023ba93e14ed2d462ab62c3428f9bbf2b4fbac50a03ca66d31991f7", size = 12500045, upload-time = "2025-10-31T00:26:20.503Z" },
{ url = "https://files.pythonhosted.org/packages/03/15/51960ae340823c9859fb60c63301d977308735403e2134e17d1d2858c7fb/ruff-0.14.3-py3-none-win_amd64.whl", hash = "sha256:d7b7006ac0756306db212fd37116cce2bd307e1e109375e1c6c106002df0ae5f", size = 13594005, upload-time = "2025-10-31T00:26:22.533Z" },
{ url = "https://files.pythonhosted.org/packages/b7/73/4de6579bac8e979fca0a77e54dec1f1e011a0d268165eb8a9bc0982a6564/ruff-0.14.3-py3-none-win_arm64.whl", hash = "sha256:26eb477ede6d399d898791d01961e16b86f02bc2486d0d1a7a9bb2379d055dc1", size = 12590017, upload-time = "2025-10-31T00:26:24.52Z" },
]
[[package]]
@@ -5651,14 +5770,15 @@ wheels = [
[[package]]
name = "starlette"
version = "0.46.2"
version = "0.49.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ 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')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" }
sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" },
{ url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" },
]
[[package]]
@@ -5919,6 +6039,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/dd/5cbf31f402f1cc0ab087c94d4669cfa55bd1e818688b910631e131d74e75/typer_slim-0.20.0-py3-none-any.whl", hash = "sha256:f42a9b7571a12b97dddf364745d29f12221865acef7a2680065f9bb29c7dc89d", size = 47087, upload-time = "2025-10-20T17:03:44.546Z" },
]
[[package]]
name = "types-requests"
version = "2.32.4.20250913"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/36/27/489922f4505975b11de2b5ad07b4fe1dca0bca9be81a703f26c5f3acfce5/types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d", size = 23113, upload-time = "2025-09-13T02:40:02.309Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658, upload-time = "2025-09-13T02:40:01.115Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"