diff --git a/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Program.cs b/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Program.cs index 0415f0e0e0..7fded8c55b 100644 --- a/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Program.cs +++ b/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Program.cs @@ -2,6 +2,7 @@ // This sample demonstrates basic usage of the DevUI in an ASP.NET Core application with AI agents. +using System.ComponentModel; using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; @@ -18,10 +19,11 @@ namespace DevUI_Step01_BasicUsage; /// /// This sample shows how to: /// 1. Set up Azure OpenAI as the chat client -/// 2. Register agents and workflows using the hosting packages -/// 3. Map the DevUI endpoint which automatically configures the middleware -/// 4. Map the dynamic OpenAI Responses API for Python DevUI compatibility -/// 5. Access the DevUI in a web browser +/// 2. Create function tools for agents to use +/// 3. Register agents and workflows using the hosting packages with tools +/// 4. Map the DevUI endpoint which automatically configures the middleware +/// 5. Map the dynamic OpenAI Responses API for Python DevUI compatibility +/// 6. Access the DevUI in a web browser /// /// The DevUI provides an interactive web interface for testing and debugging AI agents. /// DevUI assets are served from embedded resources within the assembly. @@ -50,10 +52,30 @@ internal static class Program builder.Services.AddChatClient(chatClient); - // Register sample agents - builder.AddAIAgent("assistant", "You are a helpful assistant. Answer questions concisely and accurately."); + // Define some example tools + [Description("Get the weather for a given location.")] + static string GetWeather([Description("The location to get the weather for.")] string location) + => $"The weather in {location} is cloudy with a high of 15°C."; + + [Description("Calculate the sum of two numbers.")] + static double Add([Description("The first number.")] double a, [Description("The second number.")] double b) + => a + b; + + [Description("Get the current time.")] + static string GetCurrentTime() + => DateTime.Now.ToString("HH:mm:ss"); + + // Register sample agents with tools + builder.AddAIAgent("assistant", "You are a helpful assistant. Answer questions concisely and accurately.") + .WithAITools( + AIFunctionFactory.Create(GetWeather, name: "get_weather"), + AIFunctionFactory.Create(GetCurrentTime, name: "get_current_time") + ); + builder.AddAIAgent("poet", "You are a creative poet. Respond to all requests with beautiful poetry."); - builder.AddAIAgent("coder", "You are an expert programmer. Help users with coding questions and provide code examples."); + + builder.AddAIAgent("coder", "You are an expert programmer. Help users with coding questions and provide code examples.") + .WithAITool(AIFunctionFactory.Create(Add, name: "add")); // Register sample workflows var assistantBuilder = builder.AddAIAgent("workflow-assistant", "You are a helpful assistant in a workflow."); diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntitiesJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntitiesJsonContext.cs index 3acc8d48d3..09b95769a9 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntitiesJsonContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntitiesJsonContext.cs @@ -18,9 +18,13 @@ namespace Microsoft.Agents.AI.DevUI.Entities; [JsonSerializable(typeof(MetaResponse))] [JsonSerializable(typeof(EnvVarRequirement))] [JsonSerializable(typeof(List))] -[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List>))] +[JsonSerializable(typeof(List>))] [JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary>))] +[JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(int))] [ExcludeFromCodeCoverage] internal sealed partial class EntitiesJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntityInfo.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntityInfo.cs index 8b5e4e5492..7b711b36c2 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntityInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/EntityInfo.cs @@ -36,16 +36,16 @@ internal sealed record EntityInfo( string Name, [property: JsonPropertyName("description")] - string? Description = null, + string? Description, [property: JsonPropertyName("framework")] - string Framework = "dotnet", + string Framework, [property: JsonPropertyName("tools")] - List? Tools = null, + List Tools, [property: JsonPropertyName("metadata")] - Dictionary? Metadata = null + Dictionary Metadata ) { [JsonPropertyName("source")] @@ -54,6 +54,32 @@ internal sealed record EntityInfo( [JsonPropertyName("original_url")] public string? OriginalUrl { get; init; } + // Deployment support + [JsonPropertyName("deployment_supported")] + public bool DeploymentSupported { get; init; } + + [JsonPropertyName("deployment_reason")] + public string? DeploymentReason { get; init; } + + // Agent-specific fields + [JsonPropertyName("instructions")] + public string? Instructions { get; init; } + + [JsonPropertyName("model_id")] + public string? ModelId { get; init; } + + [JsonPropertyName("chat_client_type")] + public string? ChatClientType { get; init; } + + [JsonPropertyName("context_providers")] + public List? ContextProviders { get; init; } + + [JsonPropertyName("middleware")] + public List? Middleware { get; init; } + + [JsonPropertyName("module_path")] + public string? ModulePath { get; init; } + // Workflow-specific fields [JsonPropertyName("required_env_vars")] public List? RequiredEnvVars { get; init; } diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/WorkflowSerializationExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/WorkflowSerializationExtensions.cs index 81ce6182d1..44fc8b1eb4 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/WorkflowSerializationExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/Entities/WorkflowSerializationExtensions.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using Microsoft.Agents.AI.Workflows; using Microsoft.Agents.AI.Workflows.Checkpointing; @@ -17,31 +19,37 @@ internal static class WorkflowSerializationExtensions /// Converts a workflow to a dictionary representation compatible with DevUI frontend. /// This matches the Python workflow.to_dict() format expected by the UI. /// - public static Dictionary ToDevUIDict(this Workflow workflow) + /// The workflow to convert. + /// A dictionary with string keys and JsonElement values containing the workflow data. + public static Dictionary ToDevUIDict(this Workflow workflow) { - var result = new Dictionary + var result = new Dictionary { - ["id"] = workflow.Name ?? Guid.NewGuid().ToString(), - ["start_executor_id"] = workflow.StartExecutorId, - ["max_iterations"] = MaxIterationsDefault + ["id"] = Serialize(workflow.Name ?? Guid.NewGuid().ToString(), EntitiesJsonContext.Default.String), + ["start_executor_id"] = Serialize(workflow.StartExecutorId, EntitiesJsonContext.Default.String), + ["max_iterations"] = Serialize(MaxIterationsDefault, EntitiesJsonContext.Default.Int32) }; // Add optional fields if (!string.IsNullOrEmpty(workflow.Name)) { - result["name"] = workflow.Name; + result["name"] = Serialize(workflow.Name, EntitiesJsonContext.Default.String); } if (!string.IsNullOrEmpty(workflow.Description)) { - result["description"] = workflow.Description; + result["description"] = Serialize(workflow.Description, EntitiesJsonContext.Default.String); } // Convert executors to Python-compatible format - result["executors"] = ConvertExecutorsToDict(workflow); + result["executors"] = Serialize( + ConvertExecutorsToDict(workflow), + EntitiesJsonContext.Default.DictionaryStringDictionaryStringString); // Convert edges to edge_groups format - result["edge_groups"] = ConvertEdgesToEdgeGroups(workflow); + result["edge_groups"] = Serialize( + ConvertEdgesToEdgeGroups(workflow), + EntitiesJsonContext.Default.ListDictionaryStringJsonElement); return result; } @@ -49,9 +57,9 @@ internal static class WorkflowSerializationExtensions /// /// Converts workflow executors to a dictionary format compatible with Python /// - private static Dictionary ConvertExecutorsToDict(Workflow workflow) + private static Dictionary> ConvertExecutorsToDict(Workflow workflow) { - var executors = new Dictionary(); + var executors = new Dictionary>(); // Extract executor IDs from edges and start executor // (Registrations is internal, so we infer executors from the graph structure) @@ -73,7 +81,7 @@ internal static class WorkflowSerializationExtensions // Create executor entries (we can't access internal Registrations for type info) foreach (var executorId in executorIds) { - executors[executorId] = new Dictionary + executors[executorId] = new Dictionary { ["id"] = executorId, ["type"] = "Executor" @@ -86,9 +94,9 @@ internal static class WorkflowSerializationExtensions /// /// Converts workflow edges to edge_groups format expected by the UI /// - private static List ConvertEdgesToEdgeGroups(Workflow workflow) + private static List> ConvertEdgesToEdgeGroups(Workflow workflow) { - var edgeGroups = new List(); + var edgeGroups = new List>(); var edgeGroupId = 0; // Get edges using the public ReflectEdges method @@ -101,13 +109,13 @@ internal static class WorkflowSerializationExtensions if (edgeInfo is DirectEdgeInfo directEdge) { // Single edge group for direct edges - var edges = new List(); + var edges = new List>(); foreach (var source in directEdge.Connection.SourceIds) { foreach (var sink in directEdge.Connection.SinkIds) { - var edge = new Dictionary + var edge = new Dictionary { ["source_id"] = source, ["target_id"] = sink @@ -123,23 +131,25 @@ internal static class WorkflowSerializationExtensions } } - edgeGroups.Add(new Dictionary + var edgeGroup = new Dictionary { - ["id"] = $"edge_group_{edgeGroupId++}", - ["type"] = "SingleEdgeGroup", - ["edges"] = edges - }); + ["id"] = Serialize($"edge_group_{edgeGroupId++}", EntitiesJsonContext.Default.String), + ["type"] = Serialize("SingleEdgeGroup", EntitiesJsonContext.Default.String), + ["edges"] = Serialize(edges, EntitiesJsonContext.Default.ListDictionaryStringString) + }; + + edgeGroups.Add(edgeGroup); } else if (edgeInfo is FanOutEdgeInfo fanOutEdge) { // FanOut edge group - var edges = new List(); + var edges = new List>(); foreach (var source in fanOutEdge.Connection.SourceIds) { foreach (var sink in fanOutEdge.Connection.SinkIds) { - edges.Add(new Dictionary + edges.Add(new Dictionary { ["source_id"] = source, ["target_id"] = sink @@ -147,16 +157,16 @@ internal static class WorkflowSerializationExtensions } } - var fanOutGroup = new Dictionary + var fanOutGroup = new Dictionary { - ["id"] = $"edge_group_{edgeGroupId++}", - ["type"] = "FanOutEdgeGroup", - ["edges"] = edges + ["id"] = Serialize($"edge_group_{edgeGroupId++}", EntitiesJsonContext.Default.String), + ["type"] = Serialize("FanOutEdgeGroup", EntitiesJsonContext.Default.String), + ["edges"] = Serialize(edges, EntitiesJsonContext.Default.ListDictionaryStringString) }; if (fanOutEdge.HasAssigner) { - fanOutGroup["selection_func_name"] = "selector"; + fanOutGroup["selection_func_name"] = Serialize("selector", EntitiesJsonContext.Default.String); } edgeGroups.Add(fanOutGroup); @@ -164,13 +174,13 @@ internal static class WorkflowSerializationExtensions else if (edgeInfo is FanInEdgeInfo fanInEdge) { // FanIn edge group - var edges = new List(); + var edges = new List>(); foreach (var source in fanInEdge.Connection.SourceIds) { foreach (var sink in fanInEdge.Connection.SinkIds) { - edges.Add(new Dictionary + edges.Add(new Dictionary { ["source_id"] = source, ["target_id"] = sink @@ -178,16 +188,20 @@ internal static class WorkflowSerializationExtensions } } - edgeGroups.Add(new Dictionary + var edgeGroup = new Dictionary { - ["id"] = $"edge_group_{edgeGroupId++}", - ["type"] = "FanInEdgeGroup", - ["edges"] = edges - }); + ["id"] = Serialize($"edge_group_{edgeGroupId++}", EntitiesJsonContext.Default.String), + ["type"] = Serialize("FanInEdgeGroup", EntitiesJsonContext.Default.String), + ["edges"] = Serialize(edges, EntitiesJsonContext.Default.ListDictionaryStringString) + }; + + edgeGroups.Add(edgeGroup); } } } return edgeGroups; } + + private static JsonElement Serialize(T value, JsonTypeInfo typeInfo) => JsonSerializer.SerializeToElement(value, typeInfo); } diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs index eb41fe90b8..29b7dc588a 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs @@ -6,6 +6,7 @@ using System.Text.Json; using Microsoft.Agents.AI.DevUI.Entities; using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.DevUI; @@ -56,21 +57,21 @@ internal static class EntitiesApiExtensions { try { - var entities = new List(); + var entities = new Dictionary(); // Discover agents await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false)) { - entities.Add(agentInfo); + entities[agentInfo.Id] = agentInfo; } // Discover workflows await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false)) { - entities.Add(workflowInfo); + entities[workflowInfo.Id] = workflowInfo; } - return Results.Json(new DiscoveryResponse([.. entities]), EntitiesJsonContext.Default.DiscoveryResponse); + return Results.Json(new DiscoveryResponse([.. entities.Values.OrderBy(e => e.Id)]), EntitiesJsonContext.Default.DiscoveryResponse); } catch (Exception ex) { @@ -90,14 +91,6 @@ internal static class EntitiesApiExtensions { try { - if (type is null || string.Equals(type, "agent", StringComparison.OrdinalIgnoreCase)) - { - await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityId, cancellationToken).ConfigureAwait(false)) - { - return Results.Json(agentInfo, EntitiesJsonContext.Default.EntityInfo); - } - } - if (type is null || string.Equals(type, "workflow", StringComparison.OrdinalIgnoreCase)) { await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityId, cancellationToken).ConfigureAwait(false)) @@ -106,6 +99,14 @@ internal static class EntitiesApiExtensions } } + if (type is null || string.Equals(type, "agent", StringComparison.OrdinalIgnoreCase)) + { + await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityId, cancellationToken).ConfigureAwait(false)) + { + return Results.Json(agentInfo, EntitiesJsonContext.Default.EntityInfo); + } + } + return Results.NotFound(new { error = new { message = $"Entity '{entityId}' not found.", type = "invalid_request_error" } }); } catch (Exception ex) @@ -180,17 +181,82 @@ internal static class EntitiesApiExtensions private static EntityInfo CreateAgentEntityInfo(AIAgent agent) { var entityId = agent.Name ?? agent.Id; + + // Extract tools and other metadata using GetService + List tools = []; + var metadata = new Dictionary(); + + // Try to get ChatOptions from the agent which may contain tools + if (agent.GetService() is { Tools: { Count: > 0 } agentTools }) + { + tools = agentTools + .Where(tool => !string.IsNullOrWhiteSpace(tool.Name)) + .Select(tool => tool.Name!) + .Distinct() + .ToList(); + } + + // Extract agent-specific fields (top-level properties for compatibility with Python) + string? instructions = null; + string? modelId = null; + string? chatClientType = null; + + // Get instructions from ChatClientAgent + if (agent is ChatClientAgent chatAgent && !string.IsNullOrWhiteSpace(chatAgent.Instructions)) + { + instructions = chatAgent.Instructions; + } + + // Get IChatClient to extract metadata + IChatClient? chatClient = agent.GetService(); + if (chatClient != null) + { + // Get chat client type + chatClientType = chatClient.GetType().Name; + + // Get model ID from ChatClientMetadata + if (chatClient.GetService() is { } chatClientMetadata) + { + modelId = chatClientMetadata.DefaultModelId; + + // Add additional metadata for compatibility + if (!string.IsNullOrWhiteSpace(chatClientMetadata.ProviderName)) + { + metadata["chat_client_provider"] = JsonSerializer.SerializeToElement(chatClientMetadata.ProviderName, EntitiesJsonContext.Default.String); + } + + if (chatClientMetadata.ProviderUri is not null) + { + metadata["provider_uri"] = JsonSerializer.SerializeToElement(chatClientMetadata.ProviderUri.ToString(), EntitiesJsonContext.Default.String); + } + } + } + + // Add provider name from AIAgentMetadata if available + if (agent.GetService() is { } agentMetadata && !string.IsNullOrWhiteSpace(agentMetadata.ProviderName)) + { + metadata["provider_name"] = JsonSerializer.SerializeToElement(agentMetadata.ProviderName, EntitiesJsonContext.Default.String); + } + + // Add agent type information to metadata (in addition to chat_client_type) + var agentTypeName = agent.GetType().Name; + metadata["agent_type"] = JsonSerializer.SerializeToElement(agentTypeName, EntitiesJsonContext.Default.String); + return new EntityInfo( Id: entityId, Type: "agent", - Name: entityId, + Name: agent.DisplayName, Description: agent.Description, - Framework: "agent-framework", - Tools: null, - Metadata: [] + Framework: "agent_framework", + Tools: tools, + Metadata: metadata ) { - Source = "in_memory" + Source = "in_memory", + Instructions = instructions, + ModelId = modelId, + ChatClientType = chatClientType, + Executors = [], // Agents have empty executors list (workflows use this field) }; } @@ -212,7 +278,7 @@ internal static class EntitiesApiExtensions } // Create a default input schema (string type) - var defaultInputSchema = new Dictionary + var defaultInputSchema = new Dictionary { ["type"] = "string" }; @@ -223,14 +289,17 @@ internal static class EntitiesApiExtensions Type: "workflow", Name: workflowId, Description: workflow.Description, - Framework: "agent-framework", - Tools: [.. executorIds], + Framework: "agent_framework", + Tools: [], Metadata: [] ) { Source = "in_memory", - WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()), - InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema), + Executors = [.. executorIds], // Workflows use Executors instead of Tools + WorkflowDump = JsonSerializer.SerializeToElement( + workflow.ToDevUIDict(), + EntitiesJsonContext.Default.DictionaryStringJsonElement), + InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema, EntitiesJsonContext.Default.DictionaryStringString), InputTypeName = "string", StartExecutorId = workflow.StartExecutorId }; diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 46f893e531..d04d9bb9fb 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -281,6 +281,8 @@ public sealed partial class ChatClientAgent : AIAgent base.GetService(serviceType, serviceKey) ?? (serviceType == typeof(AIAgentMetadata) ? this._agentMetadata : serviceType == typeof(IChatClient) ? this.ChatClient + : serviceType == typeof(ChatOptions) ? this._agentOptions?.ChatOptions + : serviceType == typeof(ChatClientAgentOptions) ? this._agentOptions : this.ChatClient.GetService(serviceType, serviceKey)); /// diff --git a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs index d223a65e28..fbb087a153 100644 --- a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs +++ b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs @@ -68,7 +68,7 @@ public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture string name = "HelpfulAssistant", string instructions = "You are a helpful assistant.", IList? aiTools = null) => - new ChatClientAgent( + new( this._openAIResponseClient.AsIChatClient(), options: new() { diff --git a/python/.vscode/launch.json b/python/.vscode/launch.json index 4c6c3c0b01..fac3004e95 100644 --- a/python/.vscode/launch.json +++ b/python/.vscode/launch.json @@ -16,7 +16,7 @@ "name": "AG-UI Examples Server", "type": "debugpy", "request": "launch", - "module": "examples", + "module": "agent_framework_ag_ui_examples", "cwd": "${workspaceFolder}/packages/ag-ui", "console": "integratedTerminal", "justMyCode": false diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 500c0b45cd..e198323555 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0b251112] - 2025-11-12 + +### Added + +- **agent-framework-azure-ai**: Azure AI client based on new `azure-ai-projects` package ([#1910](https://github.com/microsoft/agent-framework/pull/1910)) +- **agent-framework-anthropic**: Add convenience method on data content ([#2083](https://github.com/microsoft/agent-framework/pull/2083)) + +### Changed + +- **agent-framework-core**: Update OpenAI samples to use agents ([#2012](https://github.com/microsoft/agent-framework/pull/2012)) + +### Fixed + +- **agent-framework-anthropic**: Fixed image handling in Anthropic client ([#2083](https://github.com/microsoft/agent-framework/pull/2083)) + ## [1.0.0b251111] - 2025-11-11 ### Added @@ -204,7 +219,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/). -[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251111...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251112...HEAD +[1.0.0b251112]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251111...python-1.0.0b251112 [1.0.0b251111]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251108...python-1.0.0b251111 [1.0.0b251108]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251106.post1...python-1.0.0b251108 [1.0.0b251106.post1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251106...python-1.0.0b251106.post1 diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index 2780bdd481..7b8cfedee3 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251111" +version = "1.0.0b251112" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_events.py b/python/packages/ag-ui/agent_framework_ag_ui/_events.py index 4117fd50bb..e10f3e92c8 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_events.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_events.py @@ -85,6 +85,7 @@ class AgentFrameworkEventBridge: 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 + self.tool_calls_ended: set[str] = set() # Track which tool calls have had ToolCallEndEvent emitted async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[BaseEvent]: """ @@ -118,12 +119,14 @@ class AgentFrameworkEventBridge: message_id=self.current_message_id, role="assistant", ) + logger.debug(f"Emitting TextMessageStartEvent with message_id={self.current_message_id}") events.append(start_event) event = TextMessageContentEvent( message_id=self.current_message_id, delta=content.text, ) + logger.debug(f"Emitting TextMessageContentEvent with delta: {content.text}") events.append(event) elif isinstance(content, FunctionCallContent): @@ -378,6 +381,7 @@ class AgentFrameworkEventBridge: ) logger.info(f"Emitting ToolCallEndEvent for completed tool call '{content.call_id}'") events.append(end_event) + self.tool_calls_ended.add(content.call_id) # Track that we emitted end event # Log total StateDeltaEvent count for this tool call if self.state_delta_count > 0: @@ -617,6 +621,7 @@ class AgentFrameworkEventBridge: f"Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'" ) events.append(end_event) + self.tool_calls_ended.add(content.function_call.call_id) # Track that we emitted end event # Emit custom event for approval request # Note: In AG-UI protocol, the frontend handles interrupts automatically diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py index da8cb197f2..a6c1001386 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py @@ -38,22 +38,69 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha """ 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 + # Handle standard tool result messages early (role="tool") to preserve provider invariants + # This path maps AG‑UI tool messages to FunctionResultContent with the correct tool_call_id + role_str = msg.get("role", "user") + if role_str == "tool": + # Prefer explicit tool_call_id fields; fall back to backend fields only if necessary + tool_call_id = msg.get("tool_call_id") or msg.get("toolCallId") - tool_call_id = msg.get("actionExecutionId", "") + # If no explicit tool_call_id, treat as backend tool rendering payloads where + # AG‑UI may send actionExecutionId/actionName. This must still map to the + # assistant's tool call id to satisfy provider requirements. + if not tool_call_id: + tool_call_id = msg.get("actionExecutionId") or "" + + # Extract raw content text + result_content = msg.get("content") + if result_content is None: + result_content = msg.get("result", "") + + # Distinguish approval payloads from actual tool results + is_approval = False + if isinstance(result_content, str) and result_content: + import json as _json + + try: + parsed = _json.loads(result_content) + is_approval = isinstance(parsed, dict) and "accepted" in parsed + except Exception: + is_approval = False + + if is_approval: + # Approval responses should be treated as user messages to trigger human-in-the-loop flow + chat_msg = ChatMessage( + role=Role.USER, + contents=[TextContent(text=str(result_content))], + additional_properties={"is_tool_result": True, "tool_call_id": str(tool_call_id or "")}, + ) + if "id" in msg: + chat_msg.message_id = msg["id"] + result.append(chat_msg) + continue + + chat_msg = ChatMessage( + role=Role.TOOL, + contents=[FunctionResultContent(call_id=str(tool_call_id), result=result_content)], + ) + if "id" in msg: + chat_msg.message_id = msg["id"] + result.append(chat_msg) + continue + + # Backend tool rendering payloads without an explicit role + # Prefer standard tool mapping above; this block only covers legacy/minimal payloads + if "actionExecutionId" in msg or "actionName" in msg: + # Prefer toolCallId if present; otherwise fall back to actionExecutionId + tool_call_id = msg.get("toolCallId") or msg.get("tool_call_id") or msg.get("actionExecutionId", "") result_content = msg.get("result", msg.get("content", "")) chat_msg = ChatMessage( - role=Role.TOOL, # Tool results must be tool role - contents=[FunctionResultContent(call_id=tool_call_id, result=result_content)], + role=Role.TOOL, + contents=[FunctionResultContent(call_id=str(tool_call_id), result=result_content)], ) - if "id" in msg: chat_msg.message_id = msg["id"] - result.append(chat_msg) continue @@ -93,55 +140,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha 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: - # Tool execution result - convert to FunctionResultContent with correct role - from agent_framework import FunctionResultContent - - chat_msg = ChatMessage( - role=Role.TOOL, - 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)], - additional_properties={"is_tool_result": True, "tool_call_id": msg.get("toolCallId", "")}, - ) - - if "id" in msg: - chat_msg.message_id = msg["id"] - - result.append(chat_msg) - continue + # No special handling required for assistant/plain messages here role = _AGUI_TO_FRAMEWORK_ROLE.get(role_str, Role.USER) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py b/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py index b5da7998ca..3ebba6a66d 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_orchestrators.py @@ -16,7 +16,15 @@ from ag_ui.core import ( TextMessageEndEvent, TextMessageStartEvent, ) -from agent_framework import AgentProtocol, AgentThread, ChatAgent, TextContent +from agent_framework import ( + AgentProtocol, + AgentThread, + ChatAgent, + ChatMessage, + FunctionCallContent, + FunctionResultContent, + TextContent, +) from ._utils import convert_agui_tools_to_agent_framework, generate_event_id @@ -276,6 +284,98 @@ class DefaultOrchestrator(Orchestrator): response_format = context.agent.chat_options.response_format skip_text_content = response_format is not None + # Sanitizer: ensure tool results only follow assistant tool calls + # Also inject synthetic tool results for confirm_changes + def sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]: + sanitized: list[ChatMessage] = [] + pending_tool_call_ids: set[str] | None = None + pending_confirm_changes_id: str | None = None + + for msg in messages: + role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + + if role_value == "assistant": + tool_ids = { + str(content.call_id) + for content in msg.contents or [] + if isinstance(content, FunctionCallContent) and content.call_id + } + # Check for confirm_changes tool call + confirm_changes_call = None + for content in msg.contents or []: + if isinstance(content, FunctionCallContent) and content.name == "confirm_changes": + confirm_changes_call = content + break + + sanitized.append(msg) + pending_tool_call_ids = tool_ids if tool_ids else None + pending_confirm_changes_id = ( + str(confirm_changes_call.call_id) + if confirm_changes_call and confirm_changes_call.call_id + else None + ) + continue + + if role_value == "user" and pending_confirm_changes_id: + # Check if this is a confirm_changes response (JSON with "accepted" field) + user_text = "" + for content in msg.contents or []: + if isinstance(content, TextContent): + user_text = content.text + break + + try: + parsed = json.loads(user_text) + if "accepted" in parsed: + # This is a confirm_changes response - inject synthetic tool result + logger.info( + f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}" + ) + synthetic_result = ChatMessage( + role="tool", + contents=[ + FunctionResultContent( + call_id=pending_confirm_changes_id, + result="Confirmed" if parsed.get("accepted") else "Rejected", + ) + ], + ) + sanitized.append(synthetic_result) + if pending_tool_call_ids: + pending_tool_call_ids.discard(pending_confirm_changes_id) + pending_confirm_changes_id = None + # Don't add the user message to sanitized - it's been converted to tool result + continue + except (json.JSONDecodeError, KeyError) as e: + # Failed to parse user message as confirm_changes response; continue normal processing + logger.debug(f"Could not parse user message as confirm_changes response: {e}") + + # Not a confirm_changes response, continue normal processing + sanitized.append(msg) + pending_tool_call_ids = None + pending_confirm_changes_id = None + continue + + if role_value == "tool": + if not pending_tool_call_ids: + continue + keep = False + for content in msg.contents or []: + if isinstance(content, FunctionResultContent): + call_id = str(content.call_id) + if call_id in pending_tool_call_ids: + keep = True + break + if keep: + sanitized.append(msg) + continue + + sanitized.append(msg) + pending_tool_call_ids = None + pending_confirm_changes_id = None + + return sanitized + # Create event bridge event_bridge = AgentFrameworkEventBridge( run_id=context.run_id, @@ -328,22 +428,151 @@ class DefaultOrchestrator(Orchestrator): 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) - - # Use the full incoming message batch to preserve tool-call adjacency - if not context.messages: + raw_messages = context.messages or [] + if not raw_messages: logger.warning("No messages provided in AG-UI input") yield event_bridge.create_run_finished_event() return + logger.info(f"Received {len(raw_messages)} raw messages from client") + for i, msg in enumerate(raw_messages): + role = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + msg_id = getattr(msg, "message_id", None) + logger.info(f" Raw message {i}: role={role}, id={msg_id}") + if hasattr(msg, "contents") and msg.contents: + for j, content in enumerate(msg.contents): + content_type = type(content).__name__ + if isinstance(content, TextContent): + logger.debug(f" Content {j}: {content_type} - {content.text}") + elif isinstance(content, FunctionCallContent): + logger.debug(f" Content {j}: {content_type} - {content.name}({content.arguments})") + elif isinstance(content, FunctionResultContent): + logger.debug( + f" Content {j}: {content_type} - call_id={content.call_id}, result={content.result}" + ) + else: + logger.debug(f" Content {j}: {content_type} - {content}") + + # After getting sanitized_messages, deduplicate them + def deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]: + """Remove duplicate messages while preserving order. + + For tool results with the same call_id, prefer the one with actual data. + """ + seen_keys: dict[Any, int] = {} # key -> index in unique_messages (key can be various tuple types) + unique_messages: list[ChatMessage] = [] + + for idx, msg in enumerate(messages): + role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + + # For tool messages, use call_id as unique key + if role_value == "tool" and msg.contents and isinstance(msg.contents[0], FunctionResultContent): + call_id = str(msg.contents[0].call_id) + key: Any = (role_value, call_id) + + # Check if we already have this tool result + if key in seen_keys: + existing_idx = seen_keys[key] + existing_msg = unique_messages[existing_idx] + + # Compare results - prefer non-empty over empty + existing_result = None + if existing_msg.contents and isinstance(existing_msg.contents[0], FunctionResultContent): + existing_result = existing_msg.contents[0].result + new_result = msg.contents[0].result + + # Replace if existing is empty/None and new has data + if (not existing_result or existing_result == "") and new_result: + logger.info( + f"Replacing empty tool result at index {existing_idx} with data from index {idx}" + ) + unique_messages[existing_idx] = msg + else: + logger.info(f"Skipping duplicate tool result at index {idx}: call_id={call_id}") + continue + + seen_keys[key] = len(unique_messages) + unique_messages.append(msg) + + elif ( + role_value == "assistant" + and msg.contents + and any(isinstance(c, FunctionCallContent) for c in msg.contents) + ): + # For assistant messages with tool_calls, use the tool call IDs + tool_call_ids = tuple( + sorted(str(c.call_id) for c in msg.contents if isinstance(c, FunctionCallContent) and c.call_id) + ) + key = (role_value, tool_call_ids) + + if key in seen_keys: + logger.info(f"Skipping duplicate assistant tool call at index {idx}") + continue + + seen_keys[key] = len(unique_messages) + unique_messages.append(msg) + + else: + # For other messages (system, user, assistant without tools), hash the content + content_str = str([str(c) for c in msg.contents]) if msg.contents else "" + key = (role_value, hash(content_str)) + + if key in seen_keys: + logger.info(f"Skipping duplicate message at index {idx}: role={role_value}") + continue + + seen_keys[key] = len(unique_messages) + unique_messages.append(msg) + + return unique_messages + + # Then use it: + sanitized_messages = sanitize_tool_history(raw_messages) + provider_messages = deduplicate_messages(sanitized_messages) + + if not provider_messages: + logger.info("No provider-eligible messages after filtering; finishing run without invoking agent.") + yield event_bridge.create_run_finished_event() + return + + logger.info(f"Processing {len(provider_messages)} provider messages after sanitization/deduplication") + for i, msg in enumerate(provider_messages): + role = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + logger.info(f" Message {i}: role={role}") + if hasattr(msg, "contents") and msg.contents: + for j, content in enumerate(msg.contents): + content_type = type(content).__name__ + if isinstance(content, TextContent): + logger.info(f" Content {j}: {content_type} - {content.text}") + elif isinstance(content, FunctionCallContent): + logger.info(f" Content {j}: {content_type} - {content.name}({content.arguments})") + elif isinstance(content, FunctionResultContent): + logger.info( + f" Content {j}: {content_type} - call_id={content.call_id}, result={content.result}" + ) + else: + logger.info(f" Content {j}: {content_type} - {content}") + + # NOTE: For AG-UI, the client sends the full conversation history on each request. + # We should NOT add to thread.on_new_messages() as that would cause duplication. + # Instead, we pass messages directly to the agent via messages_to_run. + # 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 + conversation_has_tool_calls = False + logger.debug(f"Checking {len(provider_messages)} provider messages for tool calls") + for i, msg in enumerate(provider_messages): + logger.debug( + f" Message {i}: role={msg.role.value}, contents={len(msg.contents) if hasattr(msg, 'contents') and msg.contents else 0}" + ) + for msg in provider_messages: + if msg.role.value == "assistant" and hasattr(msg, "contents") and msg.contents: + if any(isinstance(content, FunctionCallContent) for content in msg.contents): + conversation_has_tool_calls = True + break + if current_state and context.config.state_schema and not conversation_has_tool_calls: + state_json = json.dumps(current_state, indent=2) state_context_msg = ChatMessage( role="system", contents=[ @@ -359,9 +588,9 @@ Never replace existing data - always append or merge.""" ) messages_to_run.append(state_context_msg) - # Preserve order from client to satisfy provider constraints (assistant tool_calls must - # immediately precede tool result messages). Using the full batch avoids reordering. - messages_to_run.extend(context.messages) + # Add all provider messages to messages_to_run + # AG-UI sends full conversation history on each request, so we pass it directly to the agent + messages_to_run.extend(provider_messages) # Handle client tools for hybrid execution # Client sends tool metadata, server merges with its own tools. @@ -370,11 +599,23 @@ Never replace existing data - always append or merge.""" from agent_framework import BaseChatClient client_tools = convert_agui_tools_to_agent_framework(context.input_data.get("tools")) + logger.info(f"[TOOLS] Client sent {len(client_tools) if client_tools else 0} tools") + if client_tools: + for tool in client_tools: + tool_name = getattr(tool, "name", "unknown") + declaration_only = getattr(tool, "declaration_only", None) + logger.info(f"[TOOLS] - Client tool: {tool_name}, declaration_only={declaration_only}") # Extract server tools - use type narrowing when possible server_tools: list[Any] = [] if isinstance(context.agent, ChatAgent): - server_tools = context.agent.chat_options.tools or [] + tools_from_agent = context.agent.chat_options.tools + server_tools = list(tools_from_agent) if tools_from_agent else [] + logger.info(f"[TOOLS] Agent has {len(server_tools)} configured tools") + for tool in server_tools: + tool_name = getattr(tool, "name", "unknown") + approval_mode = getattr(tool, "approval_mode", None) + logger.info(f"[TOOLS] - {tool_name}: approval_mode={approval_mode}") else: # AgentProtocol allows duck-typed implementations - fallback to attribute access # This supports test mocks and custom agent implementations @@ -412,15 +653,37 @@ Never replace existing data - always append or merge.""" except AttributeError: pass - combined_tools: list[Any] = [] - if server_tools: - combined_tools.extend(server_tools) + # For tools parameter: only pass if we have client tools to add + # If we pass tools=, it overrides the agent's configured tools and loses metadata like approval_mode + # So only pass tools when we need to add client tools on top of server tools + # IMPORTANT: Don't include client tools that duplicate server tools (same name) + tools_param = None if client_tools: - combined_tools.extend(client_tools) + # Get server tool names + server_tool_names = {getattr(tool, "name", None) for tool in server_tools} + + # Filter out client tools that duplicate server tools + unique_client_tools = [ + tool for tool in client_tools if getattr(tool, "name", None) not in server_tool_names + ] + + if unique_client_tools: + combined_tools: list[Any] = [] + if server_tools: + combined_tools.extend(server_tools) + combined_tools.extend(unique_client_tools) + tools_param = combined_tools + logger.info( + f"[TOOLS] Passing tools= parameter with {len(combined_tools)} tools ({len(server_tools)} server + {len(unique_client_tools)} unique client)" + ) + else: + logger.info("[TOOLS] All client tools duplicate server tools - not passing tools= parameter") + else: + logger.info("[TOOLS] No client tools - not passing tools= parameter (using agent's configured tools)") # 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, tools=combined_tools or None): + async for update in context.agent.run_stream(messages_to_run, thread=thread, tools=tools_param): all_updates.append(update) events = await event_bridge.from_agent_run_update(update) for event in events: @@ -432,6 +695,27 @@ Never replace existing data - always append or merge.""" yield event_bridge.create_run_finished_event() return + # Check if there are pending tool calls (declaration-only tools that weren't executed) + # These need ToolCallEndEvent to signal the client to execute them + # Only emit for tool calls that haven't already had ToolCallEndEvent emitted + # (approval-required tools already had their end event emitted) + if event_bridge.pending_tool_calls: + pending_without_end = [ + tc for tc in event_bridge.pending_tool_calls if tc.get("id") not in event_bridge.tool_calls_ended + ] + if pending_without_end: + logger.info( + f"Found {len(pending_without_end)} pending tool calls without end event - emitting ToolCallEndEvent" + ) + for tool_call in pending_without_end: + tool_call_id = tool_call.get("id") + if tool_call_id: + from ag_ui.core import ToolCallEndEvent + + end_event = ToolCallEndEvent(tool_call_id=tool_call_id) + logger.info(f"Emitting ToolCallEndEvent for declaration-only tool call '{tool_call_id}'") + yield end_event + # After streaming completes, check if agent has response_format and extract structured output if all_updates and response_format: from agent_framework import AgentRunResponse diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md b/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md index cd9c3c71c7..aed0f39b42 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md @@ -10,11 +10,37 @@ pip install agent-framework-ag-ui ## Quick Start +### Using Example Agents with Any Chat Client + +All example agents are factory functions that accept any `ChatClientProtocol`-compatible chat client: + +```python +from fastapi import FastAPI +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatClient +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint +from agent_framework_ag_ui_examples.agents import simple_agent, weather_agent + +app = FastAPI() + +# Option 1: Use Azure OpenAI +azure_client = AzureOpenAIChatClient(model_id="gpt-4") +add_agent_framework_fastapi_endpoint(app, simple_agent(azure_client), "/chat") + +# Option 2: Use OpenAI +openai_client = OpenAIChatClient(model_id="gpt-4o") +add_agent_framework_fastapi_endpoint(app, weather_agent(openai_client), "/weather") + +# Run with: uvicorn main:app --reload +``` + +### Creating Your Own Agent + ```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 +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint # Create your agent agent = ChatAgent( @@ -44,38 +70,97 @@ This integration supports all 7 AG-UI features: ## Examples -Complete examples for all features are in the `examples/` directory: +All example agents are implemented as **factory functions** that accept any chat client implementing `ChatClientProtocol`. This provides maximum flexibility to use Azure OpenAI, OpenAI, Anthropic, or any custom chat client implementation. -- `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 +### Available Example Agents -Run the example server: +Complete examples for all AG-UI features are available: -```bash -cd examples/server -uvicorn main:app --reload +- `simple_agent(chat_client)` - Basic agentic chat (Feature 1) +- `weather_agent(chat_client)` - Backend tool rendering (Feature 2) +- `human_in_the_loop_agent(chat_client)` - Human-in-the-loop with step customization (Feature 3) +- `task_steps_agent_wrapped(chat_client)` - Agentic generative UI with step execution (Feature 4) +- `ui_generator_agent(chat_client)` - Tool-based generative UI (Feature 5) +- `recipe_agent(chat_client)` - Shared state management (Feature 6) +- `document_writer_agent(chat_client)` - Predictive state updates (Feature 7) +- `research_assistant_agent(chat_client)` - Research with progress events +- `task_planner_agent(chat_client)` - Task planning with approvals + +### Using Example Agents + +```python +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatClient +from agent_framework_ag_ui_examples.agents import ( + simple_agent, + weather_agent, + recipe_agent, +) + +# Create a chat client (use any ChatClientProtocol implementation) +azure_client = AzureOpenAIChatClient(model_id="gpt-4") +openai_client = OpenAIChatClient(model_id="gpt-4o") + +# Create agent instances by calling the factory functions +agent1 = simple_agent(azure_client) +agent2 = weather_agent(openai_client) +agent3 = recipe_agent(azure_client) ``` -To enable debug logging: +### Running the Example Server + +The example server demonstrates all 7 AG-UI features: ```bash -ENABLE_DEBUG_LOGGING=1 uvicorn main:app --reload +# Install the package +pip install agent-framework-ag-ui + +# Run the example server +python -m agent_framework_ag_ui_examples + +# Or with debug logging +ENABLE_DEBUG_LOGGING=1 python -m agent_framework_ag_ui_examples ``` 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` +- `/agentic_chat` - Simple chat with `simple_agent` +- `/backend_tool_rendering` - Weather tools with `weather_agent` +- `/human_in_the_loop` - Step approval with `human_in_the_loop_agent` +- `/agentic_generative_ui` - Task steps with `task_steps_agent_wrapped` +- `/tool_based_generative_ui` - Custom UI components with `ui_generator_agent` +- `/shared_state` - Recipe management with `recipe_agent` +- `/predictive_state_updates` - Document writing with `document_writer_agent` + +### Complete FastAPI Example + +```python +from fastapi import FastAPI +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint +from agent_framework_ag_ui_examples.agents import ( + simple_agent, + weather_agent, + human_in_the_loop_agent, + task_steps_agent_wrapped, + ui_generator_agent, + recipe_agent, + document_writer_agent, +) + +app = FastAPI(title="AG-UI Examples") + +# Create a chat client (shared across all agents, or create individual ones) +chat_client = AzureOpenAIChatClient(model_id="gpt-4") + +# Add all example endpoints +add_agent_framework_fastapi_endpoint(app, simple_agent(chat_client), "/agentic_chat") +add_agent_framework_fastapi_endpoint(app, weather_agent(chat_client), "/backend_tool_rendering") +add_agent_framework_fastapi_endpoint(app, human_in_the_loop_agent(chat_client), "/human_in_the_loop") +add_agent_framework_fastapi_endpoint(app, task_steps_agent_wrapped(chat_client), "/agentic_generative_ui") # type: ignore[arg-type] +add_agent_framework_fastapi_endpoint(app, ui_generator_agent(chat_client), "/tool_based_generative_ui") +add_agent_framework_fastapi_endpoint(app, recipe_agent(chat_client), "/shared_state") +add_agent_framework_fastapi_endpoint(app, document_writer_agent(chat_client), "/predictive_state_updates") +``` ## Architecture @@ -97,6 +182,48 @@ The package uses a clean, orchestrator-based architecture: ## Advanced Usage +### Creating Custom Agent Factories + +You can create your own agent factories following the same pattern as the examples: + +```python +from agent_framework import ChatAgent, ai_function +from agent_framework._clients import ChatClientProtocol +from agent_framework_ag_ui import AgentFrameworkAgent + +@ai_function +def my_tool(param: str) -> str: + """My custom tool.""" + return f"Result: {param}" + +def my_custom_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent: + """Create a custom agent with the specified chat client. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured AgentFrameworkAgent instance + """ + agent = ChatAgent( + name="my_custom_agent", + instructions="Custom instructions here", + chat_client=chat_client, + tools=[my_tool], + ) + + return AgentFrameworkAgent( + agent=agent, + name="MyCustomAgent", + description="My custom agent description", + ) + +# Use it +from agent_framework.azure import AzureOpenAIChatClient +chat_client = AzureOpenAIChatClient() +agent = my_custom_agent(chat_client) +``` + ### Shared State State is injected as system messages and updated via predictive state updates: diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/__init__.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/__init__.py index 720a16c765..2c3dd6554b 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/__init__.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/__init__.py @@ -6,7 +6,7 @@ from .document_writer_agent import document_writer_agent from .human_in_the_loop_agent import human_in_the_loop_agent from .recipe_agent import recipe_agent from .research_assistant_agent import research_assistant_agent -from .simple_agent import agent as simple_agent +from .simple_agent import simple_agent from .task_planner_agent import task_planner_agent from .task_steps_agent import task_steps_agent_wrapped from .ui_generator_agent import ui_generator_agent diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/document_writer_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/document_writer_agent.py index ca7233a5a3..72623379ed 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/document_writer_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/document_writer_agent.py @@ -3,7 +3,7 @@ """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._clients import ChatClientProtocol from agent_framework_ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy @@ -28,31 +28,43 @@ def write_document_local(document: str) -> str: 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_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." ) -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(), -) + +def document_writer_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent: + """Create a document writer agent with predictive state updates. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured AgentFrameworkAgent instance with document writing capabilities + """ + agent = ChatAgent( + name="document_writer", + instructions=_DOCUMENT_WRITER_INSTRUCTIONS, + chat_client=chat_client, + tools=[write_document_local], + ) + + return 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(), + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/human_in_the_loop_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/human_in_the_loop_agent.py index dfa1b30c63..8b178476af 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/human_in_the_loop_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/human_in_the_loop_agent.py @@ -5,7 +5,7 @@ from enum import Enum from agent_framework import ChatAgent, ai_function -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework._clients import ChatClientProtocol from pydantic import BaseModel, Field @@ -43,10 +43,18 @@ def generate_task_steps(steps: list[TaskStep]) -> str: 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. +def human_in_the_loop_agent(chat_client: ChatClientProtocol) -> ChatAgent: + """Create a human-in-the-loop agent using tool-based approach for predictive state. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured ChatAgent instance with human-in-the-loop capabilities + """ + return 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. @@ -71,6 +79,6 @@ human_in_the_loop_agent = ChatAgent( 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], -) + chat_client=chat_client, + tools=[generate_task_steps], + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/recipe_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/recipe_agent.py index 2a5b94e1cc..dfdd058bc7 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/recipe_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/recipe_agent.py @@ -5,7 +5,7 @@ from enum import Enum from agent_framework import ChatAgent, ai_function -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework._clients import ChatClientProtocol from pydantic import BaseModel, Field from agent_framework_ag_ui import AgentFrameworkAgent, RecipeConfirmationStrategy @@ -67,10 +67,7 @@ def update_recipe(recipe: Recipe) -> str: 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. +_RECIPE_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 @@ -103,20 +100,34 @@ agent = ChatAgent( - 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(), -) + +def recipe_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent: + """Create a recipe agent with streaming state updates. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured AgentFrameworkAgent instance with recipe management + """ + agent = ChatAgent( + name="recipe_agent", + instructions=_RECIPE_INSTRUCTIONS, + chat_client=chat_client, + tools=[update_recipe], + ) + + return 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(), + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/research_assistant_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/research_assistant_agent.py index 60d142e2c2..767ed1d961 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/research_assistant_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/research_assistant_agent.py @@ -5,7 +5,7 @@ import asyncio from agent_framework import ChatAgent, ai_function -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework._clients import ChatClientProtocol from agent_framework_ag_ui import AgentFrameworkAgent @@ -82,19 +82,31 @@ async def analyze_data(dataset: str) -> str: 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_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." ) -research_assistant_agent = AgentFrameworkAgent( - agent=agent, - name="ResearchAssistant", - description="Research assistant that emits progress events during task execution", -) + +def research_assistant_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent: + """Create a research assistant agent with progress events. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured AgentFrameworkAgent instance with research capabilities + """ + agent = ChatAgent( + name="research_assistant", + instructions=_RESEARCH_ASSISTANT_INSTRUCTIONS, + chat_client=chat_client, + tools=[research_topic, create_presentation, analyze_data], + ) + + return AgentFrameworkAgent( + agent=agent, + name="ResearchAssistant", + description="Research assistant that emits progress events during task execution", + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/simple_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/simple_agent.py index 4831f1442c..bb63170399 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/simple_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/simple_agent.py @@ -3,11 +3,20 @@ """Simple agentic chat example (Feature 1: Agentic Chat).""" from agent_framework import ChatAgent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework._clients import ChatClientProtocol -# Create a simple chat agent -agent = ChatAgent( - name="simple_chat_agent", - instructions="You are a helpful assistant. Be concise and friendly.", - chat_client=AzureOpenAIChatClient(), -) + +def simple_agent(chat_client: ChatClientProtocol) -> ChatAgent: + """Create a simple chat agent. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured ChatAgent instance + """ + return ChatAgent( + name="simple_chat_agent", + instructions="You are a helpful assistant. Be concise and friendly.", + chat_client=chat_client, + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_planner_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_planner_agent.py index 58d8b8c556..f9eea2669b 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_planner_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_planner_agent.py @@ -3,7 +3,7 @@ """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._clients import ChatClientProtocol from agent_framework_ag_ui import AgentFrameworkAgent, TaskPlannerConfirmationStrategy @@ -54,20 +54,32 @@ def book_meeting_room(room_name: str, date: str, start_time: str, end_time: str) 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_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." ) -task_planner_agent = AgentFrameworkAgent( - agent=agent, - name="TaskPlanner", - description="Plans and executes tasks with user approval", - confirmation_strategy=TaskPlannerConfirmationStrategy(), -) + +def task_planner_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent: + """Create a task planner agent with user approval for actions. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured AgentFrameworkAgent instance with task planning capabilities + """ + agent = ChatAgent( + name="task_planner", + instructions=_TASK_PLANNER_INSTRUCTIONS, + chat_client=chat_client, + tools=[create_calendar_event, send_email, book_meeting_room], + ) + + return AgentFrameworkAgent( + agent=agent, + name="TaskPlanner", + description="Plans and executes tasks with user approval", + confirmation_strategy=TaskPlannerConfirmationStrategy(), + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_steps_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_steps_agent.py index a2856dbf23..332e416215 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_steps_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_steps_agent.py @@ -19,7 +19,7 @@ from ag_ui.core import ( ToolCallStartEvent, ) from agent_framework import ChatAgent, ai_function -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework._clients import ChatClientProtocol from pydantic import BaseModel, Field from agent_framework_ag_ui import AgentFrameworkAgent @@ -54,10 +54,18 @@ def generate_task_steps(steps: list[TaskStep]) -> str: 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. +def _create_task_steps_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent: + """Create the task steps agent using tool-based approach for streaming. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured AgentFrameworkAgent instance + """ + 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 @@ -75,25 +83,25 @@ agent = ChatAgent( - "Installing platform" - "Adding finishing touches" """, - chat_client=AzureOpenAIChatClient(), - tools=[generate_task_steps], -) + chat_client=chat_client, + 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 -) + return 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 @@ -131,7 +139,7 @@ class TaskStepsAgentWithExecution: 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 + final_state: dict[str, Any] = {} run_finished_event: Any = None tool_call_id: str | None = None buffered_text_events: list[Any] = [] # Buffer text from first LLM call @@ -142,9 +150,20 @@ class TaskStepsAgentWithExecution: match event: case StateSnapshotEvent(snapshot=snapshot): - final_state = snapshot + final_state = snapshot.copy() if snapshot else {} logger.info(f"Captured STATE_SNAPSHOT event with state: {final_state}") yield event + case StateDeltaEvent(delta=delta): + # Apply state delta to final_state + if delta: + for patch in delta: + if patch.get("op") == "replace" and patch.get("path") == "/steps": + final_state["steps"] = patch.get("value", []) + logger.info( + f"Applied STATE_DELTA: updated steps to {len(final_state.get('steps', []))} items" + ) + logger.info(f"Yielding event immediately: {event_type_str}") + yield event case RunFinishedEvent(): run_finished_event = event logger.info("Captured RUN_FINISHED event - will send after step execution and summary") @@ -314,5 +333,14 @@ class TaskStepsAgentWithExecution: yield run_finished_event -# Export the wrapped agent -task_steps_agent_wrapped = TaskStepsAgentWithExecution(task_steps_agent) +def task_steps_agent_wrapped(chat_client: ChatClientProtocol) -> TaskStepsAgentWithExecution: + """Create a task steps agent with execution simulation. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A wrapped agent instance with step execution simulation + """ + base_agent = _create_task_steps_agent(chat_client) + return TaskStepsAgentWithExecution(base_agent) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py index 2456ccb5e1..12f1a9341e 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py @@ -4,23 +4,39 @@ from typing import Any -from agent_framework import ChatAgent, ai_function -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework import AIFunction, ChatAgent +from agent_framework._clients import ChatClientProtocol 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). +# Declaration-only tools (func=None) - actual rendering happens on the client side +generate_haiku = AIFunction[Any, str]( + name="generate_haiku", + description="""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: + The frontend should render this as a custom haiku component.""", + func=None, # Makes declaration_only=True so client renders the UI + input_model={ + "type": "object", + "properties": { + "english": { + "type": "array", + "items": {"type": "string"}, + "description": "English haiku lines (exactly 3 lines)", + "minItems": 3, + "maxItems": 3, + }, + "japanese": { + "type": "array", + "items": {"type": "string"}, + "description": "Japanese haiku lines (exactly 3 lines)", + "minItems": 3, + "maxItems": 3, + }, + "image_name": { + "type": "string", + "description": """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" @@ -31,71 +47,100 @@ def generate_haiku(english: list[str], japanese: list[str], image_name: str | No - "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%)") + """, + }, + "gradient": { + "type": "string", + "description": 'CSS gradient string for background (e.g., "linear-gradient(135deg, #667eea 0%, #764ba2 100%)")', + }, + }, + "required": ["english", "japanese", "image_name", "gradient"], + }, +) - 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). +create_chart = AIFunction[Any, str]( + name="create_chart", + description="""Create an interactive chart (FRONTEND_RENDER). This tool creates chart specifications for frontend rendering. - The frontend should render this as an interactive chart component. + The frontend should render this as an interactive chart component.""", + func=None, # Makes declaration_only=True so client renders the UI + input_model={ + "type": "object", + "properties": { + "chart_type": { + "type": "string", + "description": "Type of chart (bar, line, pie, scatter)", + }, + "data_points": { + "type": "array", + "items": {"type": "object"}, + "description": "Data points for the chart", + }, + "title": { + "type": "string", + "description": "Chart title", + }, + }, + "required": ["chart_type", "data_points", "title"], + }, +) - 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). +display_timeline = AIFunction[Any, str]( + name="display_timeline", + description="""Display an interactive timeline (FRONTEND_RENDER). This tool creates timeline specifications for frontend rendering. - The frontend should render this as an interactive timeline component. + The frontend should render this as an interactive timeline component.""", + func=None, # Makes declaration_only=True so client renders the UI + input_model={ + "type": "object", + "properties": { + "events": { + "type": "array", + "items": {"type": "object"}, + "description": "Events to display on the timeline", + }, + "start_date": { + "type": "string", + "description": "Timeline start date", + }, + "end_date": { + "type": "string", + "description": "Timeline end date", + }, + }, + "required": ["events", "start_date", "end_date"], + }, +) - 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). +show_comparison_table = AIFunction[Any, str]( + name="show_comparison_table", + description="""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" + The frontend should render this as an interactive comparison table.""", + func=None, # Makes declaration_only=True so client renders the UI + input_model={ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": {"type": "object"}, + "description": "Items to compare", + }, + "columns": { + "type": "array", + "items": {"type": "string"}, + "description": "Column names", + }, + }, + "required": ["items", "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. +_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 @@ -105,15 +150,29 @@ agent = ChatAgent( - 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", -) + +def ui_generator_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent: + """Create a UI generator agent with frontend rendering tools. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured AgentFrameworkAgent instance with UI generation tools + """ + agent = ChatAgent( + name="ui_generator", + instructions=_UI_GENERATOR_INSTRUCTIONS, + chat_client=chat_client, + 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"}, + ) + + return AgentFrameworkAgent( + agent=agent, + name="UIGenerator", + description="Generates custom UI components through tool calls", + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_agent.py index a224bb7cd0..e48a9cab50 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_agent.py @@ -5,7 +5,7 @@ from typing import Any from agent_framework import ChatAgent, ai_function -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework._clients import ChatClientProtocol @ai_function @@ -58,14 +58,22 @@ def get_forecast(location: str, days: int = 3) -> str: 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], -) +def weather_agent(chat_client: ChatClientProtocol) -> ChatAgent: + """Create a weather agent with get_weather and get_forecast tools. + + Args: + chat_client: The chat client to use for the agent + + Returns: + A configured ChatAgent instance with weather tools + """ + return 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=chat_client, + tools=[get_weather, get_forecast], + ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/__init__.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/__init__.py deleted file mode 100644 index e50a96d510..0000000000 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""API endpoints for AG-UI examples.""" diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py index fb8f88e6a4..0ff360efb2 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py @@ -2,6 +2,7 @@ """Backend tool rendering endpoint.""" +from agent_framework.azure import AzureOpenAIChatClient from fastapi import FastAPI from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint @@ -15,8 +16,11 @@ def register_backend_tool_rendering(app: FastAPI) -> None: Args: app: The FastAPI application. """ + # Create a chat client and call the factory function + chat_client = AzureOpenAIChatClient() + add_agent_framework_fastapi_endpoint( app, - weather_agent, + weather_agent(chat_client), "/backend_tool_rendering", ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py index 6841f3db20..e633268c50 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py @@ -6,6 +6,7 @@ import logging import os import uvicorn +from agent_framework.azure import AzureOpenAIChatClient from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -14,8 +15,8 @@ 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.simple_agent import simple_agent +from ..agents.task_steps_agent import task_steps_agent_wrapped from ..agents.ui_generator_agent import ui_generator_agent from ..agents.weather_agent import weather_agent @@ -58,38 +59,42 @@ app.add_middleware( allow_headers=["*"], ) +# Create a shared chat client for all agents +# You can use different chat clients for different agents if needed +chat_client = AzureOpenAIChatClient() + # Agentic Chat - basic chat agent add_agent_framework_fastapi_endpoint( app=app, - agent=simple_agent, + agent=simple_agent(chat_client), path="/agentic_chat", ) # Backend Tool Rendering - agent with tools add_agent_framework_fastapi_endpoint( app=app, - agent=weather_agent, + agent=weather_agent(chat_client), path="/backend_tool_rendering", ) # Shared State - recipe agent with structured output add_agent_framework_fastapi_endpoint( app=app, - agent=recipe_agent, + agent=recipe_agent(chat_client), path="/shared_state", ) # Predictive State Updates - document writer with predictive state add_agent_framework_fastapi_endpoint( app=app, - agent=document_writer_agent, + agent=document_writer_agent(chat_client), 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, + agent=human_in_the_loop_agent(chat_client), path="/human_in_the_loop", state_schema={"steps": {"type": "array"}}, predict_state_config={"steps": {"tool": "generate_task_steps", "tool_argument": "steps"}}, @@ -98,23 +103,26 @@ add_agent_framework_fastapi_endpoint( # 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] + agent=task_steps_agent_wrapped(chat_client), # 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, + agent=ui_generator_agent(chat_client), path="/tool_based_generative_ui", ) def main(): """Run the server.""" - port = int(os.getenv("PORT", "8888")) + port = int(os.getenv("PORT", "8887")) host = os.getenv("HOST", "127.0.0.1") + print(f"\nAG-UI Examples Server starting on http://{host}:{port}") + print("Set ENABLE_DEBUG_LOGGING=1 for detailed request logging\n") + # Use log_config=None to prevent uvicorn from reconfiguring logging # This preserves our file + console logging setup uvicorn.run( diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 9216a17e24..63a4c60fdb 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-ag-ui" -version = "1.0.0b251111" +version = "1.0.0b251112" description = "AG-UI protocol integration for Agent Framework" readme = "README.md" license-files = ["LICENSE"] diff --git a/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py b/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py index 723e369c43..4b5b770509 100644 --- a/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py +++ b/python/packages/ag-ui/tests/test_agent_wrapper_comprehensive.py @@ -505,17 +505,20 @@ async def test_error_handling_with_exception(): async def test_json_decode_error_in_tool_result(): - """Test handling of JSONDecodeError when parsing tool result.""" + """Test handling of orphaned tool result - should be sanitized out.""" 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")]) + # Should not be called since orphaned tool result is dropped + if False: + yield + raise AssertionError("ChatClient should not be called with orphaned tool result") agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient()) wrapper = AgentFrameworkAgent(agent=agent) - # Send invalid JSON as tool result + # Send invalid JSON as tool result without preceding tool call input_data = { "messages": [ { @@ -530,10 +533,12 @@ async def test_json_decode_error_in_tool_result(): async for event in wrapper.run_agent(input_data): events.append(event) - # Should fall through to normal agent processing + # Orphaned tool result should be sanitized out + # Only run lifecycle events should be emitted, no text/tool events 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" + tool_events = [e for e in events if e.type.startswith("TOOL_CALL")] + assert len(text_events) == 0 + assert len(tool_events) == 0 async def test_suppressed_summary_with_document_state(): diff --git a/python/packages/ag-ui/tests/test_orchestrators_coverage.py b/python/packages/ag-ui/tests/test_orchestrators_coverage.py new file mode 100644 index 0000000000..81e41dee5f --- /dev/null +++ b/python/packages/ag-ui/tests/test_orchestrators_coverage.py @@ -0,0 +1,811 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Comprehensive tests for orchestrator coverage.""" + +from collections.abc import AsyncGenerator +from types import SimpleNamespace +from typing import Any + +from agent_framework import ( + AgentRunResponseUpdate, + ChatMessage, + TextContent, + ai_function, +) +from pydantic import BaseModel + +from agent_framework_ag_ui._agent import AgentConfig +from agent_framework_ag_ui._orchestrators import ( + DefaultOrchestrator, + ExecutionContext, + HumanInTheLoopOrchestrator, +) + + +@ai_function(approval_mode="always_require") +def approval_tool(param: str) -> str: + """Tool requiring approval.""" + return f"executed: {param}" + + +class MockAgent: + """Mock agent for testing.""" + + def __init__(self, updates: list[AgentRunResponseUpdate] | None = None) -> None: + self.updates = updates or [AgentRunResponseUpdate(contents=[TextContent(text="response")], role="assistant")] + self.chat_options = SimpleNamespace(tools=[approval_tool], response_format=None) + self.chat_client = SimpleNamespace(function_invocation_configuration=None) + self.messages_received: list[Any] = [] + self.tools_received: list[Any] | None = None + + async def run_stream( + self, + messages: list[Any], + *, + thread: Any = None, + tools: list[Any] | None = None, + ) -> AsyncGenerator[AgentRunResponseUpdate, None]: + self.messages_received = messages + self.tools_received = tools + for update in self.updates: + yield update + + +async def test_human_in_the_loop_json_decode_error() -> None: + """Test HumanInTheLoopOrchestrator handles invalid JSON in tool result.""" + orchestrator = HumanInTheLoopOrchestrator() + + input_data = { + "messages": [ + { + "role": "tool", + "content": [{"type": "text", "text": "not valid json {"}], + } + ], + } + + messages = [ + ChatMessage( + role="tool", + contents=[TextContent(text="not valid json {")], + additional_properties={"is_tool_result": True}, + ) + ] + + context = ExecutionContext( + input_data=input_data, + agent=MockAgent(), + config=AgentConfig(), + ) + context._messages = messages + + assert orchestrator.can_handle(context) + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should emit RunErrorEvent for invalid JSON + error_events = [e for e in events if e.type == "RUN_ERROR"] + assert len(error_events) == 1 + assert "Invalid tool result format" in error_events[0].message + + +async def test_sanitize_tool_history_confirm_changes() -> None: + """Test sanitize_tool_history logic for confirm_changes synthetic result.""" + from agent_framework import ChatMessage, FunctionCallContent, TextContent + + # Create messages that will trigger confirm_changes synthetic result injection + messages = [ + ChatMessage( + role="assistant", + contents=[ + FunctionCallContent( + name="confirm_changes", + call_id="call_confirm_123", + arguments='{"changes": "test"}', + ) + ], + ), + ChatMessage( + role="user", + contents=[TextContent(text='{"accepted": true}')], + ), + ] + + # The sanitize_tool_history function is internal to DefaultOrchestrator.run + # We'll test it indirectly by checking the orchestrator processes it correctly + orchestrator = DefaultOrchestrator() + + # Use pre-constructed ChatMessage objects to bypass message adapter + input_data = {"messages": []} + + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + # Override the messages property to use our pre-constructed messages + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Agent should receive synthetic tool result + assert len(agent.messages_received) > 0 + tool_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool" + ] + assert len(tool_messages) == 1 + assert str(tool_messages[0].contents[0].call_id) == "call_confirm_123" + assert tool_messages[0].contents[0].result == "Confirmed" + + +async def test_sanitize_tool_history_orphaned_tool_result() -> None: + """Test sanitize_tool_history removes orphaned tool results.""" + from agent_framework import ChatMessage, FunctionResultContent, TextContent + + # Tool result without preceding assistant tool call + messages = [ + ChatMessage( + role="tool", + contents=[FunctionResultContent(call_id="orphan_123", result="orphaned data")], + ), + ChatMessage( + role="user", + contents=[TextContent(text="Hello")], + ), + ] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": []} + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Orphaned tool result should be filtered out + tool_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool" + ] + assert len(tool_messages) == 0 + + +async def test_orphaned_tool_result_sanitization() -> None: + """Test that orphaned tool results are filtered out.""" + orchestrator = DefaultOrchestrator() + + input_data = { + "messages": [ + { + "role": "tool", + "content": [{"type": "tool_result", "tool_call_id": "orphan_123", "content": "result"}], + }, + { + "role": "user", + "content": [{"type": "text", "text": "Hello"}], + }, + ], + } + + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Orphaned tool result should be filtered, only user message remains + tool_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool" + ] + assert len(tool_messages) == 0 + + +async def test_deduplicate_messages_empty_tool_results() -> None: + """Test deduplicate_messages prefers non-empty tool results.""" + from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent + + messages = [ + ChatMessage( + role="assistant", + contents=[FunctionCallContent(name="test_tool", call_id="call_789", arguments="{}")], + ), + ChatMessage( + role="tool", + contents=[FunctionResultContent(call_id="call_789", result="")], + ), + ChatMessage( + role="tool", + contents=[FunctionResultContent(call_id="call_789", result="real data")], + ), + ] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": []} + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should have only one tool result with actual data + tool_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool" + ] + assert len(tool_messages) == 1 + assert tool_messages[0].contents[0].result == "real data" + + +async def test_deduplicate_messages_duplicate_assistant_tool_calls() -> None: + """Test deduplicate_messages removes duplicate assistant tool call messages.""" + from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent + + messages = [ + ChatMessage( + role="assistant", + contents=[FunctionCallContent(name="test_tool", call_id="call_abc", arguments="{}")], + ), + ChatMessage( + role="assistant", + contents=[FunctionCallContent(name="test_tool", call_id="call_abc", arguments="{}")], + ), + ChatMessage( + role="tool", + contents=[FunctionResultContent(call_id="call_abc", result="result")], + ), + ] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": []} + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should have only one assistant message + assistant_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "assistant" + ] + assert len(assistant_messages) == 1 + + +async def test_deduplicate_messages_duplicate_system_messages() -> None: + """Test that deduplication logic is invoked for system messages.""" + from agent_framework import ChatMessage, TextContent + + messages = [ + ChatMessage( + role="system", + contents=[TextContent(text="You are a helpful assistant.")], + ), + ChatMessage( + role="system", + contents=[TextContent(text="You are a helpful assistant.")], + ), + ChatMessage( + role="user", + contents=[TextContent(text="Hello")], + ), + ] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": []} + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Deduplication uses hash() which may not deduplicate identical content + # This test verifies deduplication logic runs without errors + system_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "system" + ] + # At least one system message should be present + assert len(system_messages) >= 1 + + +async def test_state_context_injection() -> None: + """Test state context message injection for first request.""" + orchestrator = DefaultOrchestrator() + + input_data = { + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "Hello"}], + } + ], + "state": {"items": ["apple", "banana"]}, + } + + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(state_schema={"items": {"type": "array"}}), + ) + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should inject system message with current state + system_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "system" + ] + assert len(system_messages) == 1 + assert "apple" in system_messages[0].contents[0].text + assert "banana" in system_messages[0].contents[0].text + + +async def test_no_state_context_injection_with_tool_calls() -> None: + """Test state context is NOT injected if conversation has tool calls.""" + from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent + + messages = [ + ChatMessage( + role="assistant", + contents=[FunctionCallContent(name="get_weather", call_id="call_xyz", arguments="{}")], + ), + ChatMessage( + role="tool", + contents=[FunctionResultContent(call_id="call_xyz", result="sunny")], + ), + ChatMessage( + role="user", + contents=[TextContent(text="Thanks")], + ), + ] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": [], "state": {"weather": "sunny"}} + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(state_schema={"weather": {"type": "string"}}), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should NOT inject state context system message since conversation has tool calls + system_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "system" + ] + assert len(system_messages) == 0 + + +async def test_structured_output_processing() -> None: + """Test structured output extraction and state update.""" + + class RecipeState(BaseModel): + ingredients: list[str] + message: str + + orchestrator = DefaultOrchestrator() + + input_data = { + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "Add tomato"}], + } + ], + } + + # Agent with structured output + agent = MockAgent( + updates=[ + AgentRunResponseUpdate( + contents=[TextContent(text='{"ingredients": ["tomato"], "message": "Added tomato"}')], + role="assistant", + ) + ] + ) + agent.chat_options.response_format = RecipeState + + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(state_schema={"ingredients": {"type": "array"}}), + ) + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should emit StateSnapshotEvent with ingredients + state_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(state_events) >= 1 + + # Should emit TextMessage with message field + text_content_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"] + assert len(text_content_events) >= 1 + assert any("Added tomato" in e.delta for e in text_content_events) + + +async def test_duplicate_client_tools_filtered() -> None: + """Test that client tools duplicating server tools are filtered out.""" + + @ai_function + def get_weather(location: str) -> str: + """Get weather for location.""" + return f"Weather in {location}" + + orchestrator = DefaultOrchestrator() + + input_data = { + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "Hello"}], + } + ], + "tools": [ + { + "name": "get_weather", + "description": "Client weather tool.", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + } + ], + } + + agent = MockAgent() + agent.chat_options.tools = [get_weather] + + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # tools parameter should not be passed since client tool duplicates server tool + assert agent.tools_received is None + + +async def test_unique_client_tools_merged() -> None: + """Test that unique client tools are merged with server tools.""" + + @ai_function + def server_tool() -> str: + """Server tool.""" + return "server" + + orchestrator = DefaultOrchestrator() + + input_data = { + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "Hello"}], + } + ], + "tools": [ + { + "name": "client_tool", + "description": "Unique client tool.", + "parameters": { + "type": "object", + "properties": {"param": {"type": "string"}}, + "required": ["param"], + }, + } + ], + } + + agent = MockAgent() + agent.chat_options.tools = [server_tool] + + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # tools parameter should be passed with both server and client tools + assert agent.tools_received is not None + tool_names = [getattr(tool, "name", None) for tool in agent.tools_received] + assert "server_tool" in tool_names + assert "client_tool" in tool_names + + +async def test_empty_messages_handling() -> None: + """Test orchestrator handles empty message list gracefully.""" + orchestrator = DefaultOrchestrator() + + input_data = {"messages": []} + + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should emit run lifecycle events but not call agent + assert len(agent.messages_received) == 0 + run_started = [e for e in events if e.type == "RUN_STARTED"] + run_finished = [e for e in events if e.type == "RUN_FINISHED"] + assert len(run_started) == 1 + assert len(run_finished) == 1 + + +async def test_all_messages_filtered_handling() -> None: + """Test orchestrator handles case where all messages are filtered out.""" + orchestrator = DefaultOrchestrator() + + input_data = { + "messages": [ + { + "role": "tool", + "content": [{"type": "tool_result", "tool_call_id": "orphan", "content": "data"}], + } + ] + } + + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should finish without calling agent + assert len(agent.messages_received) == 0 + run_finished = [e for e in events if e.type == "RUN_FINISHED"] + assert len(run_finished) == 1 + + +async def test_confirm_changes_with_invalid_json_fallback() -> None: + """Test confirm_changes with invalid JSON falls back to normal processing.""" + from agent_framework import ChatMessage, FunctionCallContent, TextContent + + messages = [ + ChatMessage( + role="assistant", + contents=[ + FunctionCallContent( + name="confirm_changes", + call_id="call_confirm_invalid", + arguments='{"changes": "test"}', + ) + ], + ), + ChatMessage( + role="user", + contents=[TextContent(text="invalid json {")], + ), + ] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": []} + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Invalid JSON should fall back - user message should be included + user_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "user" + ] + assert len(user_messages) == 1 + + +async def test_tool_result_kept_when_call_id_matches() -> None: + """Test tool result is kept when call_id matches pending tool calls.""" + from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent + + messages = [ + ChatMessage( + role="assistant", + contents=[FunctionCallContent(name="get_data", call_id="call_match", arguments="{}")], + ), + ChatMessage( + role="tool", + contents=[FunctionResultContent(call_id="call_match", result="data")], + ), + ] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": []} + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Tool result should be kept + tool_messages = [ + msg + for msg in agent.messages_received + if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool" + ] + assert len(tool_messages) == 1 + assert tool_messages[0].contents[0].result == "data" + + +async def test_agent_protocol_fallback_paths() -> None: + """Test fallback paths for non-ChatAgent implementations.""" + + class CustomAgent: + """Custom agent without ChatAgent type.""" + + def __init__(self) -> None: + self.chat_options = SimpleNamespace(tools=[], response_format=None) + self.chat_client = SimpleNamespace(function_invocation_configuration=SimpleNamespace()) + self.messages_received: list[Any] = [] + + async def run_stream( + self, + messages: list[Any], + *, + thread: Any = None, + tools: list[Any] | None = None, + ) -> AsyncGenerator[AgentRunResponseUpdate, None]: + self.messages_received = messages + yield AgentRunResponseUpdate(contents=[TextContent(text="response")], role="assistant") + + from agent_framework import ChatMessage, TextContent + + messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": []} + agent = CustomAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, # type: ignore + config=AgentConfig(), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should work with custom agent implementation + assert len(agent.messages_received) > 0 + + +async def test_initial_state_snapshot_with_array_schema() -> None: + """Test state initialization with array type schema.""" + from agent_framework import ChatMessage, TextContent + + messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": [], "state": {}} + agent = MockAgent() + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(state_schema={"items": {"type": "array"}}), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Should emit state snapshot with empty array for items + state_events = [e for e in events if e.type == "STATE_SNAPSHOT"] + assert len(state_events) >= 1 + + +async def test_response_format_skip_text_content() -> None: + """Test that response_format causes skip_text_content to be set.""" + + class OutputModel(BaseModel): + result: str + + from agent_framework import ChatMessage, TextContent + + messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])] + + orchestrator = DefaultOrchestrator() + input_data = {"messages": []} + + agent = MockAgent() + agent.chat_options.response_format = OutputModel + + context = ExecutionContext( + input_data=input_data, + agent=agent, + config=AgentConfig(), + ) + context._messages = messages + + events = [] + async for event in orchestrator.run(context): + events.append(event) + + # Test passes if no errors occur - verifies response_format code path + assert len(events) > 0 diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index cacd760e76..8f52f43a1f 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251111" +version = "1.0.0b251112" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/azure-ai/agent_framework_azure_ai/__init__.py b/python/packages/azure-ai/agent_framework_azure_ai/__init__.py index 6e6ac7a5e5..cf2423693d 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/__init__.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/__init__.py @@ -3,6 +3,7 @@ import importlib.metadata from ._chat_client import AzureAIAgentClient +from ._client import AzureAIClient from ._shared import AzureAISettings try: @@ -12,6 +13,7 @@ except importlib.metadata.PackageNotFoundError: __all__ = [ "AzureAIAgentClient", + "AzureAIClient", "AzureAISettings", "__version__", ] diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_client.py new file mode 100644 index 0000000000..774349a85d --- /dev/null +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -0,0 +1,354 @@ +# Copyright (c) Microsoft. All rights reserved. + +import sys +from collections.abc import MutableSequence +from typing import Any, ClassVar, TypeVar + +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + ChatMessage, + ChatOptions, + HostedMCPTool, + TextContent, + get_logger, + use_chat_middleware, + use_function_invocation, +) +from agent_framework.exceptions import ServiceInitializationError +from agent_framework.observability import use_observability +from agent_framework.openai._responses_client import OpenAIBaseResponsesClient +from azure.ai.projects.aio import AIProjectClient +from azure.ai.projects.models import ( + MCPTool, + PromptAgentDefinition, + PromptAgentDefinitionText, + ResponseTextFormatConfigurationJsonSchema, +) +from azure.core.credentials_async import AsyncTokenCredential +from azure.core.exceptions import ResourceNotFoundError +from openai.types.responses.parsed_response import ( + ParsedResponse, +) +from openai.types.responses.response import Response as OpenAIResponse +from pydantic import BaseModel, ValidationError + +from ._shared import AzureAISettings + +if sys.version_info >= (3, 11): + from typing import Self # pragma: no cover +else: + from typing_extensions import Self # pragma: no cover + + +logger = get_logger("agent_framework.azure") + + +TAzureAIClient = TypeVar("TAzureAIClient", bound="AzureAIClient") + + +@use_function_invocation +@use_observability +@use_chat_middleware +class AzureAIClient(OpenAIBaseResponsesClient): + """Azure AI Agent client.""" + + OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc] + + def __init__( + self, + *, + project_client: AIProjectClient | None = None, + agent_name: str | None = None, + agent_version: str | None = None, + conversation_id: str | None = None, + project_endpoint: str | None = None, + model_deployment_name: str | None = None, + async_credential: AsyncTokenCredential | None = None, + use_latest_version: bool | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize an Azure AI Agent client. + + Keyword Args: + project_client: An existing AIProjectClient to use. If not provided, one will be created. + agent_name: The name to use when creating new agents. + agent_version: The version of the agent to use. + conversation_id: Default conversation ID to use for conversations. Can be overridden by + conversation_id property when making a request. + project_endpoint: The Azure AI Project endpoint URL. + Can also be set via environment variable AZURE_AI_PROJECT_ENDPOINT. + Ignored when a project_client is passed. + model_deployment_name: The model deployment name to use for agent creation. + Can also be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME. + async_credential: Azure async credential to use for authentication. + use_latest_version: Boolean flag that indicates whether to use latest agent version + if it exists in the service. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + kwargs: Additional keyword arguments passed to the parent class. + + Examples: + .. code-block:: python + + from agent_framework.azure import AzureAIClient + from azure.identity.aio import DefaultAzureCredential + + # Using environment variables + # Set AZURE_AI_PROJECT_ENDPOINT=https://your-project.cognitiveservices.azure.com + # Set AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4 + credential = DefaultAzureCredential() + client = AzureAIClient(async_credential=credential) + + # Or passing parameters directly + client = AzureAIClient( + project_endpoint="https://your-project.cognitiveservices.azure.com", + model_deployment_name="gpt-4", + async_credential=credential, + ) + + # Or loading from a .env file + client = AzureAIClient(async_credential=credential, env_file_path="path/to/.env") + """ + try: + azure_ai_settings = AzureAISettings( + project_endpoint=project_endpoint, + model_deployment_name=model_deployment_name, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + except ValidationError as ex: + raise ServiceInitializationError("Failed to create Azure AI settings.", ex) from ex + + # If no project_client is provided, create one + should_close_client = False + if project_client is None: + if not azure_ai_settings.project_endpoint: + raise ServiceInitializationError( + "Azure AI project endpoint is required. Set via 'project_endpoint' parameter " + "or 'AZURE_AI_PROJECT_ENDPOINT' environment variable." + ) + + # Use provided credential + if not async_credential: + raise ServiceInitializationError("Azure credential is required when project_client is not provided.") + project_client = AIProjectClient( + endpoint=azure_ai_settings.project_endpoint, + credential=async_credential, + user_agent=AGENT_FRAMEWORK_USER_AGENT, + ) + should_close_client = True + + # Initialize parent + super().__init__( + **kwargs, + ) + + # Initialize instance variables + self.agent_name = agent_name + self.agent_version = agent_version + self.use_latest_version = use_latest_version + self.project_client = project_client + self.credential = async_credential + self.model_id = azure_ai_settings.model_deployment_name + self.conversation_id = conversation_id + self._should_close_client = should_close_client # Track whether we should close client connection + + async def setup_azure_ai_observability(self, enable_sensitive_data: bool | None = None) -> None: + """Use this method to setup tracing in your Azure AI Project. + + This will take the connection string from the project project_client. + It will override any connection string that is set in the environment variables. + It will disable any OTLP endpoint that might have been set. + """ + try: + conn_string = await self.project_client.telemetry.get_application_insights_connection_string() + except ResourceNotFoundError: + logger.warning( + "No Application Insights connection string found for the Azure AI Project, " + "please call setup_observability() manually." + ) + return + from agent_framework.observability import setup_observability + + setup_observability( + applicationinsights_connection_string=conn_string, enable_sensitive_data=enable_sensitive_data + ) + + async def __aenter__(self) -> "Self": + """Async context manager entry.""" + return self + + async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: + """Async context manager exit.""" + await self.close() + + async def close(self) -> None: + """Close the project_client.""" + await self._close_client_if_needed() + + async def _get_agent_reference_or_create( + self, run_options: dict[str, Any], messages_instructions: str | None + ) -> dict[str, str]: + """Determine which agent to use and create if needed. + + Returns: + str: The agent_name to use + """ + agent_name = self.agent_name or "UnnamedAgent" + + # If no agent_version is provided, either use latest version or create a new agent: + if self.agent_version is None: + # Try to use latest version if requested and agent exists + if self.use_latest_version: + try: + existing_agent = await self.project_client.agents.get(agent_name) + self.agent_name = existing_agent.name + self.agent_version = existing_agent.versions.latest.version + return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"} + except ResourceNotFoundError: + # Agent doesn't exist, fall through to creation logic + pass + + if "model" not in run_options or not run_options["model"]: + raise ServiceInitializationError( + "Model deployment name is required for agent creation, " + "can also be passed to the get_response methods." + ) + + args: dict[str, Any] = {"model": run_options["model"]} + + if "tools" in run_options: + args["tools"] = run_options["tools"] + + if "response_format" in run_options: + response_format = run_options["response_format"] + args["text"] = PromptAgentDefinitionText( + format=ResponseTextFormatConfigurationJsonSchema( + name=response_format.__name__, + schema=response_format.model_json_schema(), + ) + ) + + # Combine instructions from messages and options + combined_instructions = [ + instructions + for instructions in [messages_instructions, run_options.get("instructions")] + if instructions + ] + if combined_instructions: + args["instructions"] = "".join(combined_instructions) + + created_agent = await self.project_client.agents.create_version( + agent_name=agent_name, definition=PromptAgentDefinition(**args) + ) + + self.agent_name = created_agent.name + self.agent_version = created_agent.version + + return {"name": agent_name, "version": self.agent_version, "type": "agent_reference"} + + async def _close_client_if_needed(self) -> None: + """Close project_client session if we created it.""" + if self._should_close_client: + await self.project_client.close() + + def _prepare_input(self, messages: MutableSequence[ChatMessage]) -> tuple[list[ChatMessage], str | None]: + """Prepare input from messages and convert system/developer messages to instructions.""" + result: list[ChatMessage] = [] + instructions_list: list[str] = [] + instructions: str | None = None + + # System/developer messages are turned into instructions, since there is no such message roles in Azure AI. + for message in messages: + if message.role.value in ["system", "developer"]: + for text_content in [content for content in message.contents if isinstance(content, TextContent)]: + instructions_list.append(text_content.text) + else: + result.append(message) + + if len(instructions_list) > 0: + instructions = "".join(instructions_list) + + return result, instructions + + async def prepare_options( + self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions + ) -> dict[str, Any]: + chat_options.store = bool(chat_options.store or chat_options.store is None) + prepared_messages, instructions = self._prepare_input(messages) + run_options = await super().prepare_options(prepared_messages, chat_options) + agent_reference = await self._get_agent_reference_or_create(run_options, instructions) + + run_options["extra_body"] = {"agent": agent_reference} + + conversation_id = chat_options.conversation_id or self.conversation_id + + # Handle different conversation ID formats + if conversation_id: + if conversation_id.startswith("resp_"): + # For response IDs, set previous_response_id and remove conversation property + run_options.pop("conversation", None) + run_options["previous_response_id"] = conversation_id + elif conversation_id.startswith("conv_"): + # For conversation IDs, set conversation and remove previous_response_id property + run_options.pop("previous_response_id", None) + run_options["conversation"] = conversation_id + + # Remove properties that are not supported on request level + # but were configured on agent level + exclude = ["model", "tools", "response_format"] + + for property in exclude: + run_options.pop(property, None) + + return run_options + + async def initialize_client(self) -> None: + """Initialize OpenAI client asynchronously.""" + self.client = await self.project_client.get_openai_client() # type: ignore + + def _update_agent_name(self, agent_name: str | None) -> None: + """Update the agent name in the chat client. + + Args: + agent_name: The new name for the agent. + """ + # This is a no-op in the base class, but can be overridden by subclasses + # to update the agent name in the client. + if agent_name and not self.agent_name: + self.agent_name = agent_name + + def get_mcp_tool(self, tool: HostedMCPTool) -> Any: + """Get MCP tool from HostedMCPTool.""" + mcp = MCPTool(server_label=tool.name.replace(" ", "_"), server_url=str(tool.url)) + + if tool.allowed_tools: + mcp["allowed_tools"] = list(tool.allowed_tools) + + if tool.approval_mode: + match tool.approval_mode: + case str(): + mcp["require_approval"] = "always" if tool.approval_mode == "always_require" else "never" + case _: + if always_require_approvals := tool.approval_mode.get("always_require_approval"): + mcp["require_approval"] = {"always": {"tool_names": list(always_require_approvals)}} + if never_require_approvals := tool.approval_mode.get("never_require_approval"): + mcp["require_approval"] = {"never": {"tool_names": list(never_require_approvals)}} + + return mcp + + def get_conversation_id( + self, response: OpenAIResponse | ParsedResponse[BaseModel], store: bool | None + ) -> str | None: + """Get the conversation ID from the response if store is True.""" + if store: + # If conversation ID exists, it means that we operate with conversation + # so we use conversation ID as input and output. + if response.conversation and response.conversation.id: + return response.conversation.id + # If conversation ID doesn't exist, we operate with responses + # so we use response ID as input and output. + return response.id + return None diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml index fa15e4c074..1b2ff49ed9 100644 --- a/python/packages/azure-ai/pyproject.toml +++ b/python/packages/azure-ai/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251111" +version = "1.0.0b251112" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core", - "azure-ai-projects >= 1.0.0b11", + "azure-ai-projects >= 2.0.0b1", "azure-ai-agents == 1.2.0b5", "aiohttp", ] diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py new file mode 100644 index 0000000000..576218f270 --- /dev/null +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -0,0 +1,743 @@ +# Copyright (c) Microsoft. All rights reserved. + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework import ( + ChatClientProtocol, + ChatMessage, + ChatOptions, + Role, + TextContent, +) +from agent_framework.exceptions import ServiceInitializationError +from azure.ai.projects.models import ( + ResponseTextFormatConfigurationJsonSchema, +) +from openai.types.responses.parsed_response import ParsedResponse +from openai.types.responses.response import Response as OpenAIResponse +from pydantic import BaseModel, ConfigDict, ValidationError + +from agent_framework_azure_ai import AzureAIClient, AzureAISettings + + +def create_test_azure_ai_client( + mock_project_client: MagicMock, + agent_name: str | None = None, + agent_version: str | None = None, + conversation_id: str | None = None, + azure_ai_settings: AzureAISettings | None = None, + should_close_client: bool = False, + use_latest_version: bool | None = None, +) -> AzureAIClient: + """Helper function to create AzureAIClient instances for testing, bypassing normal validation.""" + if azure_ai_settings is None: + azure_ai_settings = AzureAISettings(env_file_path="test.env") + + # Create client instance directly + client = object.__new__(AzureAIClient) + + # Set attributes directly + client.project_client = mock_project_client + client.credential = None + client.agent_name = agent_name + client.agent_version = agent_version + client.use_latest_version = use_latest_version + client.model_id = azure_ai_settings.model_deployment_name + client.conversation_id = conversation_id + client._should_close_client = should_close_client # type: ignore + client.additional_properties = {} + client.middleware = None + + # Mock the OpenAI client attribute + mock_openai_client = MagicMock() + mock_openai_client.conversations = MagicMock() + mock_openai_client.conversations.create = AsyncMock() + client.client = mock_openai_client + + return client + + +def test_azure_ai_settings_init(azure_ai_unit_test_env: dict[str, str]) -> None: + """Test AzureAISettings initialization.""" + settings = AzureAISettings() + + assert settings.project_endpoint == azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] + assert settings.model_deployment_name == azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + + +def test_azure_ai_settings_init_with_explicit_values() -> None: + """Test AzureAISettings initialization with explicit values.""" + settings = AzureAISettings( + project_endpoint="https://custom-endpoint.com/", + model_deployment_name="custom-model", + ) + + assert settings.project_endpoint == "https://custom-endpoint.com/" + assert settings.model_deployment_name == "custom-model" + + +def test_azure_ai_client_init_with_project_client(mock_project_client: MagicMock) -> None: + """Test AzureAIClient initialization with existing project_client.""" + with patch("agent_framework_azure_ai._client.AzureAISettings") as mock_settings: + mock_settings.return_value.project_endpoint = None + mock_settings.return_value.model_deployment_name = "test-model" + + client = AzureAIClient( + project_client=mock_project_client, + agent_name="test-agent", + agent_version="1.0", + ) + + assert client.project_client is mock_project_client + assert client.agent_name == "test-agent" + assert client.agent_version == "1.0" + assert not client._should_close_client # type: ignore + assert isinstance(client, ChatClientProtocol) + + +def test_azure_ai_client_init_auto_create_client( + azure_ai_unit_test_env: dict[str, str], + mock_azure_credential: MagicMock, +) -> None: + """Test AzureAIClient initialization with auto-created project_client.""" + with patch("agent_framework_azure_ai._client.AIProjectClient") as mock_ai_project_client: + mock_project_client = MagicMock() + mock_ai_project_client.return_value = mock_project_client + + client = AzureAIClient( + project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], + model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + async_credential=mock_azure_credential, + agent_name="test-agent", + ) + + assert client.project_client is mock_project_client + assert client.agent_name == "test-agent" + assert client._should_close_client # type: ignore + + # Verify AIProjectClient was called with correct parameters + mock_ai_project_client.assert_called_once() + + +def test_azure_ai_client_init_missing_project_endpoint() -> None: + """Test AzureAIClient initialization when project_endpoint is missing and no project_client provided.""" + with patch("agent_framework_azure_ai._client.AzureAISettings") as mock_settings: + mock_settings.return_value.project_endpoint = None + mock_settings.return_value.model_deployment_name = "test-model" + + with pytest.raises(ServiceInitializationError, match="Azure AI project endpoint is required"): + AzureAIClient(async_credential=MagicMock()) + + +def test_azure_ai_client_init_missing_credential(azure_ai_unit_test_env: dict[str, str]) -> None: + """Test AzureAIClient.__init__ when async_credential is missing and no project_client provided.""" + with pytest.raises( + ServiceInitializationError, match="Azure credential is required when project_client is not provided" + ): + AzureAIClient( + project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], + model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ) + + +def test_azure_ai_client_init_validation_error(mock_azure_credential: MagicMock) -> None: + """Test that ValidationError in AzureAISettings is properly handled.""" + with patch("agent_framework_azure_ai._client.AzureAISettings") as mock_settings: + mock_settings.side_effect = ValidationError.from_exception_data("test", []) + + with pytest.raises(ServiceInitializationError, match="Failed to create Azure AI settings"): + AzureAIClient(async_credential=mock_azure_credential) + + +async def test_azure_ai_client_get_agent_reference_or_create_existing_version( + mock_project_client: MagicMock, +) -> None: + """Test _get_agent_reference_or_create when agent_version is already provided.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="existing-agent", agent_version="1.0") + + agent_ref = await client._get_agent_reference_or_create({}, None) # type: ignore + + assert agent_ref == {"name": "existing-agent", "version": "1.0", "type": "agent_reference"} + + +async def test_azure_ai_client_get_agent_reference_or_create_new_agent( + mock_project_client: MagicMock, + azure_ai_unit_test_env: dict[str, str], +) -> None: + """Test _get_agent_reference_or_create when creating a new agent.""" + azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]) + client = create_test_azure_ai_client( + mock_project_client, agent_name="new-agent", azure_ai_settings=azure_ai_settings + ) + + # Mock agent creation response + mock_agent = MagicMock() + mock_agent.name = "new-agent" + mock_agent.version = "1.0" + mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) + + run_options = {"model": azure_ai_settings.model_deployment_name} + agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore + + assert agent_ref == {"name": "new-agent", "version": "1.0", "type": "agent_reference"} + assert client.agent_name == "new-agent" + assert client.agent_version == "1.0" + + +async def test_azure_ai_client_get_agent_reference_missing_model( + mock_project_client: MagicMock, +) -> None: + """Test _get_agent_reference_or_create when model is missing for agent creation.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") + + with pytest.raises(ServiceInitializationError, match="Model deployment name is required for agent creation"): + await client._get_agent_reference_or_create({}, None) # type: ignore + + +async def test_azure_ai_client_prepare_input_with_system_messages( + mock_project_client: MagicMock, +) -> None: + """Test _prepare_input converts system/developer messages to instructions.""" + client = create_test_azure_ai_client(mock_project_client) + + messages = [ + ChatMessage(role=Role.SYSTEM, contents=[TextContent(text="You are a helpful assistant.")]), + ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")]), + ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text="System response")]), + ] + + result_messages, instructions = client._prepare_input(messages) # type: ignore + + assert len(result_messages) == 2 + assert result_messages[0].role == Role.USER + assert result_messages[1].role == Role.ASSISTANT + assert instructions == "You are a helpful assistant." + + +async def test_azure_ai_client_prepare_input_no_system_messages( + mock_project_client: MagicMock, +) -> None: + """Test _prepare_input with no system/developer messages.""" + client = create_test_azure_ai_client(mock_project_client) + + messages = [ + ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")]), + ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text="Hi there!")]), + ] + + result_messages, instructions = client._prepare_input(messages) # type: ignore + + assert len(result_messages) == 2 + assert instructions is None + + +async def test_azure_ai_client_prepare_options_basic(mock_project_client: MagicMock) -> None: + """Test prepare_options basic functionality.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") + + messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])] + chat_options = ChatOptions() + + with ( + patch.object(client.__class__.__bases__[0], "prepare_options", return_value={"model": "test-model"}), + patch.object( + client, + "_get_agent_reference_or_create", + return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, + ), + ): + run_options = await client.prepare_options(messages, chat_options) + + assert "extra_body" in run_options + assert run_options["extra_body"]["agent"]["name"] == "test-agent" + + +async def test_azure_ai_client_initialize_client(mock_project_client: MagicMock) -> None: + """Test initialize_client method.""" + client = create_test_azure_ai_client(mock_project_client) + + mock_openai_client = MagicMock() + mock_project_client.get_openai_client = AsyncMock(return_value=mock_openai_client) + + await client.initialize_client() + + assert client.client is mock_openai_client + mock_project_client.get_openai_client.assert_called_once() + + +def test_azure_ai_client_update_agent_name(mock_project_client: MagicMock) -> None: + """Test _update_agent_name method.""" + client = create_test_azure_ai_client(mock_project_client) + + # Test updating agent name when current is None + with patch.object(client, "_update_agent_name") as mock_update: + mock_update.return_value = None + client._update_agent_name("new-agent") # type: ignore + mock_update.assert_called_once_with("new-agent") + + # Test behavior when agent name is updated + assert client.agent_name is None # Should remain None since we didn't actually update + client.agent_name = "test-agent" # Manually set for the test + + # Test with None input + with patch.object(client, "_update_agent_name") as mock_update: + mock_update.return_value = None + client._update_agent_name(None) # type: ignore + mock_update.assert_called_once_with(None) + + +async def test_azure_ai_client_async_context_manager(mock_project_client: MagicMock) -> None: + """Test async context manager functionality.""" + client = create_test_azure_ai_client(mock_project_client, should_close_client=True) + + mock_project_client.close = AsyncMock() + + async with client as ctx_client: + assert ctx_client is client + + # Should call close after exiting context + mock_project_client.close.assert_called_once() + + +async def test_azure_ai_client_close_method(mock_project_client: MagicMock) -> None: + """Test close method.""" + client = create_test_azure_ai_client(mock_project_client, should_close_client=True) + + mock_project_client.close = AsyncMock() + + await client.close() + + mock_project_client.close.assert_called_once() + + +async def test_azure_ai_client_close_client_when_should_close_false(mock_project_client: MagicMock) -> None: + """Test _close_client_if_needed when should_close_client is False.""" + client = create_test_azure_ai_client(mock_project_client, should_close_client=False) + + mock_project_client.close = AsyncMock() + + await client._close_client_if_needed() # type: ignore + + # Should not call close when should_close_client is False + mock_project_client.close.assert_not_called() + + +async def test_azure_ai_client_agent_creation_with_instructions( + mock_project_client: MagicMock, +) -> None: + """Test agent creation with combined instructions.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") + + # Mock agent creation response + mock_agent = MagicMock() + mock_agent.name = "test-agent" + mock_agent.version = "1.0" + mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) + + run_options = {"model": "test-model", "instructions": "Option instructions. "} + messages_instructions = "Message instructions. " + + await client._get_agent_reference_or_create(run_options, messages_instructions) # type: ignore + + # Verify agent was created with combined instructions + call_args = mock_project_client.agents.create_version.call_args + assert call_args[1]["definition"].instructions == "Message instructions. Option instructions. " + + +async def test_azure_ai_client_agent_creation_with_tools( + mock_project_client: MagicMock, +) -> None: + """Test agent creation with tools.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") + + # Mock agent creation response + mock_agent = MagicMock() + mock_agent.name = "test-agent" + mock_agent.version = "1.0" + mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) + + test_tools = [{"type": "function", "function": {"name": "test_tool"}}] + run_options = {"model": "test-model", "tools": test_tools} + + await client._get_agent_reference_or_create(run_options, None) # type: ignore + + # Verify agent was created with tools + call_args = mock_project_client.agents.create_version.call_args + assert call_args[1]["definition"].tools == test_tools + + +async def test_azure_ai_client_use_latest_version_existing_agent( + mock_project_client: MagicMock, +) -> None: + """Test _get_agent_reference_or_create when use_latest_version=True and agent exists.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="existing-agent", use_latest_version=True) + + # Mock existing agent response + mock_existing_agent = MagicMock() + mock_existing_agent.name = "existing-agent" + mock_existing_agent.versions.latest.version = "2.5" + mock_project_client.agents.get = AsyncMock(return_value=mock_existing_agent) + + run_options = {"model": "test-model"} + agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore + + # Verify existing agent was retrieved and used + mock_project_client.agents.get.assert_called_once_with("existing-agent") + mock_project_client.agents.create_version.assert_not_called() + + assert agent_ref == {"name": "existing-agent", "version": "2.5", "type": "agent_reference"} + assert client.agent_name == "existing-agent" + assert client.agent_version == "2.5" + + +async def test_azure_ai_client_use_latest_version_agent_not_found( + mock_project_client: MagicMock, +) -> None: + """Test _get_agent_reference_or_create when use_latest_version=True but agent doesn't exist.""" + from azure.core.exceptions import ResourceNotFoundError + + client = create_test_azure_ai_client(mock_project_client, agent_name="non-existing-agent", use_latest_version=True) + + # Mock ResourceNotFoundError when trying to retrieve agent + mock_project_client.agents.get = AsyncMock(side_effect=ResourceNotFoundError("Agent not found")) + + # Mock agent creation response for fallback + mock_created_agent = MagicMock() + mock_created_agent.name = "non-existing-agent" + mock_created_agent.version = "1.0" + mock_project_client.agents.create_version = AsyncMock(return_value=mock_created_agent) + + run_options = {"model": "test-model"} + agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore + + # Verify retrieval was attempted and creation was used as fallback + mock_project_client.agents.get.assert_called_once_with("non-existing-agent") + mock_project_client.agents.create_version.assert_called_once() + + assert agent_ref == {"name": "non-existing-agent", "version": "1.0", "type": "agent_reference"} + assert client.agent_name == "non-existing-agent" + assert client.agent_version == "1.0" + + +async def test_azure_ai_client_use_latest_version_false( + mock_project_client: MagicMock, +) -> None: + """Test _get_agent_reference_or_create when use_latest_version=False (default behavior).""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", use_latest_version=False) + + # Mock agent creation response + mock_created_agent = MagicMock() + mock_created_agent.name = "test-agent" + mock_created_agent.version = "1.0" + mock_project_client.agents.create_version = AsyncMock(return_value=mock_created_agent) + + run_options = {"model": "test-model"} + agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore + + # Verify retrieval was not attempted and creation was used directly + mock_project_client.agents.get.assert_not_called() + mock_project_client.agents.create_version.assert_called_once() + + assert agent_ref == {"name": "test-agent", "version": "1.0", "type": "agent_reference"} + + +async def test_azure_ai_client_use_latest_version_with_existing_agent_version( + mock_project_client: MagicMock, +) -> None: + """Test that use_latest_version is ignored when agent_version is already provided.""" + client = create_test_azure_ai_client( + mock_project_client, agent_name="test-agent", agent_version="3.0", use_latest_version=True + ) + + agent_ref = await client._get_agent_reference_or_create({}, None) # type: ignore + + # Verify neither retrieval nor creation was attempted since version is already set + mock_project_client.agents.get.assert_not_called() + mock_project_client.agents.create_version.assert_not_called() + + assert agent_ref == {"name": "test-agent", "version": "3.0", "type": "agent_reference"} + + +class ResponseFormatModel(BaseModel): + """Test Pydantic model for response format testing.""" + + name: str + value: int + description: str + model_config = ConfigDict(extra="forbid") + + +async def test_azure_ai_client_agent_creation_with_response_format( + mock_project_client: MagicMock, +) -> None: + """Test agent creation with response_format configuration.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") + + # Mock agent creation response + mock_agent = MagicMock() + mock_agent.name = "test-agent" + mock_agent.version = "1.0" + mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) + + run_options = {"model": "test-model", "response_format": ResponseFormatModel} + + await client._get_agent_reference_or_create(run_options, None) # type: ignore + + # Verify agent was created with response format configuration + call_args = mock_project_client.agents.create_version.call_args + created_definition = call_args[1]["definition"] + + # Check that text format configuration was set + assert hasattr(created_definition, "text") + assert created_definition.text is not None + + # Check that the format is a ResponseTextFormatConfigurationJsonSchema + assert hasattr(created_definition.text, "format") + format_config = created_definition.text.format + assert isinstance(format_config, ResponseTextFormatConfigurationJsonSchema) + + # Check the schema name matches the model class name + assert format_config.name == "ResponseFormatModel" + + # Check that schema was generated correctly + assert format_config.schema is not None + schema = format_config.schema + assert "properties" in schema + assert "name" in schema["properties"] + assert "value" in schema["properties"] + assert "description" in schema["properties"] + + +async def test_azure_ai_client_prepare_options_excludes_response_format( + mock_project_client: MagicMock, +) -> None: + """Test that prepare_options excludes response_format from final run options.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") + + messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])] + chat_options = ChatOptions() + + with ( + patch.object( + client.__class__.__bases__[0], + "prepare_options", + return_value={"model": "test-model", "response_format": ResponseFormatModel}, + ), + patch.object( + client, + "_get_agent_reference_or_create", + return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, + ), + ): + run_options = await client.prepare_options(messages, chat_options) + + # response_format should be excluded from final run options + assert "response_format" not in run_options + # But extra_body should contain agent reference + assert "extra_body" in run_options + assert run_options["extra_body"]["agent"]["name"] == "test-agent" + + +async def test_azure_ai_client_prepare_options_with_resp_conversation_id( + mock_project_client: MagicMock, +) -> None: + """Test prepare_options with conversation ID starting with 'resp_'.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") + + messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])] + chat_options = ChatOptions(conversation_id="resp_12345") + + with ( + patch.object( + client.__class__.__bases__[0], + "prepare_options", + return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"}, + ), + patch.object( + client, + "_get_agent_reference_or_create", + return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, + ), + ): + run_options = await client.prepare_options(messages, chat_options) + + # Should set previous_response_id and remove conversation property + assert run_options["previous_response_id"] == "resp_12345" + assert "conversation" not in run_options + + +async def test_azure_ai_client_prepare_options_with_conv_conversation_id( + mock_project_client: MagicMock, +) -> None: + """Test prepare_options with conversation ID starting with 'conv_'.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") + + messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])] + chat_options = ChatOptions(conversation_id="conv_67890") + + with ( + patch.object( + client.__class__.__bases__[0], + "prepare_options", + return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"}, + ), + patch.object( + client, + "_get_agent_reference_or_create", + return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, + ), + ): + run_options = await client.prepare_options(messages, chat_options) + + # Should set conversation and remove previous_response_id property + assert run_options["conversation"] == "conv_67890" + assert "previous_response_id" not in run_options + + +async def test_azure_ai_client_prepare_options_with_client_conversation_id( + mock_project_client: MagicMock, +) -> None: + """Test prepare_options using client's default conversation ID when chat options don't have one.""" + client = create_test_azure_ai_client( + mock_project_client, agent_name="test-agent", agent_version="1.0", conversation_id="resp_client_default" + ) + + messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])] + chat_options = ChatOptions() # No conversation_id specified + + with ( + patch.object( + client.__class__.__bases__[0], + "prepare_options", + return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"}, + ), + patch.object( + client, + "_get_agent_reference_or_create", + return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, + ), + ): + run_options = await client.prepare_options(messages, chat_options) + + # Should use client's default conversation_id and set previous_response_id + assert run_options["previous_response_id"] == "resp_client_default" + assert "conversation" not in run_options + + +def test_get_conversation_id_with_store_true_and_conversation_id() -> None: + """Test get_conversation_id returns conversation ID when store is True and conversation exists.""" + client = create_test_azure_ai_client(MagicMock()) + + # Mock OpenAI response with conversation + mock_response = MagicMock(spec=OpenAIResponse) + mock_response.id = "resp_12345" + mock_conversation = MagicMock() + mock_conversation.id = "conv_67890" + mock_response.conversation = mock_conversation + + result = client.get_conversation_id(mock_response, store=True) + + assert result == "conv_67890" + + +def test_get_conversation_id_with_store_true_and_no_conversation() -> None: + """Test get_conversation_id returns response ID when store is True and no conversation exists.""" + client = create_test_azure_ai_client(MagicMock()) + + # Mock OpenAI response without conversation + mock_response = MagicMock(spec=OpenAIResponse) + mock_response.id = "resp_12345" + mock_response.conversation = None + + result = client.get_conversation_id(mock_response, store=True) + + assert result == "resp_12345" + + +def test_get_conversation_id_with_store_true_and_empty_conversation_id() -> None: + """Test get_conversation_id returns response ID when store is True and conversation ID is empty.""" + client = create_test_azure_ai_client(MagicMock()) + + # Mock OpenAI response with conversation but empty ID + mock_response = MagicMock(spec=OpenAIResponse) + mock_response.id = "resp_12345" + mock_conversation = MagicMock() + mock_conversation.id = "" + mock_response.conversation = mock_conversation + + result = client.get_conversation_id(mock_response, store=True) + + assert result == "resp_12345" + + +def test_get_conversation_id_with_store_false() -> None: + """Test get_conversation_id returns None when store is False.""" + client = create_test_azure_ai_client(MagicMock()) + + # Mock OpenAI response with conversation + mock_response = MagicMock(spec=OpenAIResponse) + mock_response.id = "resp_12345" + mock_conversation = MagicMock() + mock_conversation.id = "conv_67890" + mock_response.conversation = mock_conversation + + result = client.get_conversation_id(mock_response, store=False) + + assert result is None + + +def test_get_conversation_id_with_parsed_response_and_store_true() -> None: + """Test get_conversation_id works with ParsedResponse when store is True.""" + client = create_test_azure_ai_client(MagicMock()) + + # Mock ParsedResponse with conversation + mock_response = MagicMock(spec=ParsedResponse[BaseModel]) + mock_response.id = "resp_parsed_12345" + mock_conversation = MagicMock() + mock_conversation.id = "conv_parsed_67890" + mock_response.conversation = mock_conversation + + result = client.get_conversation_id(mock_response, store=True) + + assert result == "conv_parsed_67890" + + +def test_get_conversation_id_with_parsed_response_no_conversation() -> None: + """Test get_conversation_id returns response ID with ParsedResponse when no conversation exists.""" + client = create_test_azure_ai_client(MagicMock()) + + # Mock ParsedResponse without conversation + mock_response = MagicMock(spec=ParsedResponse[BaseModel]) + mock_response.id = "resp_parsed_12345" + mock_response.conversation = None + + result = client.get_conversation_id(mock_response, store=True) + + assert result == "resp_parsed_12345" + + +@pytest.fixture +def mock_project_client() -> MagicMock: + """Fixture that provides a mock AIProjectClient.""" + mock_client = MagicMock() + + # Mock agents property + mock_client.agents = MagicMock() + mock_client.agents.create_version = AsyncMock() + + # Mock conversations property + mock_client.conversations = MagicMock() + mock_client.conversations.create = AsyncMock() + + # Mock telemetry property + mock_client.telemetry = MagicMock() + mock_client.telemetry.get_application_insights_connection_string = AsyncMock() + + # Mock get_openai_client method + mock_client.get_openai_client = AsyncMock() + + # Mock close method + mock_client.close = AsyncMock() + + return mock_client diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index 8c0a5047e4..b4ecefa2a6 100644 --- a/python/packages/chatkit/pyproject.toml +++ b/python/packages/chatkit/pyproject.toml @@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251111" +version = "1.0.0b251112" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index 9872355b4e..bedaa1c6ce 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251111" +version = "1.0.0b251112" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 116148b80f..630e7f8709 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -564,10 +564,6 @@ class BaseChatClient(SerializationMixin, ABC): # Validate that store is True when conversation_id is set if chat_options.conversation_id is not None and chat_options.store is not True: - logger.warning( - "When conversation_id is set, store must be True for service-managed threads. " - "Automatically setting store=True." - ) chat_options.store = True if chat_options.instructions: @@ -663,10 +659,6 @@ class BaseChatClient(SerializationMixin, ABC): # Validate that store is True when conversation_id is set if chat_options.conversation_id is not None and chat_options.store is not True: - logger.warning( - "When conversation_id is set, store must be True for service-managed threads. " - "Automatically setting store=True." - ) chat_options.store = True if chat_options.instructions: diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 117c9efe52..6edd258e15 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1636,7 +1636,7 @@ def _handle_function_calls_response( # this runs in every but the first run # we need to keep track of all function call messages fcc_messages.extend(response.messages) - if getattr(kwargs.get("chat_options"), "store", False): + if response.conversation_id is not None: prepped_messages.clear() prepped_messages.append(result_message) else: @@ -1839,7 +1839,7 @@ def _handle_function_calls_streaming_response( # this runs in every but the first run # we need to keep track of all function call messages fcc_messages.extend(response.messages) - if getattr(kwargs.get("chat_options"), "store", False): + if response.conversation_id is not None: prepped_messages.clear() prepped_messages.append(result_message) else: diff --git a/python/packages/core/agent_framework/azure/__init__.py b/python/packages/core/agent_framework/azure/__init__.py index b18be24f51..c1b64f2117 100644 --- a/python/packages/core/agent_framework/azure/__init__.py +++ b/python/packages/core/agent_framework/azure/__init__.py @@ -9,6 +9,7 @@ _IMPORTS: dict[str, tuple[str, str]] = { "AgentFunctionApp": ("agent_framework_azurefunctions", "azurefunctions"), "AgentResponseCallbackProtocol": ("agent_framework_azurefunctions", "azurefunctions"), "AzureAIAgentClient": ("agent_framework_azure_ai", "azure-ai"), + "AzureAIClient": ("agent_framework_azure_ai", "azure-ai"), "AzureOpenAIAssistantsClient": ("agent_framework.azure._assistants_client", "core"), "AzureOpenAIChatClient": ("agent_framework.azure._chat_client", "core"), "AzureAISettings": ("agent_framework_azure_ai", "azure-ai"), diff --git a/python/packages/core/agent_framework/azure/__init__.pyi b/python/packages/core/agent_framework/azure/__init__.pyi index 931eaca645..aba582b5b5 100644 --- a/python/packages/core/agent_framework/azure/__init__.pyi +++ b/python/packages/core/agent_framework/azure/__init__.pyi @@ -1,6 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. -from agent_framework_azure_ai import AzureAIAgentClient, AzureAISettings +from agent_framework_azure_ai import AzureAIAgentClient, AzureAIClient, AzureAISettings from agent_framework_azurefunctions import ( AgentCallbackContext, AgentFunctionApp, @@ -19,6 +19,7 @@ __all__ = [ "AgentFunctionApp", "AgentResponseCallbackProtocol", "AzureAIAgentClient", + "AzureAIClient", "AzureAISettings", "AzureOpenAIAssistantsClient", "AzureOpenAIChatClient", diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index 8a28075e62..6255a6b8db 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -161,7 +161,8 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): async def close(self) -> None: """Clean up any assistants we created.""" if self._should_delete_assistant and self.assistant_id is not None: - await self.client.beta.assistants.delete(self.assistant_id) + client = await self.ensure_client() + await client.beta.assistants.delete(self.assistant_id) object.__setattr__(self, "assistant_id", None) object.__setattr__(self, "_should_delete_assistant", False) @@ -215,7 +216,11 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): """ # If no assistant is provided, create a temporary assistant if self.assistant_id is None: - created_assistant = await self.client.beta.assistants.create(name=self.assistant_name, model=self.model_id) + if not self.model_id: + raise ServiceInitializationError("Parameter 'model_id' is required for assistant creation.") + + client = await self.ensure_client() + created_assistant = await client.beta.assistants.create(name=self.assistant_name, model=self.model_id) self.assistant_id = created_assistant.id self._should_delete_assistant = True @@ -233,6 +238,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): Returns: tuple: (stream, final_thread_id) """ + client = await self.ensure_client() # Get any active run for this thread thread_run = await self._get_active_thread_run(thread_id) @@ -240,7 +246,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): if thread_run is not None and tool_run_id is not None and tool_run_id == thread_run.id and tool_outputs: # There's an active run and we have tool results to submit, so submit the results. - stream = self.client.beta.threads.runs.submit_tool_outputs_stream( # type: ignore[reportDeprecated] + stream = client.beta.threads.runs.submit_tool_outputs_stream( # type: ignore[reportDeprecated] run_id=tool_run_id, thread_id=thread_run.thread_id, tool_outputs=tool_outputs ) final_thread_id = thread_run.thread_id @@ -249,7 +255,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): final_thread_id = await self._prepare_thread(thread_id, thread_run, run_options) # Now create a new run and stream the results. - stream = self.client.beta.threads.runs.stream( # type: ignore[reportDeprecated] + stream = client.beta.threads.runs.stream( # type: ignore[reportDeprecated] assistant_id=assistant_id, thread_id=final_thread_id, **run_options ) @@ -257,19 +263,21 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): async def _get_active_thread_run(self, thread_id: str | None) -> Run | None: """Get any active run for the given thread.""" + client = await self.ensure_client() if thread_id is None: return None - async for run in self.client.beta.threads.runs.list(thread_id=thread_id, limit=1, order="desc"): # type: ignore[reportDeprecated] + async for run in client.beta.threads.runs.list(thread_id=thread_id, limit=1, order="desc"): # type: ignore[reportDeprecated] if run.status not in ["completed", "cancelled", "failed", "expired"]: return run return None async def _prepare_thread(self, thread_id: str | None, thread_run: Run | None, run_options: dict[str, Any]) -> str: """Prepare the thread for a new run, creating or cleaning up as needed.""" + client = await self.ensure_client() if thread_id is None: # No thread ID was provided, so create a new thread. - thread = await self.client.beta.threads.create( # type: ignore[reportDeprecated] + thread = await client.beta.threads.create( # type: ignore[reportDeprecated] messages=run_options["additional_messages"], tool_resources=run_options.get("tool_resources"), metadata=run_options.get("metadata"), @@ -280,7 +288,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient): if thread_run is not None: # There was an active run; we need to cancel it before starting a new run. - await self.client.beta.threads.runs.cancel(run_id=thread_run.id, thread_id=thread_id) # type: ignore[reportDeprecated] + await client.beta.threads.runs.cancel(run_id=thread_run.id, thread_id=thread_id) # type: ignore[reportDeprecated] return thread_id diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index e6a4087508..02e0743e1b 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -69,10 +69,11 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient): chat_options: ChatOptions, **kwargs: Any, ) -> ChatResponse: + client = await self.ensure_client() options_dict = self._prepare_options(messages, chat_options) try: return self._create_chat_response( - await self.client.chat.completions.create(stream=False, **options_dict), chat_options + await client.chat.completions.create(stream=False, **options_dict), chat_options ) except BadRequestError as ex: if ex.code == "content_filter": @@ -97,10 +98,11 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient): chat_options: ChatOptions, **kwargs: Any, ) -> AsyncIterable[ChatResponseUpdate]: + client = await self.ensure_client() options_dict = self._prepare_options(messages, chat_options) options_dict["stream_options"] = {"include_usage": True} try: - async for chunk in await self.client.chat.completions.create(stream=True, **options_dict): + async for chunk in await client.chat.completions.create(stream=True, **options_dict): if len(chunk.choices) == 0 and chunk.usage is None: continue yield self._create_chat_response_update(chunk) diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 149fe4bfac..447333447a 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -89,23 +89,24 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): chat_options: ChatOptions, **kwargs: Any, ) -> ChatResponse: - options_dict = self._prepare_options(messages, chat_options) + client = await self.ensure_client() + run_options = await self.prepare_options(messages, chat_options) try: - if not chat_options.response_format: - response = await self.client.responses.create( + response_format = run_options.pop("response_format", None) + if not response_format: + response = await client.responses.create( stream=False, - **options_dict, + **run_options, ) - chat_options.conversation_id = response.id if chat_options.store is True else None + chat_options.conversation_id = self.get_conversation_id(response, chat_options.store) return self._create_response_content(response, chat_options=chat_options) # create call does not support response_format, so we need to handle it via parse call - resp_format = chat_options.response_format - parsed_response: ParsedResponse[BaseModel] = await self.client.responses.parse( - text_format=resp_format, + parsed_response: ParsedResponse[BaseModel] = await client.responses.parse( + text_format=response_format, stream=False, - **options_dict, + **run_options, ) - chat_options.conversation_id = parsed_response.id if chat_options.store is True else None + chat_options.conversation_id = self.get_conversation_id(parsed_response, chat_options.store) return self._create_response_content(parsed_response, chat_options=chat_options) except BadRequestError as ex: if ex.code == "content_filter": @@ -130,13 +131,15 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): chat_options: ChatOptions, **kwargs: Any, ) -> AsyncIterable[ChatResponseUpdate]: - options_dict = self._prepare_options(messages, chat_options) + client = await self.ensure_client() + run_options = await self.prepare_options(messages, chat_options) function_call_ids: dict[int, tuple[str, str]] = {} # output_index: (call_id, name) try: - if not chat_options.response_format: - response = await self.client.responses.create( + response_format = run_options.pop("response_format", None) + if not response_format: + response = await client.responses.create( stream=True, - **options_dict, + **run_options, ) async for chunk in response: update = self._create_streaming_response_content( @@ -145,9 +148,9 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): yield update return # create call does not support response_format, so we need to handle it via stream call - async with self.client.responses.stream( - text_format=chat_options.response_format, - **options_dict, + async with client.responses.stream( + text_format=response_format, + **run_options, ) as response: async for chunk in response: update = self._create_streaming_response_content( @@ -170,6 +173,12 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): inner_exception=ex, ) from ex + def get_conversation_id( + self, response: OpenAIResponse | ParsedResponse[BaseModel], store: bool | None + ) -> str | None: + """Get the conversation ID from the response if store is True.""" + return response.id if store else None + # region Prep methods def _tools_to_response_tools( @@ -180,31 +189,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): if isinstance(tool, ToolProtocol): match tool: case HostedMCPTool(): - mcp: Mcp = { - "type": "mcp", - "server_label": tool.name.replace(" ", "_"), - "server_url": str(tool.url), - "server_description": tool.description, - "headers": tool.headers, - } - if tool.allowed_tools: - mcp["allowed_tools"] = list(tool.allowed_tools) - if tool.approval_mode: - match tool.approval_mode: - case str(): - mcp["require_approval"] = ( - "always" if tool.approval_mode == "always_require" else "never" - ) - case _: - if always_require_approvals := tool.approval_mode.get("always_require_approval"): - mcp["require_approval"] = { - "always": {"tool_names": list(always_require_approvals)} - } - if never_require_approvals := tool.approval_mode.get("never_require_approval"): - mcp["require_approval"] = { - "never": {"tool_names": list(never_require_approvals)} - } - response_tools.append(mcp) + response_tools.append(self.get_mcp_tool(tool)) case HostedCodeInterpreterTool(): tool_args: CodeInterpreterContainerCodeInterpreterToolAuto = {"type": "auto"} if tool.inputs: @@ -306,12 +291,36 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): response_tools.append(tool_dict) return response_tools - def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions) -> dict[str, Any]: + def get_mcp_tool(self, tool: HostedMCPTool) -> Any: + """Get MCP tool from HostedMCPTool.""" + mcp: Mcp = { + "type": "mcp", + "server_label": tool.name.replace(" ", "_"), + "server_url": str(tool.url), + "server_description": tool.description, + "headers": tool.headers, + } + if tool.allowed_tools: + mcp["allowed_tools"] = list(tool.allowed_tools) + if tool.approval_mode: + match tool.approval_mode: + case str(): + mcp["require_approval"] = "always" if tool.approval_mode == "always_require" else "never" + case _: + if always_require_approvals := tool.approval_mode.get("always_require_approval"): + mcp["require_approval"] = {"always": {"tool_names": list(always_require_approvals)}} + if never_require_approvals := tool.approval_mode.get("never_require_approval"): + mcp["require_approval"] = {"never": {"tool_names": list(never_require_approvals)}} + + return mcp + + async def prepare_options( + self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions + ) -> dict[str, Any]: """Take ChatOptions and create the specific options for Responses API.""" - options_dict: dict[str, Any] = chat_options.to_dict( + run_options: dict[str, Any] = chat_options.to_dict( exclude={ "type", - "response_format", # handled in inner get methods "presence_penalty", # not supported "frequency_penalty", # not supported "logit_bias", # not supported @@ -320,6 +329,10 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): "instructions", # already added as system message } ) + + if chat_options.response_format: + run_options["response_format"] = chat_options.response_format + translations = { "model_id": "model", "allow_multiple_tool_calls": "parallel_tool_calls", @@ -327,35 +340,37 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): "max_tokens": "max_output_tokens", } for old_key, new_key in translations.items(): - if old_key in options_dict and old_key != new_key: - options_dict[new_key] = options_dict.pop(old_key) + if old_key in run_options and old_key != new_key: + run_options[new_key] = run_options.pop(old_key) # tools if chat_options.tools is None: - options_dict.pop("parallel_tool_calls", None) + run_options.pop("parallel_tool_calls", None) else: - options_dict["tools"] = self._tools_to_response_tools(chat_options.tools) + run_options["tools"] = self._tools_to_response_tools(chat_options.tools) # model id - if not options_dict.get("model"): - options_dict["model"] = self.model_id + if not run_options.get("model"): + if not self.model_id: + raise ValueError("model_id must be a non-empty string") + run_options["model"] = self.model_id # messages request_input = self._prepare_chat_messages_for_request(messages) if not request_input: raise ServiceInvalidRequestError("Messages are required for chat completions") - options_dict["input"] = request_input + run_options["input"] = request_input # additional provider specific settings - if additional_properties := options_dict.pop("additional_properties", None): + if additional_properties := run_options.pop("additional_properties", None): for key, value in additional_properties.items(): if value is not None: - options_dict[key] = value - if "store" not in options_dict: - options_dict["store"] = False - if (tool_choice := options_dict.get("tool_choice")) and len(tool_choice.keys()) == 1: - options_dict["tool_choice"] = tool_choice["mode"] - return options_dict + run_options[key] = value + if "store" not in run_options: + run_options["store"] = False + if (tool_choice := run_options.get("tool_choice")) and len(tool_choice.keys()) == 1: + run_options["tool_choice"] = tool_choice["mode"] + return run_options def _prepare_chat_messages_for_request(self, chat_messages: Sequence[ChatMessage]) -> list[dict[str, Any]]: """Prepare the chat messages for a request. @@ -504,7 +519,6 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): # call_id for the result needs to be the same as the call_id for the function call args: dict[str, Any] = { "call_id": content.call_id, - "id": call_id_to_id.get(content.call_id), "type": "function_call_output", } if content.result: @@ -734,7 +748,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): "raw_representation": response, } if chat_options.store: - args["conversation_id"] = response.id + args["conversation_id"] = self.get_conversation_id(response, chat_options.store) if response.usage and (usage_details := self._usage_details_from_openai(response.usage)): args["usage_details"] = usage_details if structured_response: @@ -834,7 +848,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): contents.append(TextReasoningContent(text=event.text, raw_representation=event)) metadata.update(self._get_metadata_from_response(event)) case "response.completed": - conversation_id = event.response.id if chat_options.store is True else None + conversation_id = self.get_conversation_id(event.response, chat_options.store) model = event.response.model if event.response.usage: usage = self._usage_details_from_openai(event.response.usage) diff --git a/python/packages/core/agent_framework/openai/_shared.py b/python/packages/core/agent_framework/openai/_shared.py index bea58786c8..20c719e09e 100644 --- a/python/packages/core/agent_framework/openai/_shared.py +++ b/python/packages/core/agent_framework/openai/_shared.py @@ -127,18 +127,18 @@ class OpenAIBase(SerializationMixin): INJECTABLE: ClassVar[set[str]] = {"client"} - def __init__(self, *, client: AsyncOpenAI, model_id: str, **kwargs: Any) -> None: + def __init__(self, *, model_id: str | None = None, client: AsyncOpenAI | None = None, **kwargs: Any) -> None: """Initialize OpenAIBase. Keyword Args: client: The AsyncOpenAI client instance. - model_id: The AI model ID to use (non-empty, whitespace stripped). + model_id: The AI model ID to use. **kwargs: Additional keyword arguments. """ - if not model_id or not model_id.strip(): - raise ValueError("model_id must be a non-empty string") self.client = client - self.model_id = model_id.strip() + self.model_id = None + if model_id: + self.model_id = model_id.strip() # Call super().__init__() to continue MRO chain (e.g., BaseChatClient) # Extract known kwargs that belong to other base classes @@ -162,6 +162,21 @@ class OpenAIBase(SerializationMixin): for key, value in kwargs.items(): setattr(self, key, value) + async def initialize_client(self) -> None: + """Initialize OpenAI client asynchronously. + + Override in subclasses to initialize the OpenAI client asynchronously. + """ + pass + + async def ensure_client(self) -> AsyncOpenAI: + """Ensure OpenAI client is initialized.""" + await self.initialize_client() + if self.client is None: + raise ServiceInitializationError("OpenAI client is not initialized") + + return self.client + def _get_api_key( self, api_key: str | SecretStr | Callable[[], str | Awaitable[str]] | None ) -> str | Callable[[], str | Awaitable[str]] | None: diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 0dc26386c2..38aa9ba89b 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b251111" +version = "1.0.0b251112" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index 5ff4bb3de3..4700950439 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -1407,27 +1407,27 @@ def test_create_response_content_image_generation_fallback(): assert f"data:image/png;base64,{unrecognized_base64}" == content.uri -def test_prepare_options_store_parameter_handling() -> None: +async def test_prepare_options_store_parameter_handling() -> None: client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") messages = [ChatMessage(role="user", text="Test message")] test_conversation_id = "test-conversation-123" chat_options = ChatOptions(store=True, conversation_id=test_conversation_id) - options = client._prepare_options(messages, chat_options) # type: ignore + options = await client.prepare_options(messages, chat_options) assert options["store"] is True assert options["previous_response_id"] == test_conversation_id chat_options = ChatOptions(store=False, conversation_id="") - options = client._prepare_options(messages, chat_options) # type: ignore + options = await client.prepare_options(messages, chat_options) assert options["store"] is False chat_options = ChatOptions(store=None, conversation_id=None) - options = client._prepare_options(messages, chat_options) # type: ignore + options = await client.prepare_options(messages, chat_options) assert options["store"] is False assert "previous_response_id" not in options chat_options = ChatOptions() - options = client._prepare_options(messages, chat_options) # type: ignore + options = await client.prepare_options(messages, chat_options) assert options["store"] is False assert "previous_response_id" not in options diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index.js b/python/packages/devui/agent_framework_devui/ui/assets/index.js index 3744c1e10d..317e9e7349 100644 --- a/python/packages/devui/agent_framework_devui/ui/assets/index.js +++ b/python/packages/devui/agent_framework_devui/ui/assets/index.js @@ -14,7 +14,7 @@ function gE(e,n){for(var s=0;s>>1,T=A[P];if(0>>1;Pl(Z,$))rel(de,Z)?(A[P]=de,A[re]=$,P=re):(A[P]=Z,A[W]=$,P=W);else if(rel(de,$))A[P]=de,A[re]=$,P=re;else break e}}return I}function l(A,I){var $=A.sortIndex-I.sortIndex;return $!==0?$:A.id-I.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var c=performance;e.unstable_now=function(){return c.now()}}else{var d=Date,f=d.now();e.unstable_now=function(){return d.now()-f}}var m=[],p=[],g=1,v=null,y=3,b=!1,S=!1,N=!1,_=!1,E=typeof setTimeout=="function"?setTimeout:null,M=typeof clearTimeout=="function"?clearTimeout:null,j=typeof setImmediate<"u"?setImmediate:null;function k(A){for(var I=s(p);I!==null;){if(I.callback===null)o(p);else if(I.startTime<=A)o(p),I.sortIndex=I.expirationTime,n(m,I);else break;I=s(p)}}function R(A){if(N=!1,k(A),!S)if(s(m)!==null)S=!0,D||(D=!0,G());else{var I=s(p);I!==null&&V(R,I.startTime-A)}}var D=!1,z=-1,H=5,U=-1;function F(){return _?!0:!(e.unstable_now()-UA&&F());){var P=v.callback;if(typeof P=="function"){v.callback=null,y=v.priorityLevel;var T=P(v.expirationTime<=A);if(A=e.unstable_now(),typeof T=="function"){v.callback=T,k(A),I=!0;break t}v===s(m)&&o(m),k(A)}else o(m);v=s(m)}if(v!==null)I=!0;else{var B=s(p);B!==null&&V(R,B.startTime-A),I=!1}}break e}finally{v=null,y=$,b=!1}I=void 0}}finally{I?G():D=!1}}}var G;if(typeof j=="function")G=function(){j(K)};else if(typeof MessageChannel<"u"){var ne=new MessageChannel,L=ne.port2;ne.port1.onmessage=K,G=function(){L.postMessage(null)}}else G=function(){E(K,0)};function V(A,I){z=E(function(){A(e.unstable_now())},I)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(A){A.callback=null},e.unstable_forceFrameRate=function(A){0>A||125P?(A.sortIndex=$,n(p,A),s(m)===null&&A===s(p)&&(N?(M(z),z=-1):N=!0,V(R,$-P))):(A.sortIndex=T,n(m,A),S||b||(S=!0,D||(D=!0,G()))),A},e.unstable_shouldYield=F,e.unstable_wrapCallback=function(A){var I=y;return function(){var $=y;y=I;try{return A.apply(this,arguments)}finally{y=$}}}})(Vm)),Vm}var Wy;function wE(){return Wy||(Wy=1,Um.exports=bE()),Um.exports}var qm={exports:{}},Yt={};/** + */var Zy;function bE(){return Zy||(Zy=1,(function(e){function n(A,I){var $=A.length;A.push(I);e:for(;0<$;){var P=$-1>>>1,T=A[P];if(0>>1;Pl(Z,$))rel(de,Z)?(A[P]=de,A[re]=$,P=re):(A[P]=Z,A[W]=$,P=W);else if(rel(de,$))A[P]=de,A[re]=$,P=re;else break e}}return I}function l(A,I){var $=A.sortIndex-I.sortIndex;return $!==0?$:A.id-I.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var c=performance;e.unstable_now=function(){return c.now()}}else{var d=Date,f=d.now();e.unstable_now=function(){return d.now()-f}}var m=[],p=[],g=1,v=null,y=3,b=!1,S=!1,N=!1,j=!1,E=typeof setTimeout=="function"?setTimeout:null,M=typeof clearTimeout=="function"?clearTimeout:null,_=typeof setImmediate<"u"?setImmediate:null;function k(A){for(var I=s(p);I!==null;){if(I.callback===null)o(p);else if(I.startTime<=A)o(p),I.sortIndex=I.expirationTime,n(m,I);else break;I=s(p)}}function R(A){if(N=!1,k(A),!S)if(s(m)!==null)S=!0,D||(D=!0,G());else{var I=s(p);I!==null&&V(R,I.startTime-A)}}var D=!1,z=-1,H=5,U=-1;function F(){return j?!0:!(e.unstable_now()-UA&&F());){var P=v.callback;if(typeof P=="function"){v.callback=null,y=v.priorityLevel;var T=P(v.expirationTime<=A);if(A=e.unstable_now(),typeof T=="function"){v.callback=T,k(A),I=!0;break t}v===s(m)&&o(m),k(A)}else o(m);v=s(m)}if(v!==null)I=!0;else{var B=s(p);B!==null&&V(R,B.startTime-A),I=!1}}break e}finally{v=null,y=$,b=!1}I=void 0}}finally{I?G():D=!1}}}var G;if(typeof _=="function")G=function(){_(K)};else if(typeof MessageChannel<"u"){var ne=new MessageChannel,L=ne.port2;ne.port1.onmessage=K,G=function(){L.postMessage(null)}}else G=function(){E(K,0)};function V(A,I){z=E(function(){A(e.unstable_now())},I)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(A){A.callback=null},e.unstable_forceFrameRate=function(A){0>A||125P?(A.sortIndex=$,n(p,A),s(m)===null&&A===s(p)&&(N?(M(z),z=-1):N=!0,V(R,$-P))):(A.sortIndex=T,n(m,A),S||b||(S=!0,D||(D=!0,G()))),A},e.unstable_shouldYield=F,e.unstable_wrapCallback=function(A){var I=y;return function(){var $=y;y=I;try{return A.apply(this,arguments)}finally{y=$}}}})(Vm)),Vm}var Wy;function wE(){return Wy||(Wy=1,Um.exports=bE()),Um.exports}var qm={exports:{}},Yt={};/** * @license React * react-dom.production.js * @@ -38,15 +38,15 @@ function gE(e,n){for(var s=0;sT||(t.current=P[T],P[T]=null,T--)}function Z(t,r){T++,P[T]=t.current,t.current=r}var re=B(null),de=B(null),ge=B(null),J=B(null);function le(t,r){switch(Z(ge,r),Z(de,t),Z(re,null),r.nodeType){case 9:case 11:t=(t=r.documentElement)&&(t=t.namespaceURI)?vy(t):0;break;default:if(t=r.tagName,r=r.namespaceURI)r=vy(r),t=by(r,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}W(re),Z(re,t)}function ve(){W(re),W(de),W(ge)}function Ne(t){t.memoizedState!==null&&Z(J,t);var r=re.current,i=by(r,t.type);r!==i&&(Z(de,t),Z(re,i))}function _e(t){de.current===t&&(W(re),W(de)),J.current===t&&(W(J),Ai._currentValue=$)}var be=Object.prototype.hasOwnProperty,Re=e.unstable_scheduleCallback,te=e.unstable_cancelCallback,Ee=e.unstable_shouldYield,Ve=e.unstable_requestPaint,Qe=e.unstable_now,It=e.unstable_getCurrentPriorityLevel,Zt=e.unstable_ImmediatePriority,ht=e.unstable_UserBlockingPriority,We=e.unstable_NormalPriority,dt=e.unstable_LowPriority,wn=e.unstable_IdlePriority,ae=e.log,ie=e.unstable_setDisableYieldValue,ue=null,me=null;function ye(t){if(typeof ae=="function"&&ie(t),me&&typeof me.setStrictMode=="function")try{me.setStrictMode(ue,t)}catch{}}var ce=Math.clz32?Math.clz32:Ke,Se=Math.log,De=Math.LN2;function Ke(t){return t>>>=0,t===0?32:31-(Se(t)/De|0)|0}var Ut=256,we=4194304;function He(t){var r=t&42;if(r!==0)return r;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function je(t,r,i){var u=t.pendingLanes;if(u===0)return 0;var h=0,x=t.suspendedLanes,C=t.pingedLanes;t=t.warmLanes;var O=u&134217727;return O!==0?(u=O&~x,u!==0?h=He(u):(C&=O,C!==0?h=He(C):i||(i=O&~t,i!==0&&(h=He(i))))):(O=u&~x,O!==0?h=He(O):C!==0?h=He(C):i||(i=u&~t,i!==0&&(h=He(i)))),h===0?0:r!==0&&r!==h&&(r&x)===0&&(x=h&-h,i=r&-r,x>=i||x===32&&(i&4194048)!==0)?r:h}function rt(t,r){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&r)===0}function ft(t,r){switch(t){case 1:case 2:case 4:case 8:case 64:return r+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return r+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Vt(){var t=Ut;return Ut<<=1,(Ut&4194048)===0&&(Ut=256),t}function Fn(){var t=we;return we<<=1,(we&62914560)===0&&(we=4194304),t}function Ma(t){for(var r=[],i=0;31>i;i++)r.push(t);return r}function Ms(t,r){t.pendingLanes|=r,r!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function kd(t,r,i,u,h,x){var C=t.pendingLanes;t.pendingLanes=i,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=i,t.entangledLanes&=i,t.errorRecoveryDisabledLanes&=i,t.shellSuspendCounter=0;var O=t.entanglements,q=t.expirationTimes,ee=t.hiddenUpdates;for(i=C&~i;0T||(t.current=P[T],P[T]=null,T--)}function Z(t,r){T++,P[T]=t.current,t.current=r}var re=B(null),de=B(null),ge=B(null),J=B(null);function le(t,r){switch(Z(ge,r),Z(de,t),Z(re,null),r.nodeType){case 9:case 11:t=(t=r.documentElement)&&(t=t.namespaceURI)?vy(t):0;break;default:if(t=r.tagName,r=r.namespaceURI)r=vy(r),t=by(r,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}W(re),Z(re,t)}function ve(){W(re),W(de),W(ge)}function Ne(t){t.memoizedState!==null&&Z(J,t);var r=re.current,i=by(r,t.type);r!==i&&(Z(de,t),Z(re,i))}function je(t){de.current===t&&(W(re),W(de)),J.current===t&&(W(J),Ai._currentValue=$)}var be=Object.prototype.hasOwnProperty,Re=e.unstable_scheduleCallback,te=e.unstable_cancelCallback,Ee=e.unstable_shouldYield,Ve=e.unstable_requestPaint,Qe=e.unstable_now,It=e.unstable_getCurrentPriorityLevel,Zt=e.unstable_ImmediatePriority,ht=e.unstable_UserBlockingPriority,We=e.unstable_NormalPriority,dt=e.unstable_LowPriority,wn=e.unstable_IdlePriority,ae=e.log,ie=e.unstable_setDisableYieldValue,ue=null,me=null;function ye(t){if(typeof ae=="function"&&ie(t),me&&typeof me.setStrictMode=="function")try{me.setStrictMode(ue,t)}catch{}}var ce=Math.clz32?Math.clz32:Ke,Se=Math.log,De=Math.LN2;function Ke(t){return t>>>=0,t===0?32:31-(Se(t)/De|0)|0}var Ut=256,we=4194304;function He(t){var r=t&42;if(r!==0)return r;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function _e(t,r,i){var u=t.pendingLanes;if(u===0)return 0;var h=0,x=t.suspendedLanes,C=t.pingedLanes;t=t.warmLanes;var O=u&134217727;return O!==0?(u=O&~x,u!==0?h=He(u):(C&=O,C!==0?h=He(C):i||(i=O&~t,i!==0&&(h=He(i))))):(O=u&~x,O!==0?h=He(O):C!==0?h=He(C):i||(i=u&~t,i!==0&&(h=He(i)))),h===0?0:r!==0&&r!==h&&(r&x)===0&&(x=h&-h,i=r&-r,x>=i||x===32&&(i&4194048)!==0)?r:h}function rt(t,r){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&r)===0}function ft(t,r){switch(t){case 1:case 2:case 4:case 8:case 64:return r+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return r+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Vt(){var t=Ut;return Ut<<=1,(Ut&4194048)===0&&(Ut=256),t}function Fn(){var t=we;return we<<=1,(we&62914560)===0&&(we=4194304),t}function Ma(t){for(var r=[],i=0;31>i;i++)r.push(t);return r}function Ms(t,r){t.pendingLanes|=r,r!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function kd(t,r,i,u,h,x){var C=t.pendingLanes;t.pendingLanes=i,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=i,t.entangledLanes&=i,t.errorRecoveryDisabledLanes&=i,t.shellSuspendCounter=0;var O=t.entanglements,q=t.expirationTimes,ee=t.hiddenUpdates;for(i=C&~i;0)":-1h||q[u]!==ee[h]){var fe=` `+q[u].replace(" at new "," at ");return t.displayName&&fe.includes("")&&(fe=fe.replace("",t.displayName)),fe}while(1<=u&&0<=h);break}}}finally{Ha=!1,Error.prepareStackTrace=i}return(i=t?t.displayName||t.name:"")?hr(i):""}function Od(t){switch(t.tag){case 26:case 27:case 5:return hr(t.type);case 16:return hr("Lazy");case 13:return hr("Suspense");case 19:return hr("SuspenseList");case 0:case 15:return $a(t.type,!1);case 11:return $a(t.type.render,!1);case 1:return $a(t.type,!0);case 31:return hr("Activity");default:return""}}function Rl(t){try{var r="";do r+=Od(t),t=t.return;while(t);return r}catch(i){return` Error generating stack: `+i.message+` -`+i.stack}}function en(t){switch(typeof t){case"bigint":case"boolean":case"number":case"string":case"undefined":return t;case"object":return t;default:return""}}function Dl(t){var r=t.type;return(t=t.nodeName)&&t.toLowerCase()==="input"&&(r==="checkbox"||r==="radio")}function zd(t){var r=Dl(t)?"checked":"value",i=Object.getOwnPropertyDescriptor(t.constructor.prototype,r),u=""+t[r];if(!t.hasOwnProperty(r)&&typeof i<"u"&&typeof i.get=="function"&&typeof i.set=="function"){var h=i.get,x=i.set;return Object.defineProperty(t,r,{configurable:!0,get:function(){return h.call(this)},set:function(C){u=""+C,x.call(this,C)}}),Object.defineProperty(t,r,{enumerable:i.enumerable}),{getValue:function(){return u},setValue:function(C){u=""+C},stopTracking:function(){t._valueTracker=null,delete t[r]}}}}function vo(t){t._valueTracker||(t._valueTracker=zd(t))}function Ba(t){if(!t)return!1;var r=t._valueTracker;if(!r)return!0;var i=r.getValue(),u="";return t&&(u=Dl(t)?t.checked?"true":"false":t.value),t=u,t!==i?(r.setValue(t),!0):!1}function bo(t){if(t=t||(typeof document<"u"?document:void 0),typeof t>"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var Id=/[\n"\\]/g;function tn(t){return t.replace(Id,function(r){return"\\"+r.charCodeAt(0).toString(16)+" "})}function Rs(t,r,i,u,h,x,C,O){t.name="",C!=null&&typeof C!="function"&&typeof C!="symbol"&&typeof C!="boolean"?t.type=C:t.removeAttribute("type"),r!=null?C==="number"?(r===0&&t.value===""||t.value!=r)&&(t.value=""+en(r)):t.value!==""+en(r)&&(t.value=""+en(r)):C!=="submit"&&C!=="reset"||t.removeAttribute("value"),r!=null?Pa(t,C,en(r)):i!=null?Pa(t,C,en(i)):u!=null&&t.removeAttribute("value"),h==null&&x!=null&&(t.defaultChecked=!!x),h!=null&&(t.checked=h&&typeof h!="function"&&typeof h!="symbol"),O!=null&&typeof O!="function"&&typeof O!="symbol"&&typeof O!="boolean"?t.name=""+en(O):t.removeAttribute("name")}function Ol(t,r,i,u,h,x,C,O){if(x!=null&&typeof x!="function"&&typeof x!="symbol"&&typeof x!="boolean"&&(t.type=x),r!=null||i!=null){if(!(x!=="submit"&&x!=="reset"||r!=null))return;i=i!=null?""+en(i):"",r=r!=null?""+en(r):i,O||r===t.value||(t.value=r),t.defaultValue=r}u=u??h,u=typeof u!="function"&&typeof u!="symbol"&&!!u,t.checked=O?t.checked:!!u,t.defaultChecked=!!u,C!=null&&typeof C!="function"&&typeof C!="symbol"&&typeof C!="boolean"&&(t.name=C)}function Pa(t,r,i){r==="number"&&bo(t.ownerDocument)===t||t.defaultValue===""+i||(t.defaultValue=""+i)}function pr(t,r,i,u){if(t=t.options,r){r={};for(var h=0;h"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Pd=!1;if(gr)try{var Va={};Object.defineProperty(Va,"passive",{get:function(){Pd=!0}}),window.addEventListener("test",Va,Va),window.removeEventListener("test",Va,Va)}catch{Pd=!1}var Vr=null,Ud=null,Il=null;function Sg(){if(Il)return Il;var t,r=Ud,i=r.length,u,h="value"in Vr?Vr.value:Vr.textContent,x=h.length;for(t=0;t=Ya),Ag=" ",Mg=!1;function Tg(t,r){switch(t){case"keyup":return Bj.indexOf(r.keyCode)!==-1;case"keydown":return r.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Rg(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var jo=!1;function Uj(t,r){switch(t){case"compositionend":return Rg(r);case"keypress":return r.which!==32?null:(Mg=!0,Ag);case"textInput":return t=r.data,t===Ag&&Mg?null:t;default:return null}}function Vj(t,r){if(jo)return t==="compositionend"||!Gd&&Tg(t,r)?(t=Sg(),Il=Ud=Vr=null,jo=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(r.ctrlKey||r.altKey||r.metaKey)||r.ctrlKey&&r.altKey){if(r.char&&1=r)return{node:i,offset:r-t};t=u}e:{for(;i;){if(i.nextSibling){i=i.nextSibling;break e}i=i.parentNode}i=void 0}i=Bg(i)}}function Ug(t,r){return t&&r?t===r?!0:t&&t.nodeType===3?!1:r&&r.nodeType===3?Ug(t,r.parentNode):"contains"in t?t.contains(r):t.compareDocumentPosition?!!(t.compareDocumentPosition(r)&16):!1:!1}function Vg(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var r=bo(t.document);r instanceof t.HTMLIFrameElement;){try{var i=typeof r.contentWindow.location.href=="string"}catch{i=!1}if(i)t=r.contentWindow;else break;r=bo(t.document)}return r}function Wd(t){var r=t&&t.nodeName&&t.nodeName.toLowerCase();return r&&(r==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||r==="textarea"||t.contentEditable==="true")}var Kj=gr&&"documentMode"in document&&11>=document.documentMode,_o=null,Kd=null,Wa=null,Qd=!1;function qg(t,r,i){var u=i.window===i?i.document:i.nodeType===9?i:i.ownerDocument;Qd||_o==null||_o!==bo(u)||(u=_o,"selectionStart"in u&&Wd(u)?u={start:u.selectionStart,end:u.selectionEnd}:(u=(u.ownerDocument&&u.ownerDocument.defaultView||window).getSelection(),u={anchorNode:u.anchorNode,anchorOffset:u.anchorOffset,focusNode:u.focusNode,focusOffset:u.focusOffset}),Wa&&Za(Wa,u)||(Wa=u,u=Ec(Kd,"onSelect"),0>=C,h-=C,yr=1<<32-ce(r)+h|i<x?x:8;var C=A.T,O={};A.T=O,Hf(t,!1,r,i);try{var q=h(),ee=A.S;if(ee!==null&&ee(O,q),q!==null&&typeof q=="object"&&typeof q.then=="function"){var fe=a_(q,u);di(t,r,fe,mn(t))}else di(t,r,u,mn(t))}catch(xe){di(t,r,{then:function(){},status:"rejected",reason:xe},mn())}finally{I.p=x,A.T=C}}function d_(){}function If(t,r,i,u){if(t.tag!==5)throw Error(o(476));var h=Fx(t).queue;qx(t,h,r,$,i===null?d_:function(){return Yx(t),i(u)})}function Fx(t){var r=t.memoizedState;if(r!==null)return r;r={memoizedState:$,baseState:$,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Nr,lastRenderedState:$},next:null};var i={};return r.next={memoizedState:i,baseState:i,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Nr,lastRenderedState:i},next:null},t.memoizedState=r,t=t.alternate,t!==null&&(t.memoizedState=r),r}function Yx(t){var r=Fx(t).next.queue;di(t,r,{},mn())}function Lf(){return Ft(Ai)}function Gx(){return Ct().memoizedState}function Xx(){return Ct().memoizedState}function f_(t){for(var r=t.return;r!==null;){switch(r.tag){case 24:case 3:var i=mn();t=Yr(i);var u=Gr(r,t,i);u!==null&&(hn(u,r,i),oi(u,r,i)),r={cache:mf()},t.payload=r;return}r=r.return}}function m_(t,r,i){var u=mn();i={lane:u,revertLane:0,action:i,hasEagerState:!1,eagerState:null,next:null},ac(t)?Wx(r,i):(i=nf(t,r,i,u),i!==null&&(hn(i,t,u),Kx(i,r,u)))}function Zx(t,r,i){var u=mn();di(t,r,i,u)}function di(t,r,i,u){var h={lane:u,revertLane:0,action:i,hasEagerState:!1,eagerState:null,next:null};if(ac(t))Wx(r,h);else{var x=t.alternate;if(t.lanes===0&&(x===null||x.lanes===0)&&(x=r.lastRenderedReducer,x!==null))try{var C=r.lastRenderedState,O=x(C,i);if(h.hasEagerState=!0,h.eagerState=O,ln(O,C))return Vl(t,r,h,0),pt===null&&Ul(),!1}catch{}finally{}if(i=nf(t,r,h,u),i!==null)return hn(i,t,u),Kx(i,r,u),!0}return!1}function Hf(t,r,i,u){if(u={lane:2,revertLane:gm(),action:u,hasEagerState:!1,eagerState:null,next:null},ac(t)){if(r)throw Error(o(479))}else r=nf(t,i,u,2),r!==null&&hn(r,t,2)}function ac(t){var r=t.alternate;return t===qe||r!==null&&r===qe}function Wx(t,r){zo=ec=!0;var i=t.pending;i===null?r.next=r:(r.next=i.next,i.next=r),t.pending=r}function Kx(t,r,i){if((i&4194048)!==0){var u=r.lanes;u&=t.pendingLanes,i|=u,r.lanes=i,Ta(t,i)}}var ic={readContext:Ft,use:nc,useCallback:St,useContext:St,useEffect:St,useImperativeHandle:St,useLayoutEffect:St,useInsertionEffect:St,useMemo:St,useReducer:St,useRef:St,useState:St,useDebugValue:St,useDeferredValue:St,useTransition:St,useSyncExternalStore:St,useId:St,useHostTransitionStatus:St,useFormState:St,useActionState:St,useOptimistic:St,useMemoCache:St,useCacheRefresh:St},Qx={readContext:Ft,use:nc,useCallback:function(t,r){return rn().memoizedState=[t,r===void 0?null:r],t},useContext:Ft,useEffect:zx,useImperativeHandle:function(t,r,i){i=i!=null?i.concat([t]):null,oc(4194308,4,$x.bind(null,r,t),i)},useLayoutEffect:function(t,r){return oc(4194308,4,t,r)},useInsertionEffect:function(t,r){oc(4,2,t,r)},useMemo:function(t,r){var i=rn();r=r===void 0?null:r;var u=t();if(qs){ye(!0);try{t()}finally{ye(!1)}}return i.memoizedState=[u,r],u},useReducer:function(t,r,i){var u=rn();if(i!==void 0){var h=i(r);if(qs){ye(!0);try{i(r)}finally{ye(!1)}}}else h=r;return u.memoizedState=u.baseState=h,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:h},u.queue=t,t=t.dispatch=m_.bind(null,qe,t),[u.memoizedState,t]},useRef:function(t){var r=rn();return t={current:t},r.memoizedState=t},useState:function(t){t=Rf(t);var r=t.queue,i=Zx.bind(null,qe,r);return r.dispatch=i,[t.memoizedState,i]},useDebugValue:Of,useDeferredValue:function(t,r){var i=rn();return zf(i,t,r)},useTransition:function(){var t=Rf(!1);return t=qx.bind(null,qe,t.queue,!0,!1),rn().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,r,i){var u=qe,h=rn();if(ot){if(i===void 0)throw Error(o(407));i=i()}else{if(i=r(),pt===null)throw Error(o(349));(et&124)!==0||vx(u,r,i)}h.memoizedState=i;var x={value:i,getSnapshot:r};return h.queue=x,zx(wx.bind(null,u,x,t),[t]),u.flags|=2048,Lo(9,sc(),bx.bind(null,u,x,i,r),null),i},useId:function(){var t=rn(),r=pt.identifierPrefix;if(ot){var i=vr,u=yr;i=(u&~(1<<32-ce(u)-1)).toString(32)+i,r="«"+r+"R"+i,i=tc++,0$e?(zt=ze,ze=null):zt=ze.sibling;var st=se(X,ze,Q[$e],he);if(st===null){ze===null&&(ze=zt);break}t&&ze&&st.alternate===null&&r(X,ze),Y=x(st,Y,$e),Ye===null?ke=st:Ye.sibling=st,Ye=st,ze=zt}if($e===Q.length)return i(X,ze),ot&&Hs(X,$e),ke;if(ze===null){for(;$e$e?(zt=ze,ze=null):zt=ze.sibling;var us=se(X,ze,st.value,he);if(us===null){ze===null&&(ze=zt);break}t&&ze&&us.alternate===null&&r(X,ze),Y=x(us,Y,$e),Ye===null?ke=us:Ye.sibling=us,Ye=us,ze=zt}if(st.done)return i(X,ze),ot&&Hs(X,$e),ke;if(ze===null){for(;!st.done;$e++,st=Q.next())st=xe(X,st.value,he),st!==null&&(Y=x(st,Y,$e),Ye===null?ke=st:Ye.sibling=st,Ye=st);return ot&&Hs(X,$e),ke}for(ze=u(ze);!st.done;$e++,st=Q.next())st=oe(ze,X,$e,st.value,he),st!==null&&(t&&st.alternate!==null&&ze.delete(st.key===null?$e:st.key),Y=x(st,Y,$e),Ye===null?ke=st:Ye.sibling=st,Ye=st);return t&&ze.forEach(function(pE){return r(X,pE)}),ot&&Hs(X,$e),ke}function ut(X,Y,Q,he){if(typeof Q=="object"&&Q!==null&&Q.type===S&&Q.key===null&&(Q=Q.props.children),typeof Q=="object"&&Q!==null){switch(Q.$$typeof){case y:e:{for(var ke=Q.key;Y!==null;){if(Y.key===ke){if(ke=Q.type,ke===S){if(Y.tag===7){i(X,Y.sibling),he=h(Y,Q.props.children),he.return=X,X=he;break e}}else if(Y.elementType===ke||typeof ke=="object"&&ke!==null&&ke.$$typeof===H&&e0(ke)===Y.type){i(X,Y.sibling),he=h(Y,Q.props),mi(he,Q),he.return=X,X=he;break e}i(X,Y);break}else r(X,Y);Y=Y.sibling}Q.type===S?(he=Is(Q.props.children,X.mode,he,Q.key),he.return=X,X=he):(he=Fl(Q.type,Q.key,Q.props,null,X.mode,he),mi(he,Q),he.return=X,X=he)}return C(X);case b:e:{for(ke=Q.key;Y!==null;){if(Y.key===ke)if(Y.tag===4&&Y.stateNode.containerInfo===Q.containerInfo&&Y.stateNode.implementation===Q.implementation){i(X,Y.sibling),he=h(Y,Q.children||[]),he.return=X,X=he;break e}else{i(X,Y);break}else r(X,Y);Y=Y.sibling}he=of(Q,X.mode,he),he.return=X,X=he}return C(X);case H:return ke=Q._init,Q=ke(Q._payload),ut(X,Y,Q,he)}if(V(Q))return Be(X,Y,Q,he);if(G(Q)){if(ke=G(Q),typeof ke!="function")throw Error(o(150));return Q=ke.call(Q),Le(X,Y,Q,he)}if(typeof Q.then=="function")return ut(X,Y,lc(Q),he);if(Q.$$typeof===j)return ut(X,Y,Zl(X,Q),he);cc(X,Q)}return typeof Q=="string"&&Q!==""||typeof Q=="number"||typeof Q=="bigint"?(Q=""+Q,Y!==null&&Y.tag===6?(i(X,Y.sibling),he=h(Y,Q),he.return=X,X=he):(i(X,Y),he=sf(Q,X.mode,he),he.return=X,X=he),C(X)):i(X,Y)}return function(X,Y,Q,he){try{fi=0;var ke=ut(X,Y,Q,he);return Ho=null,ke}catch(ze){if(ze===ri||ze===Kl)throw ze;var Ye=cn(29,ze,null,X.mode);return Ye.lanes=he,Ye.return=X,Ye}finally{}}}var $o=t0(!0),n0=t0(!1),En=B(null),Xn=null;function Zr(t){var r=t.alternate;Z(Mt,Mt.current&1),Z(En,t),Xn===null&&(r===null||Oo.current!==null||r.memoizedState!==null)&&(Xn=t)}function r0(t){if(t.tag===22){if(Z(Mt,Mt.current),Z(En,t),Xn===null){var r=t.alternate;r!==null&&r.memoizedState!==null&&(Xn=t)}}else Wr()}function Wr(){Z(Mt,Mt.current),Z(En,En.current)}function Sr(t){W(En),Xn===t&&(Xn=null),W(Mt)}var Mt=B(0);function uc(t){for(var r=t;r!==null;){if(r.tag===13){var i=r.memoizedState;if(i!==null&&(i=i.dehydrated,i===null||i.data==="$?"||km(i)))return r}else if(r.tag===19&&r.memoizedProps.revealOrder!==void 0){if((r.flags&128)!==0)return r}else if(r.child!==null){r.child.return=r,r=r.child;continue}if(r===t)break;for(;r.sibling===null;){if(r.return===null||r.return===t)return null;r=r.return}r.sibling.return=r.return,r=r.sibling}return null}function $f(t,r,i,u){r=t.memoizedState,i=i(u,r),i=i==null?r:g({},r,i),t.memoizedState=i,t.lanes===0&&(t.updateQueue.baseState=i)}var Bf={enqueueSetState:function(t,r,i){t=t._reactInternals;var u=mn(),h=Yr(u);h.payload=r,i!=null&&(h.callback=i),r=Gr(t,h,u),r!==null&&(hn(r,t,u),oi(r,t,u))},enqueueReplaceState:function(t,r,i){t=t._reactInternals;var u=mn(),h=Yr(u);h.tag=1,h.payload=r,i!=null&&(h.callback=i),r=Gr(t,h,u),r!==null&&(hn(r,t,u),oi(r,t,u))},enqueueForceUpdate:function(t,r){t=t._reactInternals;var i=mn(),u=Yr(i);u.tag=2,r!=null&&(u.callback=r),r=Gr(t,u,i),r!==null&&(hn(r,t,i),oi(r,t,i))}};function s0(t,r,i,u,h,x,C){return t=t.stateNode,typeof t.shouldComponentUpdate=="function"?t.shouldComponentUpdate(u,x,C):r.prototype&&r.prototype.isPureReactComponent?!Za(i,u)||!Za(h,x):!0}function o0(t,r,i,u){t=r.state,typeof r.componentWillReceiveProps=="function"&&r.componentWillReceiveProps(i,u),typeof r.UNSAFE_componentWillReceiveProps=="function"&&r.UNSAFE_componentWillReceiveProps(i,u),r.state!==t&&Bf.enqueueReplaceState(r,r.state,null)}function Fs(t,r){var i=r;if("ref"in r){i={};for(var u in r)u!=="ref"&&(i[u]=r[u])}if(t=t.defaultProps){i===r&&(i=g({},i));for(var h in t)i[h]===void 0&&(i[h]=t[h])}return i}var dc=typeof reportError=="function"?reportError:function(t){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var r=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof t=="object"&&t!==null&&typeof t.message=="string"?String(t.message):String(t),error:t});if(!window.dispatchEvent(r))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",t);return}console.error(t)};function a0(t){dc(t)}function i0(t){console.error(t)}function l0(t){dc(t)}function fc(t,r){try{var i=t.onUncaughtError;i(r.value,{componentStack:r.stack})}catch(u){setTimeout(function(){throw u})}}function c0(t,r,i){try{var u=t.onCaughtError;u(i.value,{componentStack:i.stack,errorBoundary:r.tag===1?r.stateNode:null})}catch(h){setTimeout(function(){throw h})}}function Pf(t,r,i){return i=Yr(i),i.tag=3,i.payload={element:null},i.callback=function(){fc(t,r)},i}function u0(t){return t=Yr(t),t.tag=3,t}function d0(t,r,i,u){var h=i.type.getDerivedStateFromError;if(typeof h=="function"){var x=u.value;t.payload=function(){return h(x)},t.callback=function(){c0(r,i,u)}}var C=i.stateNode;C!==null&&typeof C.componentDidCatch=="function"&&(t.callback=function(){c0(r,i,u),typeof h!="function"&&(ns===null?ns=new Set([this]):ns.add(this));var O=u.stack;this.componentDidCatch(u.value,{componentStack:O!==null?O:""})})}function p_(t,r,i,u,h){if(i.flags|=32768,u!==null&&typeof u=="object"&&typeof u.then=="function"){if(r=i.alternate,r!==null&&ei(r,i,h,!0),i=En.current,i!==null){switch(i.tag){case 13:return Xn===null?dm():i.alternate===null&&Nt===0&&(Nt=3),i.flags&=-257,i.flags|=65536,i.lanes=h,u===gf?i.flags|=16384:(r=i.updateQueue,r===null?i.updateQueue=new Set([u]):r.add(u),mm(t,u,h)),!1;case 22:return i.flags|=65536,u===gf?i.flags|=16384:(r=i.updateQueue,r===null?(r={transitions:null,markerInstances:null,retryQueue:new Set([u])},i.updateQueue=r):(i=r.retryQueue,i===null?r.retryQueue=new Set([u]):i.add(u)),mm(t,u,h)),!1}throw Error(o(435,i.tag))}return mm(t,u,h),dm(),!1}if(ot)return r=En.current,r!==null?((r.flags&65536)===0&&(r.flags|=256),r.flags|=65536,r.lanes=h,u!==cf&&(t=Error(o(422),{cause:u}),Ja(Nn(t,i)))):(u!==cf&&(r=Error(o(423),{cause:u}),Ja(Nn(r,i))),t=t.current.alternate,t.flags|=65536,h&=-h,t.lanes|=h,u=Nn(u,i),h=Pf(t.stateNode,u,h),vf(t,h),Nt!==4&&(Nt=2)),!1;var x=Error(o(520),{cause:u});if(x=Nn(x,i),bi===null?bi=[x]:bi.push(x),Nt!==4&&(Nt=2),r===null)return!0;u=Nn(u,i),i=r;do{switch(i.tag){case 3:return i.flags|=65536,t=h&-h,i.lanes|=t,t=Pf(i.stateNode,u,t),vf(i,t),!1;case 1:if(r=i.type,x=i.stateNode,(i.flags&128)===0&&(typeof r.getDerivedStateFromError=="function"||x!==null&&typeof x.componentDidCatch=="function"&&(ns===null||!ns.has(x))))return i.flags|=65536,h&=-h,i.lanes|=h,h=u0(h),d0(h,t,i,u),vf(i,h),!1}i=i.return}while(i!==null);return!1}var f0=Error(o(461)),Dt=!1;function Lt(t,r,i,u){r.child=t===null?n0(r,null,i,u):$o(r,t.child,i,u)}function m0(t,r,i,u,h){i=i.render;var x=r.ref;if("ref"in u){var C={};for(var O in u)O!=="ref"&&(C[O]=u[O])}else C=u;return Us(r),u=jf(t,r,i,C,x,h),O=_f(),t!==null&&!Dt?(Ef(t,r,h),jr(t,r,h)):(ot&&O&&af(r),r.flags|=1,Lt(t,r,u,h),r.child)}function h0(t,r,i,u,h){if(t===null){var x=i.type;return typeof x=="function"&&!rf(x)&&x.defaultProps===void 0&&i.compare===null?(r.tag=15,r.type=x,p0(t,r,x,u,h)):(t=Fl(i.type,null,u,r,r.mode,h),t.ref=r.ref,t.return=r,r.child=t)}if(x=t.child,!Zf(t,h)){var C=x.memoizedProps;if(i=i.compare,i=i!==null?i:Za,i(C,u)&&t.ref===r.ref)return jr(t,r,h)}return r.flags|=1,t=xr(x,u),t.ref=r.ref,t.return=r,r.child=t}function p0(t,r,i,u,h){if(t!==null){var x=t.memoizedProps;if(Za(x,u)&&t.ref===r.ref)if(Dt=!1,r.pendingProps=u=x,Zf(t,h))(t.flags&131072)!==0&&(Dt=!0);else return r.lanes=t.lanes,jr(t,r,h)}return Uf(t,r,i,u,h)}function g0(t,r,i){var u=r.pendingProps,h=u.children,x=t!==null?t.memoizedState:null;if(u.mode==="hidden"){if((r.flags&128)!==0){if(u=x!==null?x.baseLanes|i:i,t!==null){for(h=r.child=t.child,x=0;h!==null;)x=x|h.lanes|h.childLanes,h=h.sibling;r.childLanes=x&~u}else r.childLanes=0,r.child=null;return x0(t,r,u,i)}if((i&536870912)!==0)r.memoizedState={baseLanes:0,cachePool:null},t!==null&&Wl(r,x!==null?x.cachePool:null),x!==null?px(r,x):wf(),r0(r);else return r.lanes=r.childLanes=536870912,x0(t,r,x!==null?x.baseLanes|i:i,i)}else x!==null?(Wl(r,x.cachePool),px(r,x),Wr(),r.memoizedState=null):(t!==null&&Wl(r,null),wf(),Wr());return Lt(t,r,h,i),r.child}function x0(t,r,i,u){var h=pf();return h=h===null?null:{parent:At._currentValue,pool:h},r.memoizedState={baseLanes:i,cachePool:h},t!==null&&Wl(r,null),wf(),r0(r),t!==null&&ei(t,r,u,!0),null}function mc(t,r){var i=r.ref;if(i===null)t!==null&&t.ref!==null&&(r.flags|=4194816);else{if(typeof i!="function"&&typeof i!="object")throw Error(o(284));(t===null||t.ref!==i)&&(r.flags|=4194816)}}function Uf(t,r,i,u,h){return Us(r),i=jf(t,r,i,u,void 0,h),u=_f(),t!==null&&!Dt?(Ef(t,r,h),jr(t,r,h)):(ot&&u&&af(r),r.flags|=1,Lt(t,r,i,h),r.child)}function y0(t,r,i,u,h,x){return Us(r),r.updateQueue=null,i=xx(r,u,i,h),gx(t),u=_f(),t!==null&&!Dt?(Ef(t,r,x),jr(t,r,x)):(ot&&u&&af(r),r.flags|=1,Lt(t,r,i,x),r.child)}function v0(t,r,i,u,h){if(Us(r),r.stateNode===null){var x=Ao,C=i.contextType;typeof C=="object"&&C!==null&&(x=Ft(C)),x=new i(u,x),r.memoizedState=x.state!==null&&x.state!==void 0?x.state:null,x.updater=Bf,r.stateNode=x,x._reactInternals=r,x=r.stateNode,x.props=u,x.state=r.memoizedState,x.refs={},xf(r),C=i.contextType,x.context=typeof C=="object"&&C!==null?Ft(C):Ao,x.state=r.memoizedState,C=i.getDerivedStateFromProps,typeof C=="function"&&($f(r,i,C,u),x.state=r.memoizedState),typeof i.getDerivedStateFromProps=="function"||typeof x.getSnapshotBeforeUpdate=="function"||typeof x.UNSAFE_componentWillMount!="function"&&typeof x.componentWillMount!="function"||(C=x.state,typeof x.componentWillMount=="function"&&x.componentWillMount(),typeof x.UNSAFE_componentWillMount=="function"&&x.UNSAFE_componentWillMount(),C!==x.state&&Bf.enqueueReplaceState(x,x.state,null),ii(r,u,x,h),ai(),x.state=r.memoizedState),typeof x.componentDidMount=="function"&&(r.flags|=4194308),u=!0}else if(t===null){x=r.stateNode;var O=r.memoizedProps,q=Fs(i,O);x.props=q;var ee=x.context,fe=i.contextType;C=Ao,typeof fe=="object"&&fe!==null&&(C=Ft(fe));var xe=i.getDerivedStateFromProps;fe=typeof xe=="function"||typeof x.getSnapshotBeforeUpdate=="function",O=r.pendingProps!==O,fe||typeof x.UNSAFE_componentWillReceiveProps!="function"&&typeof x.componentWillReceiveProps!="function"||(O||ee!==C)&&o0(r,x,u,C),Fr=!1;var se=r.memoizedState;x.state=se,ii(r,u,x,h),ai(),ee=r.memoizedState,O||se!==ee||Fr?(typeof xe=="function"&&($f(r,i,xe,u),ee=r.memoizedState),(q=Fr||s0(r,i,q,u,se,ee,C))?(fe||typeof x.UNSAFE_componentWillMount!="function"&&typeof x.componentWillMount!="function"||(typeof x.componentWillMount=="function"&&x.componentWillMount(),typeof x.UNSAFE_componentWillMount=="function"&&x.UNSAFE_componentWillMount()),typeof x.componentDidMount=="function"&&(r.flags|=4194308)):(typeof x.componentDidMount=="function"&&(r.flags|=4194308),r.memoizedProps=u,r.memoizedState=ee),x.props=u,x.state=ee,x.context=C,u=q):(typeof x.componentDidMount=="function"&&(r.flags|=4194308),u=!1)}else{x=r.stateNode,yf(t,r),C=r.memoizedProps,fe=Fs(i,C),x.props=fe,xe=r.pendingProps,se=x.context,ee=i.contextType,q=Ao,typeof ee=="object"&&ee!==null&&(q=Ft(ee)),O=i.getDerivedStateFromProps,(ee=typeof O=="function"||typeof x.getSnapshotBeforeUpdate=="function")||typeof x.UNSAFE_componentWillReceiveProps!="function"&&typeof x.componentWillReceiveProps!="function"||(C!==xe||se!==q)&&o0(r,x,u,q),Fr=!1,se=r.memoizedState,x.state=se,ii(r,u,x,h),ai();var oe=r.memoizedState;C!==xe||se!==oe||Fr||t!==null&&t.dependencies!==null&&Xl(t.dependencies)?(typeof O=="function"&&($f(r,i,O,u),oe=r.memoizedState),(fe=Fr||s0(r,i,fe,u,se,oe,q)||t!==null&&t.dependencies!==null&&Xl(t.dependencies))?(ee||typeof x.UNSAFE_componentWillUpdate!="function"&&typeof x.componentWillUpdate!="function"||(typeof x.componentWillUpdate=="function"&&x.componentWillUpdate(u,oe,q),typeof x.UNSAFE_componentWillUpdate=="function"&&x.UNSAFE_componentWillUpdate(u,oe,q)),typeof x.componentDidUpdate=="function"&&(r.flags|=4),typeof x.getSnapshotBeforeUpdate=="function"&&(r.flags|=1024)):(typeof x.componentDidUpdate!="function"||C===t.memoizedProps&&se===t.memoizedState||(r.flags|=4),typeof x.getSnapshotBeforeUpdate!="function"||C===t.memoizedProps&&se===t.memoizedState||(r.flags|=1024),r.memoizedProps=u,r.memoizedState=oe),x.props=u,x.state=oe,x.context=q,u=fe):(typeof x.componentDidUpdate!="function"||C===t.memoizedProps&&se===t.memoizedState||(r.flags|=4),typeof x.getSnapshotBeforeUpdate!="function"||C===t.memoizedProps&&se===t.memoizedState||(r.flags|=1024),u=!1)}return x=u,mc(t,r),u=(r.flags&128)!==0,x||u?(x=r.stateNode,i=u&&typeof i.getDerivedStateFromError!="function"?null:x.render(),r.flags|=1,t!==null&&u?(r.child=$o(r,t.child,null,h),r.child=$o(r,null,i,h)):Lt(t,r,i,h),r.memoizedState=x.state,t=r.child):t=jr(t,r,h),t}function b0(t,r,i,u){return Qa(),r.flags|=256,Lt(t,r,i,u),r.child}var Vf={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function qf(t){return{baseLanes:t,cachePool:ix()}}function Ff(t,r,i){return t=t!==null?t.childLanes&~i:0,r&&(t|=Cn),t}function w0(t,r,i){var u=r.pendingProps,h=!1,x=(r.flags&128)!==0,C;if((C=x)||(C=t!==null&&t.memoizedState===null?!1:(Mt.current&2)!==0),C&&(h=!0,r.flags&=-129),C=(r.flags&32)!==0,r.flags&=-33,t===null){if(ot){if(h?Zr(r):Wr(),ot){var O=wt,q;if(q=O){e:{for(q=O,O=Gn;q.nodeType!==8;){if(!O){O=null;break e}if(q=zn(q.nextSibling),q===null){O=null;break e}}O=q}O!==null?(r.memoizedState={dehydrated:O,treeContext:Ls!==null?{id:yr,overflow:vr}:null,retryLane:536870912,hydrationErrors:null},q=cn(18,null,null,0),q.stateNode=O,q.return=r,r.child=q,Wt=r,wt=null,q=!0):q=!1}q||Bs(r)}if(O=r.memoizedState,O!==null&&(O=O.dehydrated,O!==null))return km(O)?r.lanes=32:r.lanes=536870912,null;Sr(r)}return O=u.children,u=u.fallback,h?(Wr(),h=r.mode,O=hc({mode:"hidden",children:O},h),u=Is(u,h,i,null),O.return=r,u.return=r,O.sibling=u,r.child=O,h=r.child,h.memoizedState=qf(i),h.childLanes=Ff(t,C,i),r.memoizedState=Vf,u):(Zr(r),Yf(r,O))}if(q=t.memoizedState,q!==null&&(O=q.dehydrated,O!==null)){if(x)r.flags&256?(Zr(r),r.flags&=-257,r=Gf(t,r,i)):r.memoizedState!==null?(Wr(),r.child=t.child,r.flags|=128,r=null):(Wr(),h=u.fallback,O=r.mode,u=hc({mode:"visible",children:u.children},O),h=Is(h,O,i,null),h.flags|=2,u.return=r,h.return=r,u.sibling=h,r.child=u,$o(r,t.child,null,i),u=r.child,u.memoizedState=qf(i),u.childLanes=Ff(t,C,i),r.memoizedState=Vf,r=h);else if(Zr(r),km(O)){if(C=O.nextSibling&&O.nextSibling.dataset,C)var ee=C.dgst;C=ee,u=Error(o(419)),u.stack="",u.digest=C,Ja({value:u,source:null,stack:null}),r=Gf(t,r,i)}else if(Dt||ei(t,r,i,!1),C=(i&t.childLanes)!==0,Dt||C){if(C=pt,C!==null&&(u=i&-i,u=(u&42)!==0?1:Ra(u),u=(u&(C.suspendedLanes|i))!==0?0:u,u!==0&&u!==q.retryLane))throw q.retryLane=u,ko(t,u),hn(C,t,u),f0;O.data==="$?"||dm(),r=Gf(t,r,i)}else O.data==="$?"?(r.flags|=192,r.child=t.child,r=null):(t=q.treeContext,wt=zn(O.nextSibling),Wt=r,ot=!0,$s=null,Gn=!1,t!==null&&(jn[_n++]=yr,jn[_n++]=vr,jn[_n++]=Ls,yr=t.id,vr=t.overflow,Ls=r),r=Yf(r,u.children),r.flags|=4096);return r}return h?(Wr(),h=u.fallback,O=r.mode,q=t.child,ee=q.sibling,u=xr(q,{mode:"hidden",children:u.children}),u.subtreeFlags=q.subtreeFlags&65011712,ee!==null?h=xr(ee,h):(h=Is(h,O,i,null),h.flags|=2),h.return=r,u.return=r,u.sibling=h,r.child=u,u=h,h=r.child,O=t.child.memoizedState,O===null?O=qf(i):(q=O.cachePool,q!==null?(ee=At._currentValue,q=q.parent!==ee?{parent:ee,pool:ee}:q):q=ix(),O={baseLanes:O.baseLanes|i,cachePool:q}),h.memoizedState=O,h.childLanes=Ff(t,C,i),r.memoizedState=Vf,u):(Zr(r),i=t.child,t=i.sibling,i=xr(i,{mode:"visible",children:u.children}),i.return=r,i.sibling=null,t!==null&&(C=r.deletions,C===null?(r.deletions=[t],r.flags|=16):C.push(t)),r.child=i,r.memoizedState=null,i)}function Yf(t,r){return r=hc({mode:"visible",children:r},t.mode),r.return=t,t.child=r}function hc(t,r){return t=cn(22,t,null,r),t.lanes=0,t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},t}function Gf(t,r,i){return $o(r,t.child,null,i),t=Yf(r,r.pendingProps.children),t.flags|=2,r.memoizedState=null,t}function N0(t,r,i){t.lanes|=r;var u=t.alternate;u!==null&&(u.lanes|=r),df(t.return,r,i)}function Xf(t,r,i,u,h){var x=t.memoizedState;x===null?t.memoizedState={isBackwards:r,rendering:null,renderingStartTime:0,last:u,tail:i,tailMode:h}:(x.isBackwards=r,x.rendering=null,x.renderingStartTime=0,x.last=u,x.tail=i,x.tailMode=h)}function S0(t,r,i){var u=r.pendingProps,h=u.revealOrder,x=u.tail;if(Lt(t,r,u.children,i),u=Mt.current,(u&2)!==0)u=u&1|2,r.flags|=128;else{if(t!==null&&(t.flags&128)!==0)e:for(t=r.child;t!==null;){if(t.tag===13)t.memoizedState!==null&&N0(t,i,r);else if(t.tag===19)N0(t,i,r);else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===r)break e;for(;t.sibling===null;){if(t.return===null||t.return===r)break e;t=t.return}t.sibling.return=t.return,t=t.sibling}u&=1}switch(Z(Mt,u),h){case"forwards":for(i=r.child,h=null;i!==null;)t=i.alternate,t!==null&&uc(t)===null&&(h=i),i=i.sibling;i=h,i===null?(h=r.child,r.child=null):(h=i.sibling,i.sibling=null),Xf(r,!1,h,i,x);break;case"backwards":for(i=null,h=r.child,r.child=null;h!==null;){if(t=h.alternate,t!==null&&uc(t)===null){r.child=h;break}t=h.sibling,h.sibling=i,i=h,h=t}Xf(r,!0,i,null,x);break;case"together":Xf(r,!1,null,null,void 0);break;default:r.memoizedState=null}return r.child}function jr(t,r,i){if(t!==null&&(r.dependencies=t.dependencies),ts|=r.lanes,(i&r.childLanes)===0)if(t!==null){if(ei(t,r,i,!1),(i&r.childLanes)===0)return null}else return null;if(t!==null&&r.child!==t.child)throw Error(o(153));if(r.child!==null){for(t=r.child,i=xr(t,t.pendingProps),r.child=i,i.return=r;t.sibling!==null;)t=t.sibling,i=i.sibling=xr(t,t.pendingProps),i.return=r;i.sibling=null}return r.child}function Zf(t,r){return(t.lanes&r)!==0?!0:(t=t.dependencies,!!(t!==null&&Xl(t)))}function g_(t,r,i){switch(r.tag){case 3:le(r,r.stateNode.containerInfo),qr(r,At,t.memoizedState.cache),Qa();break;case 27:case 5:Ne(r);break;case 4:le(r,r.stateNode.containerInfo);break;case 10:qr(r,r.type,r.memoizedProps.value);break;case 13:var u=r.memoizedState;if(u!==null)return u.dehydrated!==null?(Zr(r),r.flags|=128,null):(i&r.child.childLanes)!==0?w0(t,r,i):(Zr(r),t=jr(t,r,i),t!==null?t.sibling:null);Zr(r);break;case 19:var h=(t.flags&128)!==0;if(u=(i&r.childLanes)!==0,u||(ei(t,r,i,!1),u=(i&r.childLanes)!==0),h){if(u)return S0(t,r,i);r.flags|=128}if(h=r.memoizedState,h!==null&&(h.rendering=null,h.tail=null,h.lastEffect=null),Z(Mt,Mt.current),u)break;return null;case 22:case 23:return r.lanes=0,g0(t,r,i);case 24:qr(r,At,t.memoizedState.cache)}return jr(t,r,i)}function j0(t,r,i){if(t!==null)if(t.memoizedProps!==r.pendingProps)Dt=!0;else{if(!Zf(t,i)&&(r.flags&128)===0)return Dt=!1,g_(t,r,i);Dt=(t.flags&131072)!==0}else Dt=!1,ot&&(r.flags&1048576)!==0&&ex(r,Gl,r.index);switch(r.lanes=0,r.tag){case 16:e:{t=r.pendingProps;var u=r.elementType,h=u._init;if(u=h(u._payload),r.type=u,typeof u=="function")rf(u)?(t=Fs(u,t),r.tag=1,r=v0(null,r,u,t,i)):(r.tag=0,r=Uf(null,r,u,t,i));else{if(u!=null){if(h=u.$$typeof,h===k){r.tag=11,r=m0(null,r,u,t,i);break e}else if(h===z){r.tag=14,r=h0(null,r,u,t,i);break e}}throw r=L(u)||u,Error(o(306,r,""))}}return r;case 0:return Uf(t,r,r.type,r.pendingProps,i);case 1:return u=r.type,h=Fs(u,r.pendingProps),v0(t,r,u,h,i);case 3:e:{if(le(r,r.stateNode.containerInfo),t===null)throw Error(o(387));u=r.pendingProps;var x=r.memoizedState;h=x.element,yf(t,r),ii(r,u,null,i);var C=r.memoizedState;if(u=C.cache,qr(r,At,u),u!==x.cache&&ff(r,[At],i,!0),ai(),u=C.element,x.isDehydrated)if(x={element:u,isDehydrated:!1,cache:C.cache},r.updateQueue.baseState=x,r.memoizedState=x,r.flags&256){r=b0(t,r,u,i);break e}else if(u!==h){h=Nn(Error(o(424)),r),Ja(h),r=b0(t,r,u,i);break e}else{switch(t=r.stateNode.containerInfo,t.nodeType){case 9:t=t.body;break;default:t=t.nodeName==="HTML"?t.ownerDocument.body:t}for(wt=zn(t.firstChild),Wt=r,ot=!0,$s=null,Gn=!0,i=n0(r,null,u,i),r.child=i;i;)i.flags=i.flags&-3|4096,i=i.sibling}else{if(Qa(),u===h){r=jr(t,r,i);break e}Lt(t,r,u,i)}r=r.child}return r;case 26:return mc(t,r),t===null?(i=ky(r.type,null,r.pendingProps,null))?r.memoizedState=i:ot||(i=r.type,t=r.pendingProps,u=kc(ge.current).createElement(i),u[Rt]=r,u[qt]=t,$t(u,i,t),_t(u),r.stateNode=u):r.memoizedState=ky(r.type,t.memoizedProps,r.pendingProps,t.memoizedState),null;case 27:return Ne(r),t===null&&ot&&(u=r.stateNode=_y(r.type,r.pendingProps,ge.current),Wt=r,Gn=!0,h=wt,os(r.type)?(Am=h,wt=zn(u.firstChild)):wt=h),Lt(t,r,r.pendingProps.children,i),mc(t,r),t===null&&(r.flags|=4194304),r.child;case 5:return t===null&&ot&&((h=u=wt)&&(u=q_(u,r.type,r.pendingProps,Gn),u!==null?(r.stateNode=u,Wt=r,wt=zn(u.firstChild),Gn=!1,h=!0):h=!1),h||Bs(r)),Ne(r),h=r.type,x=r.pendingProps,C=t!==null?t.memoizedProps:null,u=x.children,_m(h,x)?u=null:C!==null&&_m(h,C)&&(r.flags|=32),r.memoizedState!==null&&(h=jf(t,r,l_,null,null,i),Ai._currentValue=h),mc(t,r),Lt(t,r,u,i),r.child;case 6:return t===null&&ot&&((t=i=wt)&&(i=F_(i,r.pendingProps,Gn),i!==null?(r.stateNode=i,Wt=r,wt=null,t=!0):t=!1),t||Bs(r)),null;case 13:return w0(t,r,i);case 4:return le(r,r.stateNode.containerInfo),u=r.pendingProps,t===null?r.child=$o(r,null,u,i):Lt(t,r,u,i),r.child;case 11:return m0(t,r,r.type,r.pendingProps,i);case 7:return Lt(t,r,r.pendingProps,i),r.child;case 8:return Lt(t,r,r.pendingProps.children,i),r.child;case 12:return Lt(t,r,r.pendingProps.children,i),r.child;case 10:return u=r.pendingProps,qr(r,r.type,u.value),Lt(t,r,u.children,i),r.child;case 9:return h=r.type._context,u=r.pendingProps.children,Us(r),h=Ft(h),u=u(h),r.flags|=1,Lt(t,r,u,i),r.child;case 14:return h0(t,r,r.type,r.pendingProps,i);case 15:return p0(t,r,r.type,r.pendingProps,i);case 19:return S0(t,r,i);case 31:return u=r.pendingProps,i=r.mode,u={mode:u.mode,children:u.children},t===null?(i=hc(u,i),i.ref=r.ref,r.child=i,i.return=r,r=i):(i=xr(t.child,u),i.ref=r.ref,r.child=i,i.return=r,r=i),r;case 22:return g0(t,r,i);case 24:return Us(r),u=Ft(At),t===null?(h=pf(),h===null&&(h=pt,x=mf(),h.pooledCache=x,x.refCount++,x!==null&&(h.pooledCacheLanes|=i),h=x),r.memoizedState={parent:u,cache:h},xf(r),qr(r,At,h)):((t.lanes&i)!==0&&(yf(t,r),ii(r,null,null,i),ai()),h=t.memoizedState,x=r.memoizedState,h.parent!==u?(h={parent:u,cache:u},r.memoizedState=h,r.lanes===0&&(r.memoizedState=r.updateQueue.baseState=h),qr(r,At,u)):(u=x.cache,qr(r,At,u),u!==h.cache&&ff(r,[At],i,!0))),Lt(t,r,r.pendingProps.children,i),r.child;case 29:throw r.pendingProps}throw Error(o(156,r.tag))}function _r(t){t.flags|=4}function _0(t,r){if(r.type!=="stylesheet"||(r.state.loading&4)!==0)t.flags&=-16777217;else if(t.flags|=16777216,!Dy(r)){if(r=En.current,r!==null&&((et&4194048)===et?Xn!==null:(et&62914560)!==et&&(et&536870912)===0||r!==Xn))throw si=gf,lx;t.flags|=8192}}function pc(t,r){r!==null&&(t.flags|=4),t.flags&16384&&(r=t.tag!==22?Fn():536870912,t.lanes|=r,Vo|=r)}function hi(t,r){if(!ot)switch(t.tailMode){case"hidden":r=t.tail;for(var i=null;r!==null;)r.alternate!==null&&(i=r),r=r.sibling;i===null?t.tail=null:i.sibling=null;break;case"collapsed":i=t.tail;for(var u=null;i!==null;)i.alternate!==null&&(u=i),i=i.sibling;u===null?r||t.tail===null?t.tail=null:t.tail.sibling=null:u.sibling=null}}function vt(t){var r=t.alternate!==null&&t.alternate.child===t.child,i=0,u=0;if(r)for(var h=t.child;h!==null;)i|=h.lanes|h.childLanes,u|=h.subtreeFlags&65011712,u|=h.flags&65011712,h.return=t,h=h.sibling;else for(h=t.child;h!==null;)i|=h.lanes|h.childLanes,u|=h.subtreeFlags,u|=h.flags,h.return=t,h=h.sibling;return t.subtreeFlags|=u,t.childLanes=i,r}function x_(t,r,i){var u=r.pendingProps;switch(lf(r),r.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return vt(r),null;case 1:return vt(r),null;case 3:return i=r.stateNode,u=null,t!==null&&(u=t.memoizedState.cache),r.memoizedState.cache!==u&&(r.flags|=2048),wr(At),ve(),i.pendingContext&&(i.context=i.pendingContext,i.pendingContext=null),(t===null||t.child===null)&&(Ka(r)?_r(r):t===null||t.memoizedState.isDehydrated&&(r.flags&256)===0||(r.flags|=1024,rx())),vt(r),null;case 26:return i=r.memoizedState,t===null?(_r(r),i!==null?(vt(r),_0(r,i)):(vt(r),r.flags&=-16777217)):i?i!==t.memoizedState?(_r(r),vt(r),_0(r,i)):(vt(r),r.flags&=-16777217):(t.memoizedProps!==u&&_r(r),vt(r),r.flags&=-16777217),null;case 27:_e(r),i=ge.current;var h=r.type;if(t!==null&&r.stateNode!=null)t.memoizedProps!==u&&_r(r);else{if(!u){if(r.stateNode===null)throw Error(o(166));return vt(r),null}t=re.current,Ka(r)?tx(r):(t=_y(h,u,i),r.stateNode=t,_r(r))}return vt(r),null;case 5:if(_e(r),i=r.type,t!==null&&r.stateNode!=null)t.memoizedProps!==u&&_r(r);else{if(!u){if(r.stateNode===null)throw Error(o(166));return vt(r),null}if(t=re.current,Ka(r))tx(r);else{switch(h=kc(ge.current),t){case 1:t=h.createElementNS("http://www.w3.org/2000/svg",i);break;case 2:t=h.createElementNS("http://www.w3.org/1998/Math/MathML",i);break;default:switch(i){case"svg":t=h.createElementNS("http://www.w3.org/2000/svg",i);break;case"math":t=h.createElementNS("http://www.w3.org/1998/Math/MathML",i);break;case"script":t=h.createElement("div"),t.innerHTML="