mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d81b579111 | ||
|
|
35a8565495 | ||
|
|
0c862e97a6 | ||
|
|
bbde248839 | ||
|
|
552f7c781d | ||
|
|
2499262f30 | ||
|
|
f415959d33 |
+9
-1
@@ -203,4 +203,12 @@ agents.md
|
||||
|
||||
# AI
|
||||
.claude/
|
||||
WARP.md
|
||||
WARP.md
|
||||
|
||||
# Frontend
|
||||
**/frontend/node_modules/
|
||||
**/frontend/.vite/
|
||||
**/frontend/dist/
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
@@ -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.
|
||||
|
||||
|
||||
+24
@@ -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>
|
||||
+409
@@ -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;
|
||||
}
|
||||
}
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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$
|
||||
|
||||
Vendored
+9
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"python.analysis.extraPaths": [
|
||||
"${workspaceFolder}/packages/ag-ui/examples"
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
chatkit-python
|
||||
openai-chatkit-advanced-samples
|
||||
chatkit-js
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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"]
|
||||
@@ -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!"
|
||||
Generated
+295
-163
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user