diff --git a/.github/workflows/python-check-coverage.py b/.github/workflows/python-check-coverage.py
index c8f96cd0ab..9f48cbdcbf 100644
--- a/.github/workflows/python-check-coverage.py
+++ b/.github/workflows/python-check-coverage.py
@@ -30,9 +30,11 @@ from dataclasses import dataclass
# =============================================================================
ENFORCED_MODULES: set[str] = {
"packages.azure-ai.agent_framework_azure_ai",
+ "packages.core.agent_framework",
+ "packages.core.agent_framework._workflows",
+ "packages.purview.agent_framework_purview",
# Add more modules here as coverage improves:
- # "packages.core.agent_framework",
- # "packages.core.agent_framework._workflows",
+ # "packages.azure-ai-search.agent_framework_azure_ai_search",
# "packages.anthropic.agent_framework_anthropic",
}
diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 98c7376aaf..d0227c1dbe 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -33,18 +33,18 @@
-
+
-
+
-
-
+
+
@@ -61,9 +61,9 @@
-
-
-
+
+
+
@@ -71,11 +71,11 @@
-
+
-
+
diff --git a/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Program.cs b/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Program.cs
index aa18fdd286..426b40f1f5 100644
--- a/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Program.cs
+++ b/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Program.cs
@@ -5,7 +5,6 @@
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
-using OpenAI.Responses;
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set.");
var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-5";
@@ -15,14 +14,10 @@ var client = new OpenAIClient(apiKey)
.AsIChatClient().AsBuilder()
.ConfigureOptions(o =>
{
- o.RawRepresentationFactory = _ => new CreateResponseOptions()
+ o.Reasoning = new()
{
- ReasoningOptions = new()
- {
- ReasoningEffortLevel = ResponseReasoningEffortLevel.Medium,
- // Verbosity requires OpenAI verified Organization
- ReasoningSummaryVerbosity = ResponseReasoningSummaryVerbosity.Detailed
- }
+ Effort = ReasoningEffort.Medium,
+ Output = ReasoningOutput.Full,
};
}).Build();
diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs
index 8f38378626..a266aed278 100644
--- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs
+++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs
@@ -21,7 +21,7 @@ AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: VisionName, model
ChatMessage message = new(ChatRole.User, [
new TextContent("What do you see in this image?"),
- new DataContent(File.ReadAllBytes("assets/walkway.jpg"), "image/jpeg")
+ await DataContent.LoadFromAsync("assets/walkway.jpg"),
]);
AgentSession session = await agent.CreateSessionAsync();
diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs
index 82ca49705e..1c12f96796 100644
--- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs
@@ -283,8 +283,13 @@ public static partial class AzureAIProjectChatClientExtensions
TextOptions = new() { TextFormat = ToOpenAIResponseTextFormat(options.ChatOptions?.ResponseFormat, options.ChatOptions) }
};
- // Attempt to capture breaking glass options from the raw representation factory that match the agent definition.
- if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is CreateResponseOptions respCreationOptions)
+ // Map reasoning options from the abstraction-level ChatOptions.Reasoning,
+ // falling back to extracting from the raw representation factory for breaking glass scenarios.
+ if (options.ChatOptions?.Reasoning is { } reasoning)
+ {
+ agentDefinition.ReasoningOptions = ToResponseReasoningOptions(reasoning);
+ }
+ else if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is CreateResponseOptions respCreationOptions)
{
agentDefinition.ReasoningOptions = respCreationOptions.ReasoningOptions;
}
@@ -770,6 +775,36 @@ public static partial class AzureAIProjectChatClientExtensions
}
return name;
}
+
+ private static ResponseReasoningOptions? ToResponseReasoningOptions(ReasoningOptions reasoning)
+ {
+ ResponseReasoningEffortLevel? effortLevel = reasoning.Effort switch
+ {
+ ReasoningEffort.Low => ResponseReasoningEffortLevel.Low,
+ ReasoningEffort.Medium => ResponseReasoningEffortLevel.Medium,
+ ReasoningEffort.High => ResponseReasoningEffortLevel.High,
+ ReasoningEffort.ExtraHigh => ResponseReasoningEffortLevel.High,
+ _ => null,
+ };
+
+ ResponseReasoningSummaryVerbosity? summary = reasoning.Output switch
+ {
+ ReasoningOutput.Summary => ResponseReasoningSummaryVerbosity.Concise,
+ ReasoningOutput.Full => ResponseReasoningSummaryVerbosity.Detailed,
+ _ => null,
+ };
+
+ if (effortLevel is null && summary is null)
+ {
+ return null;
+ }
+
+ return new ResponseReasoningOptions
+ {
+ ReasoningEffortLevel = effortLevel,
+ ReasoningSummaryVerbosity = summary,
+ };
+ }
}
[JsonSerializable(typeof(JsonElement))]
diff --git a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs
index 0556430636..cdec40dd96 100644
--- a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs
@@ -217,16 +217,15 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
}
});
- List tempFiles = [];
+ string? tempDir = null;
try
{
// Build prompt from text content
string prompt = string.Join("\n", messages.Select(m => m.Text));
// Handle DataContent as attachments
- List? attachments = await ProcessDataContentAttachmentsAsync(
+ (List? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
messages,
- tempFiles,
cancellationToken).ConfigureAwait(false);
// Send the message with attachments
@@ -245,7 +244,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
}
finally
{
- CleanupTempFiles(tempFiles);
+ CleanupTempDir(tempDir);
}
}
finally
@@ -410,45 +409,23 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
return new SessionConfig { Tools = mappedTools, SystemMessage = systemMessage };
}
- private static readonly Dictionary s_mediaTypeExtensions = new(StringComparer.OrdinalIgnoreCase)
- {
- ["image/png"] = ".png",
- ["image/jpeg"] = ".jpg",
- ["image/jpg"] = ".jpg",
- ["image/gif"] = ".gif",
- ["image/webp"] = ".webp",
- ["image/svg+xml"] = ".svg",
- ["text/plain"] = ".txt",
- ["text/html"] = ".html",
- ["text/markdown"] = ".md",
- ["application/json"] = ".json",
- ["application/xml"] = ".xml",
- ["application/pdf"] = ".pdf"
- };
-
- private static string GetExtensionForMediaType(string? mediaType)
- {
- return mediaType is not null && s_mediaTypeExtensions.TryGetValue(mediaType, out string? extension) ? extension : ".dat";
- }
-
- private static async Task?> ProcessDataContentAttachmentsAsync(
+ private static async Task<(List? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
IEnumerable messages,
- List tempFiles,
CancellationToken cancellationToken)
{
List? attachments = null;
+ string? tempDir = null;
foreach (ChatMessage message in messages)
{
foreach (AIContent content in message.Contents)
{
if (content is DataContent dataContent)
{
- // Write DataContent to a temp file
- string tempFilePath = Path.Combine(Path.GetTempPath(), $"agentframework_copilot_data_{Guid.NewGuid()}{GetExtensionForMediaType(dataContent.MediaType)}");
- await File.WriteAllBytesAsync(tempFilePath, dataContent.Data.ToArray(), cancellationToken).ConfigureAwait(false);
- tempFiles.Add(tempFilePath);
+ tempDir ??= Directory.CreateDirectory(
+ Path.Combine(Path.GetTempPath(), $"af_copilot_{Guid.NewGuid():N}")).FullName;
+
+ string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
- // Create attachment
attachments ??= [];
attachments.Add(new UserMessageDataAttachmentsItem
{
@@ -460,19 +437,16 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
}
}
- return attachments;
+ return (attachments, tempDir);
}
- private static void CleanupTempFiles(List tempFiles)
+ private static void CleanupTempDir(string? tempDir)
{
- foreach (string tempFile in tempFiles)
+ if (tempDir is not null)
{
try
{
- if (File.Exists(tempFile))
- {
- File.Delete(tempFile);
- }
+ Directory.Delete(tempDir, recursive: true);
}
catch
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs
index 6a51ce5805..bf5be5310f 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs
@@ -42,14 +42,11 @@ internal sealed class CopyConversationMessagesExecutor(CopyConversationMessages
private IEnumerable? GetInputMessages()
{
- DataValue? messages = null;
+ Throw.IfNull(this.Model.Messages, $"{nameof(this.Model)}.{nameof(this.Model.Messages)}");
- if (this.Model.Messages is not null)
- {
- EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Messages);
- messages = expressionResult.Value;
- }
+ EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Messages);
+ DataValue messages = expressionResult.Value;
- return messages?.ToChatMessages();
+ return messages.ToChatMessages();
}
}
diff --git a/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs b/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs
index 380ea5eaeb..7649635aa7 100644
--- a/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs
+++ b/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs
@@ -304,7 +304,7 @@ internal sealed class WorkflowRunner
ChatMessage? responseMessage =
requestItem switch
{
- FunctionCallContent functionCall => await InvokeFunctionAsync(functionCall).ConfigureAwait(false),
+ FunctionCallContent functionCall when !functionCall.InformationalOnly => await InvokeFunctionAsync(functionCall).ConfigureAwait(false),
FunctionApprovalRequestContent functionApprovalRequest => ApproveFunction(functionApprovalRequest),
McpServerToolApprovalRequestContent mcpApprovalRequest => ApproveMCP(mcpApprovalRequest),
_ => HandleUnknown(requestItem),
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/FunctionInvocationDelegatingAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/FunctionInvocationDelegatingAgentTests.cs
index 695f8a4825..d54a0644a4 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/FunctionInvocationDelegatingAgentTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/FunctionInvocationDelegatingAgentTests.cs
@@ -524,8 +524,15 @@ public sealed class FunctionInvocationDelegatingAgentTests
{
// Arrange
var testFunction = AIFunctionFactory.Create(() => "Function result", "TestFunction", "A test function");
- var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary());
- var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
+ var mockChatClient = new Mock();
+
+ mockChatClient.Setup(c => c.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(() => new ChatResponse([
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call_123", "TestFunction", new Dictionary())])
+ ]));
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List { new(ChatRole.User, "Test message") };
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CopyConversationMessagesExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CopyConversationMessagesExecutorTest.cs
new file mode 100644
index 0000000000..cb818fec15
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CopyConversationMessagesExecutorTest.cs
@@ -0,0 +1,202 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
+using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
+using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
+using Microsoft.Agents.ObjectModel;
+using Microsoft.Extensions.AI;
+using Microsoft.PowerFx.Types;
+using Xunit.Abstractions;
+
+namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
+
+///
+/// Tests for .
+///
+public sealed class CopyConversationMessagesExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
+{
+ [Fact]
+ public async Task CopyMessagesWithSingleStringMessageAsync()
+ {
+ // Arrange, Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(CopyMessagesWithSingleStringMessageAsync),
+ conversationId: "TestConversationId",
+ messages: ValueExpression.Literal(StringDataValue.Create("Hello, how can I help you?")),
+ expectedMessageCount: 1);
+ }
+
+ [Fact]
+ public async Task CopyMessagesWithSingleRecordMessageAsync()
+ {
+ // Arrange
+ ChatMessage testMessage = new(ChatRole.User, "Test message content");
+ DataValue messageDataValue = testMessage.ToRecord().ToDataValue();
+ Assert.IsType(messageDataValue);
+ RecordDataValue messageRecord = (RecordDataValue)messageDataValue;
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(CopyMessagesWithSingleRecordMessageAsync),
+ conversationId: "TestConversationId",
+ messages: ValueExpression.Literal(messageRecord),
+ expectedMessageCount: 1);
+ }
+
+ [Fact]
+ public async Task CopyMessagesWithMultipleMessagesAsync()
+ {
+ // Arrange
+ List testMessages =
+ [
+ new ChatMessage(ChatRole.User, "First message"),
+ new ChatMessage(ChatRole.Assistant, "Second message"),
+ new ChatMessage(ChatRole.User, "Third message")
+ ];
+ DataValue messagesDataValue = testMessages.ToTable().ToDataValue();
+ Assert.IsType(messagesDataValue);
+ TableDataValue messagesTable = (TableDataValue)messagesDataValue;
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(CopyMessagesWithMultipleMessagesAsync),
+ conversationId: "TestConversationId",
+ messages: ValueExpression.Literal(messagesTable),
+ expectedMessageCount: 3);
+ }
+
+ [Fact]
+ public async Task CopyMessagesWithVariableExpressionAsync()
+ {
+ // Arrange
+ List testMessages =
+ [
+ new ChatMessage(ChatRole.User, "Message from variable")
+ ];
+ TableValue messagesTable = testMessages.ToTable();
+ this.State.Set("SourceMessages", messagesTable);
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(CopyMessagesWithVariableExpressionAsync),
+ conversationId: "TestConversationId",
+ messages: ValueExpression.Variable(PropertyPath.TopicVariable("SourceMessages")),
+ expectedMessageCount: 1);
+ }
+
+ [Fact]
+ public async Task CopyMessagesToWorkflowConversationAsync()
+ {
+ // Arrange
+ this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System);
+
+ List testMessages =
+ [
+ new ChatMessage(ChatRole.User, "Message to workflow conversation")
+ ];
+ DataValue messagesDataValue = testMessages.ToTable().ToDataValue();
+ Assert.IsType(messagesDataValue);
+ TableDataValue messagesTable = (TableDataValue)messagesDataValue;
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(CopyMessagesToWorkflowConversationAsync),
+ conversationId: "WorkflowConversationId",
+ messages: ValueExpression.Literal(messagesTable),
+ expectedMessageCount: 1,
+ expectWorkflowEvent: true);
+ }
+
+ [Fact]
+ public async Task CopyMessagesToNonWorkflowConversationAsync()
+ {
+ // Arrange
+ this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System);
+
+ List testMessages =
+ [
+ new ChatMessage(ChatRole.User, "Message to non-workflow conversation")
+ ];
+ DataValue messagesDataValue = testMessages.ToTable().ToDataValue();
+ Assert.IsType(messagesDataValue);
+ TableDataValue messagesTable = (TableDataValue)messagesDataValue;
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(CopyMessagesToNonWorkflowConversationAsync),
+ conversationId: "DifferentConversationId",
+ messages: ValueExpression.Literal(messagesTable),
+ expectedMessageCount: 1,
+ expectWorkflowEvent: false);
+ }
+
+ [Fact]
+ public async Task CopyMessagesWithBlankDataValueAsync()
+ {
+ // Arrange, Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(CopyMessagesWithBlankDataValueAsync),
+ conversationId: "TestConversationId",
+ messages: ValueExpression.Literal(DataValue.Blank()),
+ expectedMessageCount: 0);
+ }
+
+ private async Task ExecuteTestAsync(
+ string displayName,
+ string conversationId,
+ ValueExpression messages,
+ int expectedMessageCount,
+ bool expectWorkflowEvent = false)
+ {
+ // Arrange
+ MockAgentProvider mockAgentProvider = new();
+ mockAgentProvider.TestMessages.Clear();
+
+ CopyConversationMessages model = this.CreateModel(
+ this.FormatDisplayName(displayName),
+ conversationId,
+ messages);
+
+ CopyConversationMessagesExecutor action = new(model, mockAgentProvider.Object, this.State);
+
+ // Act
+ WorkflowEvent[] events = await this.ExecuteAsync(action);
+
+ // Assert
+ Assert.Equal(expectedMessageCount, mockAgentProvider.TestMessages.Count);
+ VerifyModel(model, action);
+
+ AgentResponseEvent[] responseEvents = events.OfType().ToArray();
+ if (expectWorkflowEvent && expectedMessageCount > 0)
+ {
+ Assert.NotEmpty(responseEvents);
+ AgentResponseEvent responseEvent = responseEvents.First();
+ Assert.Equal(action.Id, responseEvent.ExecutorId);
+ Assert.NotNull(responseEvent.Response);
+ Assert.Equal(expectedMessageCount, responseEvent.Response.Messages.Count);
+ }
+ else
+ {
+ Assert.Empty(responseEvents);
+ }
+ }
+
+ private CopyConversationMessages CreateModel(
+ string displayName,
+ string conversationId,
+ ValueExpression messages)
+ {
+ CopyConversationMessages.Builder actionBuilder = new()
+ {
+ Id = this.CreateActionId(),
+ DisplayName = this.FormatDisplayName(displayName),
+ ConversationId = StringExpression.Literal(conversationId),
+ Messages = messages
+ };
+
+ return AssignParent(actionBuilder);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/DefaultActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/DefaultActionExecutorTest.cs
new file mode 100644
index 0000000000..0e7f0a4558
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/DefaultActionExecutorTest.cs
@@ -0,0 +1,50 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
+using Microsoft.Agents.ObjectModel;
+using Xunit.Abstractions;
+
+namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
+
+///
+/// Tests for .
+///
+public sealed class DefaultActionExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
+{
+ [Fact]
+ public async Task ExecuteDefaultActionAsync()
+ {
+ // Arrange, Act & Assert
+ await this.ExecuteTestAsync(
+ this.FormatDisplayName(nameof(ExecuteDefaultActionAsync)));
+ }
+
+ private async Task ExecuteTestAsync(string displayName)
+ {
+ // Arrange
+ ResetVariable model = this.CreateModel(displayName);
+
+ // Act
+ DefaultActionExecutor action = new(model, this.State);
+ WorkflowEvent[] events = await this.ExecuteAsync(action);
+
+ // Assert
+ VerifyModel(model, action);
+ Assert.NotEmpty(events);
+ }
+
+ private ResetVariable CreateModel(string displayName)
+ {
+ // Use a simple concrete action type since DialogAction.Builder is abstract
+ ResetVariable.Builder actionBuilder =
+ new()
+ {
+ Id = this.CreateActionId(),
+ DisplayName = this.FormatDisplayName(displayName),
+ Variable = PropertyPath.Create(FormatVariablePath("TestVariable")),
+ };
+
+ return AssignParent(actionBuilder);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/EditTableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/EditTableExecutorTest.cs
new file mode 100644
index 0000000000..6c422247f1
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/EditTableExecutorTest.cs
@@ -0,0 +1,442 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
+using Microsoft.Agents.ObjectModel;
+using Microsoft.PowerFx.Types;
+using Xunit.Abstractions;
+
+namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
+
+///
+/// Tests for .
+///
+public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
+{
+ [Fact]
+ public void InvalidModelNullItemsVariable() =>
+ // Arrange, Act, Assert
+ Assert.Throws(() => new EditTableExecutor(new EditTable(), this.State));
+
+ [Fact]
+ public async Task AddItemToTableAsync()
+ {
+ // Arrange - Initialize table using Power FX expression
+ FormulaValue tableValue = this.State.Engine.Eval("[{id: 3}]");
+ this.State.Set("MyTable", tableValue);
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(AddItemToTableAsync),
+ variableName: "MyTable",
+ changeType: TableChangeType.Add,
+ value: new RecordDataValue([new("id", new NumberDataValue(7))]));
+
+ // Verify the variable now contains the added record
+ FormulaValue resultValue = this.State.Get("MyTable");
+ RecordValue resultRecord = Assert.IsAssignableFrom(resultValue);
+ DecimalValue idValue = Assert.IsType(resultRecord.GetField("id"));
+ Assert.Equal(7, idValue.Value);
+ }
+
+ [Fact]
+ public async Task AddItemWithMultipleFieldsAsync()
+ {
+ // Arrange - Initialize table using Power FX expression
+ FormulaValue tableValue = this.State.Engine.Eval("[{id: 1, name: \"First\"}]");
+ this.State.Set("MyTable", tableValue);
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(AddItemWithMultipleFieldsAsync),
+ variableName: "MyTable",
+ changeType: TableChangeType.Add,
+ value: new RecordDataValue([
+ new("id", new NumberDataValue(2)),
+ new("name", new StringDataValue("Second"))
+ ]));
+
+ // Verify the variable now contains the added record
+ FormulaValue resultValue = this.State.Get("MyTable");
+ RecordValue resultRecord = Assert.IsAssignableFrom(resultValue);
+ DecimalValue idValue = Assert.IsType(resultRecord.GetField("id"));
+ Assert.Equal(2, idValue.Value);
+ StringValue nameValue = Assert.IsType(resultRecord.GetField("name"));
+ Assert.Equal("Second", nameValue.Value);
+ }
+
+ [Fact]
+ public async Task AddItemToEmptyTableAsync()
+ {
+ // Arrange - Initialize empty table using Power FX expression with schema
+ FormulaValue tableValue = this.State.Engine.Eval("Table({id: 1})");
+ TableValue table = Assert.IsAssignableFrom(tableValue);
+ // Clear the table to make it empty but preserve schema
+ await table.ClearAsync(CancellationToken.None);
+ this.State.Set("MyTable", table);
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(AddItemToEmptyTableAsync),
+ variableName: "MyTable",
+ changeType: TableChangeType.Add,
+ value: new RecordDataValue([new("id", new NumberDataValue(1))]));
+
+ // Verify the variable now contains the added record
+ FormulaValue resultValue = this.State.Get("MyTable");
+ RecordValue resultRecord = Assert.IsAssignableFrom(resultValue);
+ DecimalValue idValue = Assert.IsType(resultRecord.GetField("id"));
+ Assert.Equal(1, idValue.Value);
+ }
+
+ [Fact]
+ public async Task RemoveItemFromTableAsync()
+ {
+ // Arrange - Initialize table using Power FX expression
+ FormulaValue tableValue = this.State.Engine.Eval("[{id: 3}, {id: 7}]");
+ this.State.Set("MyTable", tableValue);
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(RemoveItemFromTableAsync),
+ variableName: "MyTable",
+ changeType: TableChangeType.Remove,
+ value: new TableDataValue([new RecordDataValue([new("id", new NumberDataValue(3))])]));
+
+ // Verify the variable now contains an empty record
+ FormulaValue resultValue = this.State.Get("MyTable");
+ RecordValue resultRecord = Assert.IsAssignableFrom(resultValue);
+ // Empty record should have no fields
+ Assert.Empty(resultRecord.Fields);
+ }
+
+ [Fact]
+ public async Task RemoveMultipleItemsFromTableAsync()
+ {
+ // Arrange - Initialize table using Power FX expression
+ FormulaValue tableValue = this.State.Engine.Eval("[{id: 1}, {id: 2}, {id: 3}]");
+ this.State.Set("MyTable", tableValue);
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(RemoveMultipleItemsFromTableAsync),
+ variableName: "MyTable",
+ changeType: TableChangeType.Remove,
+ value: new TableDataValue([
+ new RecordDataValue([new("id", new NumberDataValue(1))]),
+ new RecordDataValue([new("id", new NumberDataValue(3))])
+ ]));
+
+ // Verify the variable now contains an empty record
+ FormulaValue resultValue = this.State.Get("MyTable");
+ RecordValue resultRecord = Assert.IsAssignableFrom(resultValue);
+ // Empty record should have no fields
+ Assert.Empty(resultRecord.Fields);
+ }
+
+ [Fact]
+ public async Task ClearTableAsync()
+ {
+ // Arrange - Initialize table using Power FX expression
+ FormulaValue tableValue = this.State.Engine.Eval("[{id: 1}, {id: 2}]");
+ this.State.Set("MyTable", tableValue);
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(ClearTableAsync),
+ variableName: "MyTable",
+ changeType: TableChangeType.Clear,
+ value: null);
+
+ // Verify table is cleared
+ FormulaValue resultValue = this.State.Get("MyTable");
+ Assert.IsType(resultValue);
+ }
+
+ [Fact]
+ public async Task ClearEmptyTableAsync()
+ {
+ // Arrange - Initialize empty table using Power FX expression with schema
+ FormulaValue tableValue = this.State.Engine.Eval("Table({id: 1})");
+ TableValue table = Assert.IsAssignableFrom(tableValue);
+ // Clear the table to make it empty but preserve schema
+ await table.ClearAsync(CancellationToken.None);
+ this.State.Set("MyTable", table);
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(ClearEmptyTableAsync),
+ variableName: "MyTable",
+ changeType: TableChangeType.Clear,
+ value: null);
+
+ // Verify table is blank
+ FormulaValue resultValue = this.State.Get("MyTable");
+ Assert.IsType(resultValue);
+ }
+
+ [Fact]
+ public async Task TakeFirstItemAsync()
+ {
+ // Arrange - Initialize table using Power FX expression
+ FormulaValue tableValue = this.State.Engine.Eval("[{id: 10}, {id: 20}, {id: 30}]");
+ this.State.Set("MyTable", tableValue);
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(TakeFirstItemAsync),
+ variableName: "MyTable",
+ changeType: TableChangeType.TakeFirst,
+ value: null);
+
+ // Verify the variable now contains the first record that was taken
+ FormulaValue resultValue = this.State.Get("MyTable");
+ RecordValue resultRecord = Assert.IsAssignableFrom(resultValue);
+ DecimalValue idValue = Assert.IsType(resultRecord.GetField("id"));
+ Assert.Equal(10, idValue.Value);
+ }
+
+ [Fact]
+ public async Task TakeFirstFromEmptyTableAsync()
+ {
+ // Arrange - Initialize empty table using Power FX expression with schema
+ FormulaValue tableValue = this.State.Engine.Eval("Table({id: 1})");
+ TableValue table = Assert.IsAssignableFrom(tableValue);
+ // Clear the table to make it empty but preserve schema
+ await table.ClearAsync(CancellationToken.None);
+ this.State.Set("MyTable", table);
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(TakeFirstFromEmptyTableAsync),
+ variableName: "MyTable",
+ changeType: TableChangeType.TakeFirst,
+ value: null);
+
+ // Verify table is still empty (nothing was taken, variable remains unchanged)
+ FormulaValue resultValue = this.State.Get("MyTable");
+ TableValue resultTable = Assert.IsAssignableFrom(resultValue);
+ Assert.Empty(resultTable.Rows);
+ }
+
+ [Fact]
+ public async Task TakeLastItemAsync()
+ {
+ // Arrange - Initialize table using Power FX expression
+ FormulaValue tableValue = this.State.Engine.Eval("[{id: 10}, {id: 20}, {id: 30}]");
+ this.State.Set("MyTable", tableValue);
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(TakeLastItemAsync),
+ variableName: "MyTable",
+ changeType: TableChangeType.TakeLast,
+ value: null);
+
+ // Verify the variable now contains the last record that was taken
+ FormulaValue resultValue = this.State.Get("MyTable");
+ RecordValue resultRecord = Assert.IsAssignableFrom(resultValue);
+ DecimalValue idValue = Assert.IsType(resultRecord.GetField("id"));
+ Assert.Equal(30, idValue.Value);
+ }
+
+ [Fact]
+ public async Task TakeLastFromEmptyTableAsync()
+ {
+ // Arrange - Initialize empty table using Power FX expression with schema
+ FormulaValue tableValue = this.State.Engine.Eval("Table({id: 1})");
+ TableValue table = Assert.IsAssignableFrom(tableValue);
+ // Clear the table to make it empty but preserve schema
+ await table.ClearAsync(CancellationToken.None);
+ this.State.Set("MyTable", table);
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(TakeLastFromEmptyTableAsync),
+ variableName: "MyTable",
+ changeType: TableChangeType.TakeLast,
+ value: null);
+
+ // Verify table is still empty (nothing was taken, variable remains unchanged)
+ FormulaValue resultValue = this.State.Get("MyTable");
+ TableValue resultTable = Assert.IsAssignableFrom(resultValue);
+ Assert.Empty(resultTable.Rows);
+ }
+
+ [Fact]
+ public async Task TakeFirstFromSingleItemTableAsync()
+ {
+ // Arrange - Initialize table using Power FX expression
+ FormulaValue tableValue = this.State.Engine.Eval("[{id: 100}]");
+ this.State.Set("MyTable", tableValue);
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(TakeFirstFromSingleItemTableAsync),
+ variableName: "MyTable",
+ changeType: TableChangeType.TakeFirst,
+ value: null);
+
+ // Verify variable contains the record that was taken
+ FormulaValue resultValue = this.State.Get("MyTable");
+ RecordValue resultRecord = Assert.IsAssignableFrom(resultValue);
+ DecimalValue idValue = Assert.IsType(resultRecord.GetField("id"));
+ Assert.Equal(100, idValue.Value);
+ }
+
+ [Fact]
+ public async Task TakeLastFromSingleItemTableAsync()
+ {
+ // Arrange - Initialize table using Power FX expression
+ FormulaValue tableValue = this.State.Engine.Eval("[{id: 100}]");
+ this.State.Set("MyTable", tableValue);
+
+ // Act, Assert
+ await this.ExecuteTestAsync(
+ displayName: nameof(TakeLastFromSingleItemTableAsync),
+ variableName: "MyTable",
+ changeType: TableChangeType.TakeLast,
+ value: null);
+
+ // Verify variable contains the record that was taken
+ FormulaValue resultValue = this.State.Get("MyTable");
+ RecordValue resultRecord = Assert.IsAssignableFrom(resultValue);
+ DecimalValue idValue = Assert.IsType(resultRecord.GetField("id"));
+ Assert.Equal(100, idValue.Value);
+ }
+
+ [Fact]
+ public async Task ErrorWhenVariableIsNotTableAsync()
+ {
+ // Arrange
+ this.State.Set("NotATable", FormulaValue.New("This is a string, not a table"));
+
+ EditTable model = this.CreateModel(
+ nameof(ErrorWhenVariableIsNotTableAsync),
+ "NotATable",
+ TableChangeType.Add,
+ new RecordDataValue([new("id", new NumberDataValue(1))]));
+
+ // Act
+ EditTableExecutor action = new(model, this.State);
+
+ // Assert - Should throw an exception for non-table variable
+ DeclarativeActionException exception = await Assert.ThrowsAsync(
+ async () => await this.ExecuteAsync(action));
+ Assert.NotNull(exception);
+ }
+
+ [Fact]
+ public async Task AddWithExpressionAsync()
+ {
+ // Arrange - Initialize table using Power FX expression
+ FormulaValue tableValue = this.State.Engine.Eval("[{id: 5}]");
+ this.State.Set("MyTable", tableValue);
+ this.State.Set("NewId", FormulaValue.New(10));
+
+ EditTable model = this.CreateModel(
+ nameof(AddWithExpressionAsync),
+ "MyTable",
+ TableChangeType.Add,
+ ValueExpression.Expression("{id: Local.NewId}"));
+
+ // Act
+ EditTableExecutor action = new(model, this.State);
+ await this.ExecuteAsync(action);
+
+ // Assert - Variable should contain the newly added record
+ VerifyModel(model, action);
+ FormulaValue resultValue = this.State.Get("MyTable");
+ RecordValue resultRecord = Assert.IsAssignableFrom(resultValue);
+ DecimalValue idValue = Assert.IsType(resultRecord.GetField("id"));
+ Assert.Equal(10, idValue.Value);
+ }
+
+ [Fact]
+ public async Task RemoveWithNonTableValueAsync()
+ {
+ // Arrange - Initialize table using Power FX expression
+ FormulaValue tableValue = this.State.Engine.Eval("[{id: 1}, {id: 2}]");
+ this.State.Set("MyTable", tableValue);
+
+ // Try to remove using a non-table value (should not throw, just not remove anything)
+ EditTable model = this.CreateModel(
+ nameof(RemoveWithNonTableValueAsync),
+ "MyTable",
+ TableChangeType.Remove,
+ new RecordDataValue([new("id", new NumberDataValue(1))]));
+
+ // Act
+ EditTableExecutor action = new(model, this.State);
+ await this.ExecuteAsync(action);
+
+ // Assert - table should remain unchanged since value is not a TableDataValue
+ VerifyModel(model, action);
+ FormulaValue resultValue = this.State.Get("MyTable");
+ TableValue resultTable = Assert.IsAssignableFrom(resultValue);
+ Assert.Equal(2, resultTable.Rows.Count());
+ }
+
+ private async Task ExecuteTestAsync(
+ string displayName,
+ string variableName,
+ TableChangeType changeType,
+ DataValue? value)
+ {
+ // Arrange
+ EditTable model = this.CreateModel(displayName, variableName, changeType, value);
+
+ // Act
+ EditTableExecutor action = new(model, this.State);
+ await this.ExecuteAsync(action);
+
+ // Assert
+ VerifyModel(model, action);
+ }
+
+ private EditTable CreateModel(
+ string displayName,
+ string variableName,
+ TableChangeType changeType,
+ DataValue? value)
+ {
+ ValueExpression.Builder? valueExpressionBuilder = value switch
+ {
+ null => null,
+ _ => new ValueExpression.Builder(ValueExpression.Literal(value))
+ };
+
+ return this.CreateModel(displayName, variableName, changeType, valueExpressionBuilder);
+ }
+
+ private EditTable CreateModel(
+ string displayName,
+ string variableName,
+ TableChangeType changeType,
+ ValueExpression valueExpression)
+ {
+ ValueExpression.Builder valueExpressionBuilder = new(valueExpression);
+ return this.CreateModel(displayName, variableName, changeType, valueExpressionBuilder);
+ }
+
+ private EditTable CreateModel(
+ string displayName,
+ string variableName,
+ TableChangeType changeType,
+ ValueExpression.Builder? valueExpression)
+ {
+ EditTable.Builder actionBuilder = new()
+ {
+ Id = this.CreateActionId(),
+ DisplayName = this.FormatDisplayName(displayName),
+ ItemsVariable = PropertyPath.Create(FormatVariablePath(variableName)),
+ ChangeType = TableChangeTypeWrapper.Get(changeType),
+ Value = valueExpression,
+ };
+
+ return AssignParent(actionBuilder);
+ }
+}
diff --git a/python/.cspell.json b/python/.cspell.json
index db575845e8..a26cc7fed7 100644
--- a/python/.cspell.json
+++ b/python/.cspell.json
@@ -24,8 +24,8 @@
],
"words": [
"aeiou",
- "aiplatform",
"agui",
+ "aiplatform",
"azuredocindex",
"azuredocs",
"azurefunctions",
@@ -57,20 +57,22 @@
"nopep",
"NOSQL",
"ollama",
- "otlp",
"Onnx",
"onyourdatatest",
"OPENAI",
"opentelemetry",
"OTEL",
+ "otlp",
"powerfx",
"protos",
"pydantic",
"pytestmark",
"qdrant",
"retrywrites",
- "streamable",
"serde",
+ "streamable",
+ "superstep",
+ "supersteps",
"templating",
"uninstrument",
"vectordb",
diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py
index 91bba87f12..bf9992d7ff 100644
--- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py
+++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py
@@ -27,7 +27,7 @@ from agent_framework import (
get_logger,
prepare_function_call_results,
)
-from agent_framework._pydantic import AFBaseSettings
+from agent_framework._settings import SecretString, load_settings
from agent_framework._types import _get_data_bytes_as_str # type: ignore
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.observability import ChatTelemetryLayer
@@ -47,7 +47,7 @@ from anthropic.types.beta.beta_bash_code_execution_tool_result_error import (
from anthropic.types.beta.beta_code_execution_tool_result_error import (
BetaCodeExecutionToolResultError,
)
-from pydantic import BaseModel, SecretStr, ValidationError
+from pydantic import BaseModel
if sys.version_info >= (3, 11):
from typing import TypedDict # type: ignore # pragma: no cover
@@ -192,40 +192,20 @@ FINISH_REASON_MAP: dict[str, FinishReasonLiteral] = {
}
-class AnthropicSettings(AFBaseSettings):
+class AnthropicSettings(TypedDict, total=False):
"""Anthropic Project settings.
The settings are first loaded from environment variables with the prefix 'ANTHROPIC_'.
If the environment variables are not found, the settings can be loaded from a .env file
- with the encoding 'utf-8'. If the settings are not found in the .env file, the settings
- are ignored; however, validation will fail alerting that the settings are missing.
+ with the encoding 'utf-8'.
- Keyword Args:
+ Keys:
api_key: The Anthropic API key.
chat_model_id: The Anthropic chat model ID.
- env_file_path: If provided, the .env settings are read from this file path location.
- env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
-
- Examples:
- .. code-block:: python
-
- from agent_framework.anthropic import AnthropicSettings
-
- # Using environment variables
- # Set ANTHROPIC_API_KEY=your_anthropic_api_key
- # ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929
-
- # Or passing parameters directly
- settings = AnthropicSettings(chat_model_id="claude-sonnet-4-5-20250929")
-
- # Or loading from a .env file
- settings = AnthropicSettings(env_file_path="path/to/.env")
"""
- env_prefix: ClassVar[str] = "ANTHROPIC_"
-
- api_key: SecretStr | None = None
- chat_model_id: str | None = None
+ api_key: SecretString | None
+ chat_model_id: str | None
class AnthropicClient(
@@ -311,25 +291,24 @@ class AnthropicClient(
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
- try:
- anthropic_settings = AnthropicSettings(
- api_key=api_key, # type: ignore[arg-type]
- chat_model_id=model_id,
- env_file_path=env_file_path,
- env_file_encoding=env_file_encoding,
- )
- except ValidationError as ex:
- raise ServiceInitializationError("Failed to create Anthropic settings.", ex) from ex
+ anthropic_settings = load_settings(
+ AnthropicSettings,
+ env_prefix="ANTHROPIC_",
+ api_key=api_key,
+ chat_model_id=model_id,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ )
if anthropic_client is None:
- if not anthropic_settings.api_key:
+ if not anthropic_settings["api_key"]:
raise ServiceInitializationError(
"Anthropic API key is required. Set via 'api_key' parameter "
"or 'ANTHROPIC_API_KEY' environment variable."
)
anthropic_client = AsyncAnthropic(
- api_key=anthropic_settings.api_key.get_secret_value(),
+ api_key=anthropic_settings["api_key"].get_secret_value(),
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
)
@@ -343,7 +322,7 @@ class AnthropicClient(
# Initialize instance variables
self.anthropic_client = anthropic_client
self.additional_beta_flags = additional_beta_flags or []
- self.model_id = anthropic_settings.chat_model_id
+ self.model_id = anthropic_settings["chat_model_id"]
# streaming requires tracking the last function call ID and name
self._last_call_id_name: tuple[str, str] | None = None
diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py
index 80d57de07f..ff9234f60b 100644
--- a/python/packages/anthropic/tests/test_anthropic_client.py
+++ b/python/packages/anthropic/tests/test_anthropic_client.py
@@ -13,6 +13,7 @@ from agent_framework import (
SupportsChatGetResponse,
tool,
)
+from agent_framework._settings import load_settings
from agent_framework.exceptions import ServiceInitializationError
from anthropic.types.beta import (
BetaMessage,
@@ -20,7 +21,7 @@ from anthropic.types.beta import (
BetaToolUseBlock,
BetaUsage,
)
-from pydantic import Field, ValidationError
+from pydantic import Field
from agent_framework_anthropic import AnthropicClient
from agent_framework_anthropic._chat_client import AnthropicSettings
@@ -41,8 +42,12 @@ def create_test_anthropic_client(
) -> AnthropicClient:
"""Helper function to create AnthropicClient instances for testing, bypassing normal validation."""
if anthropic_settings is None:
- anthropic_settings = AnthropicSettings(
- api_key="test-api-key-12345", chat_model_id="claude-3-5-sonnet-20241022", env_file_path="test.env"
+ anthropic_settings = load_settings(
+ AnthropicSettings,
+ env_prefix="ANTHROPIC_",
+ api_key="test-api-key-12345",
+ chat_model_id="claude-3-5-sonnet-20241022",
+ env_file_path="test.env",
)
# Create client instance directly
@@ -50,7 +55,7 @@ def create_test_anthropic_client(
# Set attributes directly
client.anthropic_client = mock_anthropic_client
- client.model_id = model_id or anthropic_settings.chat_model_id
+ client.model_id = model_id or anthropic_settings["chat_model_id"]
client._last_call_id_name = None
client.additional_properties = {}
client.middleware = None
@@ -64,30 +69,34 @@ def create_test_anthropic_client(
def test_anthropic_settings_init(anthropic_unit_test_env: dict[str, str]) -> None:
"""Test AnthropicSettings initialization."""
- settings = AnthropicSettings(env_file_path="test.env")
+ settings = load_settings(AnthropicSettings, env_prefix="ANTHROPIC_", env_file_path="test.env")
- assert settings.api_key is not None
- assert settings.api_key.get_secret_value() == anthropic_unit_test_env["ANTHROPIC_API_KEY"]
- assert settings.chat_model_id == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"]
+ assert settings["api_key"] is not None
+ assert settings["api_key"].get_secret_value() == anthropic_unit_test_env["ANTHROPIC_API_KEY"]
+ assert settings["chat_model_id"] == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"]
def test_anthropic_settings_init_with_explicit_values() -> None:
"""Test AnthropicSettings initialization with explicit values."""
- settings = AnthropicSettings(
- api_key="custom-api-key", chat_model_id="claude-3-opus-20240229", env_file_path="test.env"
+ settings = load_settings(
+ AnthropicSettings,
+ env_prefix="ANTHROPIC_",
+ api_key="custom-api-key",
+ chat_model_id="claude-3-opus-20240229",
+ env_file_path="test.env",
)
- assert settings.api_key is not None
- assert settings.api_key.get_secret_value() == "custom-api-key"
- assert settings.chat_model_id == "claude-3-opus-20240229"
+ assert settings["api_key"] is not None
+ assert settings["api_key"].get_secret_value() == "custom-api-key"
+ assert settings["chat_model_id"] == "claude-3-opus-20240229"
@pytest.mark.parametrize("exclude_list", [["ANTHROPIC_API_KEY"]], indirect=True)
def test_anthropic_settings_missing_api_key(anthropic_unit_test_env: dict[str, str]) -> None:
"""Test AnthropicSettings when API key is missing."""
- settings = AnthropicSettings(env_file_path="test.env")
- assert settings.api_key is None
- assert settings.chat_model_id == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"]
+ settings = load_settings(AnthropicSettings, env_prefix="ANTHROPIC_", env_file_path="test.env")
+ assert settings["api_key"] is None
+ assert settings["chat_model_id"] == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"]
# Client Initialization Tests
@@ -116,23 +125,13 @@ def test_anthropic_client_init_auto_create_client(anthropic_unit_test_env: dict[
def test_anthropic_client_init_missing_api_key() -> None:
"""Test AnthropicClient initialization when API key is missing."""
- with patch("agent_framework_anthropic._chat_client.AnthropicSettings") as mock_settings:
- mock_settings.return_value.api_key = None
- mock_settings.return_value.chat_model_id = "claude-3-5-sonnet-20241022"
+ with patch("agent_framework_anthropic._chat_client.load_settings") as mock_load:
+ mock_load.return_value = {"api_key": None, "chat_model_id": "claude-3-5-sonnet-20241022"}
with pytest.raises(ServiceInitializationError, match="Anthropic API key is required"):
AnthropicClient()
-def test_anthropic_client_init_validation_error() -> None:
- """Test that ValidationError in AnthropicSettings is properly handled."""
- with patch("agent_framework_anthropic._chat_client.AnthropicSettings") as mock_settings:
- mock_settings.side_effect = ValidationError.from_exception_data("test", [])
-
- with pytest.raises(ServiceInitializationError, match="Failed to create Anthropic settings"):
- AnthropicClient()
-
-
def test_anthropic_client_service_url(mock_anthropic_client: MagicMock) -> None:
"""Test service_url method."""
client = create_test_anthropic_client(mock_anthropic_client)
diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py
index bad955d57e..091695165d 100644
--- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py
+++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py
@@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message
from agent_framework._logging import get_logger
from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext
+from agent_framework._settings import load_settings
from agent_framework.exceptions import ServiceInitializationError
from azure.core.credentials import AzureKeyCredential
from azure.core.credentials_async import AsyncTokenCredential
@@ -41,7 +42,6 @@ from azure.search.documents.models import (
VectorizableTextQuery,
VectorizedQuery,
)
-from pydantic import ValidationError
from ._search_provider import AzureAISearchSettings
@@ -180,40 +180,39 @@ class _AzureAISearchContextProvider(BaseContextProvider):
super().__init__(source_id)
# Load settings from environment/file
- try:
- settings = AzureAISearchSettings(
- endpoint=endpoint,
- index_name=index_name,
- knowledge_base_name=knowledge_base_name,
- api_key=api_key if isinstance(api_key, str) else None,
- env_file_path=env_file_path,
- env_file_encoding=env_file_encoding,
- )
- except ValidationError as ex:
- raise ServiceInitializationError("Failed to create Azure AI Search settings.", ex) from ex
+ settings = load_settings(
+ AzureAISearchSettings,
+ env_prefix="AZURE_SEARCH_",
+ endpoint=endpoint,
+ index_name=index_name,
+ knowledge_base_name=knowledge_base_name,
+ api_key=api_key if isinstance(api_key, str) else None,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ )
- if not settings.endpoint:
+ if not settings.get("endpoint"):
raise ServiceInitializationError(
"Azure AI Search endpoint is required. Set via 'endpoint' parameter "
"or 'AZURE_SEARCH_ENDPOINT' environment variable."
)
if mode == "semantic":
- if not settings.index_name:
+ if not settings.get("index_name"):
raise ServiceInitializationError(
"Azure AI Search index name is required for semantic mode. "
"Set via 'index_name' parameter or 'AZURE_SEARCH_INDEX_NAME' environment variable."
)
elif mode == "agentic":
- if settings.index_name and settings.knowledge_base_name:
+ if settings.get("index_name") and settings.get("knowledge_base_name"):
raise ServiceInitializationError(
"For agentic mode, provide either 'index_name' OR 'knowledge_base_name', not both."
)
- if not settings.index_name and not settings.knowledge_base_name:
+ if not settings.get("index_name") and not settings.get("knowledge_base_name"):
raise ServiceInitializationError(
"For agentic mode, provide either 'index_name' or 'knowledge_base_name'."
)
- if settings.index_name and not model_deployment_name:
+ if settings.get("index_name") and not model_deployment_name:
raise ServiceInitializationError(
"model_deployment_name is required for agentic mode when creating Knowledge Base from index."
)
@@ -223,16 +222,16 @@ class _AzureAISearchContextProvider(BaseContextProvider):
resolved_credential = credential
elif isinstance(api_key, AzureKeyCredential):
resolved_credential = api_key
- elif settings.api_key:
- resolved_credential = AzureKeyCredential(settings.api_key.get_secret_value())
+ elif settings.get("api_key"):
+ resolved_credential = AzureKeyCredential(settings["api_key"].get_secret_value()) # type: ignore[union-attr]
else:
raise ServiceInitializationError(
"Azure credential is required. Provide 'api_key' or 'credential' parameter "
"or set 'AZURE_SEARCH_API_KEY' environment variable."
)
- self.endpoint = settings.endpoint
- self.index_name = settings.index_name
+ self.endpoint: str = settings["endpoint"] # type: ignore[assignment] # validated above
+ self.index_name = settings.get("index_name")
self.credential = resolved_credential
self.mode = mode
self.top_k = top_k
@@ -244,7 +243,7 @@ class _AzureAISearchContextProvider(BaseContextProvider):
self.azure_openai_resource_url = azure_openai_resource_url
self.azure_openai_deployment_name = model_deployment_name
self.model_name = model_name or model_deployment_name
- self.knowledge_base_name = settings.knowledge_base_name
+ self.knowledge_base_name = settings.get("knowledge_base_name")
self.retrieval_instructions = retrieval_instructions
self.azure_openai_api_key = azure_openai_api_key
self.knowledge_base_output_mode = knowledge_base_output_mode
@@ -253,10 +252,10 @@ class _AzureAISearchContextProvider(BaseContextProvider):
self._use_existing_knowledge_base = False
if mode == "agentic":
- if settings.knowledge_base_name:
+ if settings.get("knowledge_base_name"):
self._use_existing_knowledge_base = True
else:
- self.knowledge_base_name = f"{settings.index_name}-kb"
+ self.knowledge_base_name = f"{settings.get('index_name', '')}-kb"
self._auto_discovered_vector_field = False
self._use_vectorizable_query = False
diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py
index 332c477d85..5e47b37b00 100644
--- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py
+++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py
@@ -5,11 +5,11 @@ from __future__ import annotations
import sys
from collections.abc import Awaitable, Callable, MutableSequence
-from typing import TYPE_CHECKING, Any, ClassVar, Literal
+from typing import TYPE_CHECKING, Any, Literal
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Context, ContextProvider, Message
from agent_framework._logging import get_logger
-from agent_framework._pydantic import AFBaseSettings
+from agent_framework._settings import SecretString, load_settings
from agent_framework.exceptions import ServiceInitializationError
from azure.core.credentials import AzureKeyCredential
from azure.core.credentials_async import AsyncTokenCredential
@@ -35,7 +35,6 @@ from azure.search.documents.models import (
VectorizableTextQuery,
VectorizedQuery,
)
-from pydantic import SecretStr, ValidationError
# Type checking imports for optional agentic mode dependencies
if TYPE_CHECKING:
@@ -99,9 +98,9 @@ else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
if sys.version_info >= (3, 11):
- from typing import Self # pragma: no cover
+ from typing import Self, TypedDict # pragma: no cover
else:
- from typing_extensions import Self # pragma: no cover
+ from typing_extensions import Self, TypedDict # pragma: no cover
"""Azure AI Search Context Provider for Agent Framework.
@@ -120,7 +119,7 @@ logger = get_logger("agent_framework.azure")
_DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT = 10
-class AzureAISearchSettings(AFBaseSettings):
+class AzureAISearchSettings(TypedDict, total=False):
"""Settings for Azure AI Search Context Provider with auto-loading from environment.
The settings are first loaded from environment variables with the prefix 'AZURE_SEARCH_'.
@@ -158,12 +157,10 @@ class AzureAISearchSettings(AFBaseSettings):
settings = AzureAISearchSettings(env_file_path="path/to/.env")
"""
- env_prefix: ClassVar[str] = "AZURE_SEARCH_"
-
- endpoint: str | None = None
- index_name: str | None = None
- knowledge_base_name: str | None = None
- api_key: SecretStr | None = None
+ endpoint: str | None
+ index_name: str | None
+ knowledge_base_name: str | None
+ api_key: SecretString | None
class AzureAISearchContextProvider(ContextProvider):
@@ -336,42 +333,41 @@ class AzureAISearchContextProvider(ContextProvider):
provider = AzureAISearchContextProvider(credential=credential, env_file_path="path/to/.env")
"""
# Load settings from environment/file
- try:
- settings = AzureAISearchSettings(
- endpoint=endpoint,
- index_name=index_name,
- knowledge_base_name=knowledge_base_name,
- api_key=api_key if isinstance(api_key, str) else None,
- env_file_path=env_file_path,
- env_file_encoding=env_file_encoding,
- )
- except ValidationError as ex:
- raise ServiceInitializationError("Failed to create Azure AI Search settings.", ex) from ex
+ settings = load_settings(
+ AzureAISearchSettings,
+ env_prefix="AZURE_SEARCH_",
+ endpoint=endpoint,
+ index_name=index_name,
+ knowledge_base_name=knowledge_base_name,
+ api_key=api_key if isinstance(api_key, str) else None,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ )
# Validate required parameters
- if not settings.endpoint:
+ if not settings.get("endpoint"):
raise ServiceInitializationError(
"Azure AI Search endpoint is required. Set via 'endpoint' parameter "
"or 'AZURE_SEARCH_ENDPOINT' environment variable."
)
# Validate index_name and knowledge_base_name based on mode
- # Note: settings.* contains the resolved value (explicit param OR env var)
+ # Note: settings["field"] / settings.get("field") contains the resolved value (explicit param OR env var)
if mode == "semantic":
# Semantic mode: always requires index_name
- if not settings.index_name:
+ if not settings.get("index_name"):
raise ServiceInitializationError(
"Azure AI Search index name is required for semantic mode. "
"Set via 'index_name' parameter or 'AZURE_SEARCH_INDEX_NAME' environment variable."
)
elif mode == "agentic":
# Agentic mode: requires exactly ONE of index_name or knowledge_base_name
- if settings.index_name and settings.knowledge_base_name:
+ if settings.get("index_name") and settings.get("knowledge_base_name"):
raise ServiceInitializationError(
"For agentic mode, provide either 'index_name' OR 'knowledge_base_name', not both. "
"Use 'index_name' to auto-create a Knowledge Base, or 'knowledge_base_name' to use an existing one."
)
- if not settings.index_name and not settings.knowledge_base_name:
+ if not settings.get("index_name") and not settings.get("knowledge_base_name"):
raise ServiceInitializationError(
"For agentic mode, provide either 'index_name' (to auto-create Knowledge Base) "
"or 'knowledge_base_name' (to use existing Knowledge Base). "
@@ -379,7 +375,7 @@ class AzureAISearchContextProvider(ContextProvider):
"AZURE_SEARCH_INDEX_NAME / AZURE_SEARCH_KNOWLEDGE_BASE_NAME."
)
# If using index_name to create KB, model config is required
- if settings.index_name and not model_deployment_name:
+ if settings.get("index_name") and not model_deployment_name:
raise ServiceInitializationError(
"model_deployment_name is required for agentic mode when creating Knowledge Base from index. "
"This is the Azure OpenAI deployment used by the Knowledge Base for query planning."
@@ -392,16 +388,16 @@ class AzureAISearchContextProvider(ContextProvider):
resolved_credential = credential
elif isinstance(api_key, AzureKeyCredential):
resolved_credential = api_key
- elif settings.api_key:
- resolved_credential = AzureKeyCredential(settings.api_key.get_secret_value())
+ elif resolved_api_key := settings.get("api_key"):
+ resolved_credential = AzureKeyCredential(resolved_api_key.get_secret_value())
else:
raise ServiceInitializationError(
"Azure credential is required. Provide 'api_key' or 'credential' parameter "
"or set 'AZURE_SEARCH_API_KEY' environment variable."
)
- self.endpoint = settings.endpoint
- self.index_name = settings.index_name
+ self.endpoint: str = settings["endpoint"] # type: ignore[assignment] # validated above
+ self.index_name = settings.get("index_name")
self.credential = resolved_credential
self.mode = mode
self.top_k = top_k
@@ -416,7 +412,7 @@ class AzureAISearchContextProvider(ContextProvider):
# If model_name not provided, default to deployment name
self.model_name = model_name or model_deployment_name
# Use resolved KB name (from explicit param or env var)
- self.knowledge_base_name = settings.knowledge_base_name
+ self.knowledge_base_name = settings.get("knowledge_base_name")
self.retrieval_instructions = retrieval_instructions
self.azure_openai_api_key = azure_openai_api_key
self.knowledge_base_output_mode = knowledge_base_output_mode
@@ -429,12 +425,12 @@ class AzureAISearchContextProvider(ContextProvider):
# - index_name provided: auto-create KB from index
self._use_existing_knowledge_base = False
if mode == "agentic":
- if settings.knowledge_base_name:
+ if settings.get("knowledge_base_name"):
# Use existing KB directly (supports any source type: web, blob, index, etc.)
self._use_existing_knowledge_base = True
else:
# Auto-generate KB name from index name
- self.knowledge_base_name = f"{settings.index_name}-kb"
+ self.knowledge_base_name = f"{settings.get('index_name', '')}-kb"
# Auto-discover vector field if not specified
self._auto_discovered_vector_field = False
diff --git a/python/packages/azure-ai-search/tests/test_search_provider.py b/python/packages/azure-ai-search/tests/test_search_provider.py
index def95cd732..bcdbb9b5ef 100644
--- a/python/packages/azure-ai-search/tests/test_search_provider.py
+++ b/python/packages/azure-ai-search/tests/test_search_provider.py
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import Context, Message
+from agent_framework._settings import load_settings
from agent_framework.azure import AzureAISearchContextProvider, AzureAISearchSettings
from agent_framework.exceptions import ServiceInitializationError
from azure.core.credentials import AzureKeyCredential
@@ -48,25 +49,27 @@ class TestAzureAISearchSettings:
def test_settings_with_direct_values(self) -> None:
"""Test settings with direct values."""
- settings = AzureAISearchSettings(
+ settings = load_settings(
+ AzureAISearchSettings,
+ env_prefix="AZURE_SEARCH_",
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
)
- assert settings.endpoint == "https://test.search.windows.net"
- assert settings.index_name == "test-index"
- # api_key is now SecretStr
- assert settings.api_key.get_secret_value() == "test-key"
+ assert settings["endpoint"] == "https://test.search.windows.net"
+ assert settings["index_name"] == "test-index"
+ assert settings["api_key"] == "test-key"
def test_settings_with_env_file_path(self) -> None:
"""Test settings with env_file_path parameter."""
- settings = AzureAISearchSettings(
+ settings = load_settings(
+ AzureAISearchSettings,
+ env_prefix="AZURE_SEARCH_",
endpoint="https://test.search.windows.net",
index_name="test-index",
- env_file_path="test.env",
)
- assert settings.endpoint == "https://test.search.windows.net"
- assert settings.index_name == "test-index"
+ assert settings["endpoint"] == "https://test.search.windows.net"
+ assert settings["index_name"] == "test-index"
def test_provider_uses_settings_from_env(self) -> None:
"""Test that provider creates settings internally from env."""
diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py b/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py
index c6d68daaa2..5ea9983c50 100644
--- a/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py
+++ b/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py
@@ -15,12 +15,13 @@ from agent_framework import (
normalize_tools,
)
from agent_framework._mcp import MCPTool
+from agent_framework._settings import load_settings
from agent_framework.exceptions import ServiceInitializationError
from azure.ai.agents.aio import AgentsClient
from azure.ai.agents.models import Agent as AzureAgent
from azure.ai.agents.models import ResponseFormatJsonSchema, ResponseFormatJsonSchemaType
from azure.core.credentials_async import AsyncTokenCredential
-from pydantic import BaseModel, ValidationError
+from pydantic import BaseModel
from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions
from ._shared import AzureAISettings, from_azure_ai_agent_tools, to_azure_ai_agent_tools
@@ -112,21 +113,21 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
Raises:
ServiceInitializationError: If required parameters are missing or invalid.
"""
- try:
- self._settings = AzureAISettings(
- project_endpoint=project_endpoint,
- 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
+ self._settings = load_settings(
+ AzureAISettings,
+ env_prefix="AZURE_AI_",
+ project_endpoint=project_endpoint,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ )
self._should_close_client = False
if agents_client is not None:
self._agents_client = agents_client
else:
- if not self._settings.project_endpoint:
+ resolved_endpoint = self._settings.get("project_endpoint")
+ if not resolved_endpoint:
raise ServiceInitializationError(
"Azure AI project endpoint is required. Provide 'project_endpoint' parameter "
"or set 'AZURE_AI_PROJECT_ENDPOINT' environment variable."
@@ -134,7 +135,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
if not credential:
raise ServiceInitializationError("Azure credential is required when agents_client is not provided.")
self._agents_client = AgentsClient(
- endpoint=self._settings.project_endpoint,
+ endpoint=resolved_endpoint,
credential=credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
@@ -211,7 +212,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
tools=get_weather,
)
"""
- resolved_model = model or self._settings.model_deployment_name
+ resolved_model = model or self._settings.get("model_deployment_name")
if not resolved_model:
raise ServiceInitializationError(
"Model deployment name is required. Provide 'model' parameter "
diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py
index 7a11734908..ffb39e6c25 100644
--- a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py
+++ b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py
@@ -35,6 +35,7 @@ from agent_framework import (
get_logger,
prepare_function_call_results,
)
+from agent_framework._settings import load_settings
from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidRequestError, ServiceResponseException
from agent_framework.observability import ChatTelemetryLayer
from azure.ai.agents.aio import AgentsClient
@@ -85,7 +86,7 @@ from azure.ai.agents.models import (
ToolOutput,
)
from azure.core.credentials_async import AsyncTokenCredential
-from pydantic import BaseModel, ValidationError
+from pydantic import BaseModel
from ._shared import AzureAISettings, to_azure_ai_agent_tools
@@ -482,26 +483,26 @@ class AzureAIAgentClient(
client: AzureAIAgentClient[MyOptions] = AzureAIAgentClient(credential=credential)
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
- 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
+ azure_ai_settings = load_settings(
+ AzureAISettings,
+ env_prefix="AZURE_AI_",
+ project_endpoint=project_endpoint,
+ model_deployment_name=model_deployment_name,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ )
# If no agents_client is provided, create one
should_close_client = False
if agents_client is None:
- if not azure_ai_settings.project_endpoint:
+ resolved_endpoint = azure_ai_settings.get("project_endpoint")
+ if not resolved_endpoint:
raise ServiceInitializationError(
"Azure AI project endpoint is required. Set via 'project_endpoint' parameter "
"or 'AZURE_AI_PROJECT_ENDPOINT' environment variable."
)
- if agent_id is None and not azure_ai_settings.model_deployment_name:
+ if agent_id is None and not azure_ai_settings.get("model_deployment_name"):
raise ServiceInitializationError(
"Azure AI model deployment name is required. Set via 'model_deployment_name' parameter "
"or 'AZURE_AI_MODEL_DEPLOYMENT_NAME' environment variable."
@@ -511,7 +512,7 @@ class AzureAIAgentClient(
if not credential:
raise ServiceInitializationError("Azure credential is required when agents_client is not provided.")
agents_client = AgentsClient(
- endpoint=azure_ai_settings.project_endpoint,
+ endpoint=resolved_endpoint,
credential=credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
@@ -530,7 +531,7 @@ class AzureAIAgentClient(
self.agent_id = agent_id
self.agent_name = agent_name
self.agent_description = agent_description
- self.model_id = azure_ai_settings.model_deployment_name
+ self.model_id = azure_ai_settings.get("model_deployment_name")
self.thread_id = thread_id
self.should_cleanup_agent = should_cleanup_agent # Track whether we should delete the agent
self._agent_created = False # Track whether agent was created inside this class
diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_client.py
index 03fb5e84a8..a5881c4c2a 100644
--- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py
+++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py
@@ -20,6 +20,7 @@ from agent_framework import (
MiddlewareTypes,
get_logger,
)
+from agent_framework._settings import load_settings
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.observability import ChatTelemetryLayer
from agent_framework.openai import OpenAIResponsesOptions
@@ -40,7 +41,6 @@ from azure.ai.projects.models import (
from azure.ai.projects.models import FileSearchTool as ProjectsFileSearchTool
from azure.core.credentials_async import AsyncTokenCredential
from azure.core.exceptions import ResourceNotFoundError
-from pydantic import ValidationError
from ._shared import AzureAISettings, create_text_format_config
@@ -171,20 +171,20 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
client: AzureAIClient[MyOptions] = AzureAIClient(credential=credential)
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
- 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
+ azure_ai_settings = load_settings(
+ AzureAISettings,
+ env_prefix="AZURE_AI_",
+ project_endpoint=project_endpoint,
+ model_deployment_name=model_deployment_name,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ )
# If no project_client is provided, create one
should_close_client = False
if project_client is None:
- if not azure_ai_settings.project_endpoint:
+ resolved_endpoint = azure_ai_settings.get("project_endpoint")
+ if not resolved_endpoint:
raise ServiceInitializationError(
"Azure AI project endpoint is required. Set via 'project_endpoint' parameter "
"or 'AZURE_AI_PROJECT_ENDPOINT' environment variable."
@@ -194,7 +194,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
if not credential:
raise ServiceInitializationError("Azure credential is required when project_client is not provided.")
project_client = AIProjectClient(
- endpoint=azure_ai_settings.project_endpoint,
+ endpoint=resolved_endpoint,
credential=credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
@@ -212,7 +212,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
self.use_latest_version = use_latest_version
self.project_client = project_client
self.credential = credential
- self.model_id = azure_ai_settings.model_deployment_name
+ self.model_id = azure_ai_settings.get("model_deployment_name")
self.conversation_id = conversation_id
# Track whether the application endpoint is used
diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py b/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py
index 0a6b571e40..cdf5cad5cf 100644
--- a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py
+++ b/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py
@@ -16,6 +16,7 @@ from agent_framework import (
normalize_tools,
)
from agent_framework._mcp import MCPTool
+from agent_framework._settings import load_settings
from agent_framework.exceptions import ServiceInitializationError
from azure.ai.projects.aio import AIProjectClient
from azure.ai.projects.models import (
@@ -28,7 +29,6 @@ from azure.ai.projects.models import (
FunctionTool as AzureFunctionTool,
)
from azure.core.credentials_async import AsyncTokenCredential
-from pydantic import ValidationError
from ._client import AzureAIClient, AzureAIProjectAgentOptions
from ._shared import AzureAISettings, create_text_format_config, from_azure_ai_tools, to_azure_ai_tools
@@ -123,21 +123,21 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
Raises:
ServiceInitializationError: If required parameters are missing or invalid.
"""
- try:
- self._settings = AzureAISettings(
- project_endpoint=project_endpoint,
- model_deployment_name=model,
- 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
+ self._settings = load_settings(
+ AzureAISettings,
+ env_prefix="AZURE_AI_",
+ project_endpoint=project_endpoint,
+ model_deployment_name=model,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ )
# Track whether we should close client connection
self._should_close_client = False
if project_client is None:
- if not self._settings.project_endpoint:
+ resolved_endpoint = self._settings.get("project_endpoint")
+ if not resolved_endpoint:
raise ServiceInitializationError(
"Azure AI project endpoint is required. Set via 'project_endpoint' parameter "
"or 'AZURE_AI_PROJECT_ENDPOINT' environment variable."
@@ -147,7 +147,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
raise ServiceInitializationError("Azure credential is required when project_client is not provided.")
project_client = AIProjectClient(
- endpoint=self._settings.project_endpoint,
+ endpoint=resolved_endpoint,
credential=credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
@@ -191,7 +191,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
ServiceInitializationError: If required parameters are missing.
"""
# Resolve model from parameter or environment variable
- resolved_model = model or self._settings.model_deployment_name
+ resolved_model = model or self._settings.get("model_deployment_name")
if not resolved_model:
raise ServiceInitializationError(
"Model deployment name is required. Provide 'model' parameter "
diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py
index 585fc9a9f5..81c113a1e4 100644
--- a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py
+++ b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py
@@ -2,14 +2,14 @@
from __future__ import annotations
+import sys
from collections.abc import Mapping, MutableMapping, Sequence
-from typing import Any, ClassVar, cast
+from typing import Any, cast
from agent_framework import (
FunctionTool,
get_logger,
)
-from agent_framework._pydantic import AFBaseSettings
from agent_framework.exceptions import ServiceInvalidRequestError
from azure.ai.agents.models import (
CodeInterpreterToolDefinition,
@@ -32,10 +32,15 @@ from azure.ai.projects.models import (
)
from pydantic import BaseModel
+if sys.version_info >= (3, 11):
+ from typing import TypedDict # pragma: no cover
+else:
+ from typing_extensions import TypedDict # type: ignore # pragma: no cover
+
logger = get_logger("agent_framework.azure")
-class AzureAISettings(AFBaseSettings):
+class AzureAISettings(TypedDict, total=False):
"""Azure AI Project settings.
The settings are first loaded from environment variables with the prefix 'AZURE_AI_'.
@@ -70,10 +75,8 @@ class AzureAISettings(AFBaseSettings):
settings = AzureAISettings(env_file_path="path/to/.env")
"""
- env_prefix: ClassVar[str] = "AZURE_AI_"
-
- project_endpoint: str | None = None
- model_deployment_name: str | None = None
+ project_endpoint: str | None
+ model_deployment_name: str | None
def _extract_project_connection_id(additional_properties: dict[str, Any] | None) -> str | None:
diff --git a/python/packages/azure-ai/tests/test_agent_provider.py b/python/packages/azure-ai/tests/test_agent_provider.py
index 30ef6fcf1c..b8755ed7d7 100644
--- a/python/packages/azure-ai/tests/test_agent_provider.py
+++ b/python/packages/azure-ai/tests/test_agent_provider.py
@@ -86,12 +86,9 @@ def test_provider_init_missing_endpoint_raises(
mock_azure_credential: MagicMock,
) -> None:
"""Test AzureAIAgentsProvider raises error when endpoint is missing."""
- # Mock AzureAISettings to return None for project_endpoint
- with patch("agent_framework_azure_ai._agent_provider.AzureAISettings") as mock_settings_class:
- mock_settings = MagicMock()
- mock_settings.project_endpoint = None
- mock_settings.model_deployment_name = "test-model"
- mock_settings_class.return_value = mock_settings
+ # Mock load_settings to return a dict with None for project_endpoint
+ with patch("agent_framework_azure_ai._agent_provider.load_settings") as mock_load_settings:
+ mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"}
with pytest.raises(ServiceInitializationError) as exc_info:
AzureAIAgentsProvider(credential=mock_azure_credential)
@@ -270,11 +267,8 @@ async def test_create_agent_missing_model_raises(
) -> None:
"""Test that create_agent raises error when model is not specified."""
# Create provider with mocked settings that has no model
- with patch("agent_framework_azure_ai._agent_provider.AzureAISettings") as mock_settings_class:
- mock_settings = MagicMock()
- mock_settings.project_endpoint = "https://test.com"
- mock_settings.model_deployment_name = None # No model configured
- mock_settings_class.return_value = mock_settings
+ with patch("agent_framework_azure_ai._agent_provider.load_settings") as mock_load_settings:
+ mock_load_settings.return_value = {"project_endpoint": "https://test.com", "model_deployment_name": None}
provider = AzureAIAgentsProvider(agents_client=mock_agents_client)
diff --git a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py
index d0007841f2..b9df6e5042 100644
--- a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py
+++ b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py
@@ -21,6 +21,7 @@ from agent_framework import (
tool,
)
from agent_framework._serialization import SerializationMixin
+from agent_framework._settings import load_settings
from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidRequestError
from azure.ai.agents.models import (
AgentsNamedToolChoice,
@@ -44,7 +45,7 @@ from azure.ai.agents.models import (
)
from azure.core.credentials_async import AsyncTokenCredential
from azure.identity.aio import AzureCliCredential
-from pydantic import BaseModel, Field, ValidationError
+from pydantic import BaseModel, Field
from agent_framework_azure_ai import AzureAIAgentClient, AzureAISettings
@@ -67,7 +68,7 @@ def create_test_azure_ai_chat_client(
) -> AzureAIAgentClient:
"""Helper function to create AzureAIAgentClient instances for testing, bypassing normal validation."""
if azure_ai_settings is None:
- azure_ai_settings = AzureAISettings(env_file_path="test.env")
+ azure_ai_settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_", env_file_path="test.env")
# Create client instance directly
client = object.__new__(AzureAIAgentClient)
@@ -78,7 +79,7 @@ def create_test_azure_ai_chat_client(
client.agent_id = agent_id
client.agent_name = agent_name
client.agent_description = None
- client.model_id = azure_ai_settings.model_deployment_name
+ client.model_id = azure_ai_settings.get("model_deployment_name")
client.thread_id = thread_id
client.should_cleanup_agent = should_cleanup_agent
client._agent_created = False
@@ -104,21 +105,23 @@ def create_test_azure_ai_chat_client(
def test_azure_ai_settings_init(azure_ai_unit_test_env: dict[str, str]) -> None:
"""Test AzureAISettings initialization."""
- settings = AzureAISettings()
+ settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_")
- 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"]
+ 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(
+ settings = load_settings(
+ AzureAISettings,
+ env_prefix="AZURE_AI_",
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"
+ assert settings["project_endpoint"] == "https://custom-endpoint.com/"
+ assert settings["model_deployment_name"] == "custom-model"
def test_azure_ai_chat_client_init_with_client(mock_agents_client: MagicMock) -> None:
@@ -138,33 +141,29 @@ def test_azure_ai_chat_client_init_auto_create_client(
mock_agents_client: MagicMock,
) -> None:
"""Test AzureAIAgentClient initialization with auto-created agents_client."""
- azure_ai_settings = AzureAISettings(**azure_ai_unit_test_env) # type: ignore
+ azure_ai_settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_", **azure_ai_unit_test_env) # type: ignore
# Create client instance directly
- client = object.__new__(AzureAIAgentClient)
- client.agents_client = mock_agents_client
- client.agent_id = None
- client.thread_id = None
- client._should_close_client = False # type: ignore
- client.credential = None
- client.model_id = azure_ai_settings.model_deployment_name
- client.agent_name = None
- client.additional_properties = {}
- client.middleware = None
+ chat_client = object.__new__(AzureAIAgentClient)
+ chat_client.agents_client = mock_agents_client
+ chat_client.agent_id = None
+ chat_client.thread_id = None
+ chat_client._should_close_client = False # type: ignore
+ chat_client.credential = None
+ chat_client.model_id = azure_ai_settings.get("model_deployment_name")
+ chat_client.agent_name = None
+ chat_client.additional_properties = {}
+ chat_client.middleware = None
- assert client.agents_client is mock_agents_client
- assert client.agent_id is None
+ assert chat_client.agents_client is mock_agents_client
+ assert chat_client.agent_id is None
def test_azure_ai_chat_client_init_missing_project_endpoint() -> None:
"""Test AzureAIAgentClient initialization when project_endpoint is missing and no agents_client provided."""
# Mock AzureAISettings to return settings with None project_endpoint
- with patch("agent_framework_azure_ai._chat_client.AzureAISettings") as mock_settings:
- mock_settings_instance = MagicMock()
- mock_settings_instance.project_endpoint = None # This should trigger the error
- mock_settings_instance.model_deployment_name = "test-model"
- mock_settings_instance.agent_name = "test-agent"
- mock_settings.return_value = mock_settings_instance
+ with patch("agent_framework_azure_ai._chat_client.load_settings") as mock_load_settings:
+ mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"}
with pytest.raises(ServiceInitializationError, match="project endpoint is required"):
AzureAIAgentClient(
@@ -179,12 +178,8 @@ def test_azure_ai_chat_client_init_missing_project_endpoint() -> None:
def test_azure_ai_chat_client_init_missing_model_deployment_for_agent_creation() -> None:
"""Test AzureAIAgentClient initialization when model deployment is missing for agent creation."""
# Mock AzureAISettings to return settings with None model_deployment_name
- with patch("agent_framework_azure_ai._chat_client.AzureAISettings") as mock_settings:
- mock_settings_instance = MagicMock()
- mock_settings_instance.project_endpoint = "https://test.com"
- mock_settings_instance.model_deployment_name = None # This should trigger the error
- mock_settings_instance.agent_name = "test-agent"
- mock_settings.return_value = mock_settings_instance
+ with patch("agent_framework_azure_ai._chat_client.load_settings") as mock_load_settings:
+ mock_load_settings.return_value = {"project_endpoint": "https://test.com", "model_deployment_name": None}
with pytest.raises(ServiceInitializationError, match="model deployment name is required"):
AzureAIAgentClient(
@@ -210,20 +205,6 @@ def test_azure_ai_chat_client_init_missing_credential(azure_ai_unit_test_env: di
)
-def test_azure_ai_chat_client_init_validation_error(mock_azure_credential: MagicMock) -> None:
- """Test that ValidationError in AzureAISettings is properly handled."""
- with patch("agent_framework_azure_ai._chat_client.AzureAISettings") as mock_settings:
- # Create a proper ValidationError with empty errors list and model dict
- mock_settings.side_effect = ValidationError.from_exception_data("AzureAISettings", [])
-
- with pytest.raises(ServiceInitializationError, match="Failed to create Azure AI settings."):
- AzureAIAgentClient(
- project_endpoint="https://test.com",
- model_deployment_name="test-model",
- credential=mock_azure_credential,
- )
-
-
def test_azure_ai_chat_client_from_dict() -> None:
"""Test from_settings class method."""
mock_agents_client = MagicMock()
@@ -248,11 +229,15 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_with_temperature_and_
mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str]
) -> None:
"""Test _get_agent_id_or_create with temperature and top_p in run_options."""
- azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"])
+ azure_ai_settings = load_settings(
+ AzureAISettings,
+ env_prefix="AZURE_AI_",
+ model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
+ )
client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings)
run_options = {
- "model": azure_ai_settings.model_deployment_name,
+ "model": azure_ai_settings.get("model_deployment_name"),
"temperature": 0.7,
"top_p": 0.9,
}
@@ -284,13 +269,19 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_create_new(
azure_ai_unit_test_env: dict[str, str],
) -> None:
"""Test _get_agent_id_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_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings)
+ azure_ai_settings = load_settings(
+ AzureAISettings,
+ env_prefix="AZURE_AI_",
+ model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
+ )
+ chat_client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings)
- agent_id = await client._get_agent_id_or_create(run_options={"model": azure_ai_settings.model_deployment_name}) # type: ignore
+ agent_id = await chat_client._get_agent_id_or_create(
+ run_options={"model": azure_ai_settings.get("model_deployment_name")}
+ ) # type: ignore
assert agent_id == "test-agent-id"
- assert client._agent_created
+ assert chat_client._agent_created
async def test_azure_ai_chat_client_thread_management_through_public_api(mock_agents_client: MagicMock) -> None:
@@ -547,14 +538,18 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_with_run_options(
mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str]
) -> None:
"""Test _get_agent_id_or_create with run_options containing tools and instructions."""
- azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"])
+ azure_ai_settings = load_settings(
+ AzureAISettings,
+ env_prefix="AZURE_AI_",
+ model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
+ )
client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings)
run_options = {
"tools": [{"type": "function", "function": {"name": "test_tool"}}],
"instructions": "Test instructions",
"response_format": {"type": "json_object"},
- "model": azure_ai_settings.model_deployment_name,
+ "model": azure_ai_settings.get("model_deployment_name"),
}
agent_id = await client._get_agent_id_or_create(run_options) # type: ignore
@@ -1134,13 +1129,19 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_with_agent_name(
mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str]
) -> None:
"""Test _get_agent_id_or_create uses default name when no agent_name set."""
- azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"])
+ azure_ai_settings = load_settings(
+ AzureAISettings,
+ env_prefix="AZURE_AI_",
+ model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
+ )
client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings)
# Ensure agent_name is None to test the default
client.agent_name = None # type: ignore
- agent_id = await client._get_agent_id_or_create(run_options={"model": azure_ai_settings.model_deployment_name}) # type: ignore
+ agent_id = await client._get_agent_id_or_create(
+ run_options={"model": azure_ai_settings.get("model_deployment_name")}
+ ) # type: ignore
assert agent_id == "test-agent-id"
# Verify create_agent was called with default "UnnamedAgent"
@@ -1153,11 +1154,15 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_with_response_format(
mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str]
) -> None:
"""Test _get_agent_id_or_create with response_format in run_options."""
- azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"])
+ azure_ai_settings = load_settings(
+ AzureAISettings,
+ env_prefix="AZURE_AI_",
+ model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
+ )
client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings)
# Test with response_format in run_options
- run_options = {"response_format": {"type": "json_object"}, "model": azure_ai_settings.model_deployment_name}
+ run_options = {"response_format": {"type": "json_object"}, "model": azure_ai_settings.get("model_deployment_name")}
agent_id = await client._get_agent_id_or_create(run_options) # type: ignore
@@ -1172,13 +1177,17 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_with_tool_resources(
mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str]
) -> None:
"""Test _get_agent_id_or_create with tool_resources in run_options."""
- azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"])
+ azure_ai_settings = load_settings(
+ AzureAISettings,
+ env_prefix="AZURE_AI_",
+ model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
+ )
client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings)
# Test with tool_resources in run_options
run_options = {
"tool_resources": {"vector_store_ids": ["vs-123"]},
- "model": azure_ai_settings.model_deployment_name,
+ "model": azure_ai_settings.get("model_deployment_name"),
}
agent_id = await client._get_agent_id_or_create(run_options) # type: ignore
diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py
index b4e82cbefd..abcb2a5bda 100644
--- a/python/packages/azure-ai/tests/test_azure_ai_client.py
+++ b/python/packages/azure-ai/tests/test_azure_ai_client.py
@@ -20,6 +20,7 @@ from agent_framework import (
SupportsChatGetResponse,
tool,
)
+from agent_framework._settings import load_settings
from agent_framework.exceptions import ServiceInitializationError
from azure.ai.projects.aio import AIProjectClient
from azure.ai.projects.models import (
@@ -36,7 +37,7 @@ from azure.core.exceptions import ResourceNotFoundError
from azure.identity.aio import AzureCliCredential
from openai.types.responses.parsed_response import ParsedResponse
from openai.types.responses.response import Response as OpenAIResponse
-from pydantic import BaseModel, ConfigDict, Field, ValidationError
+from pydantic import BaseModel, ConfigDict, Field
from pytest import fixture, param
from agent_framework_azure_ai import AzureAIClient, AzureAISettings
@@ -113,7 +114,7 @@ def create_test_azure_ai_client(
) -> 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")
+ azure_ai_settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_", env_file_path="test.env")
# Create client instance directly
client = object.__new__(AzureAIClient)
@@ -125,7 +126,7 @@ def create_test_azure_ai_client(
client.agent_version = agent_version
client.agent_description = None
client.use_latest_version = use_latest_version
- client.model_id = azure_ai_settings.model_deployment_name
+ client.model_id = azure_ai_settings.get("model_deployment_name")
client.conversation_id = conversation_id
client._is_application_endpoint = False # type: ignore
client._should_close_client = should_close_client # type: ignore
@@ -143,28 +144,29 @@ def create_test_azure_ai_client(
def test_azure_ai_settings_init(azure_ai_unit_test_env: dict[str, str]) -> None:
"""Test AzureAISettings initialization."""
- settings = AzureAISettings()
+ settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_")
- 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"]
+ 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(
+ settings = load_settings(
+ AzureAISettings,
+ env_prefix="AZURE_AI_",
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"
+ assert settings["project_endpoint"] == "https://custom-endpoint.com/"
+ assert settings["model_deployment_name"] == "custom-model"
def test_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"
+ with patch("agent_framework_azure_ai._client.load_settings") as mock_load_settings:
+ mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"}
client = AzureAIClient(
project_client=mock_project_client,
@@ -205,9 +207,8 @@ def test_init_auto_create_client(
def test_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 patch("agent_framework_azure_ai._client.load_settings") as mock_load_settings:
+ mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"}
with pytest.raises(ServiceInitializationError, match="Azure AI project endpoint is required"):
AzureAIClient(credential=MagicMock())
@@ -224,15 +225,6 @@ def test_init_missing_credential(azure_ai_unit_test_env: dict[str, str]) -> None
)
-def test_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(credential=mock_azure_credential)
-
-
async def test_get_agent_reference_or_create_existing_version(
mock_project_client: MagicMock,
) -> None:
@@ -259,7 +251,11 @@ async def test_get_agent_reference_or_create_new_agent(
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"])
+ azure_ai_settings = load_settings(
+ AzureAISettings,
+ env_prefix="AZURE_AI_",
+ 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
)
@@ -270,7 +266,7 @@ async def test_get_agent_reference_or_create_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}
+ run_options = {"model": azure_ai_settings.get("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"}
diff --git a/python/packages/azure-ai/tests/test_provider.py b/python/packages/azure-ai/tests/test_provider.py
index 8d6cb1a29a..1b673d2ded 100644
--- a/python/packages/azure-ai/tests/test_provider.py
+++ b/python/packages/azure-ai/tests/test_provider.py
@@ -107,9 +107,8 @@ def test_provider_init_with_credential_and_endpoint(
def test_provider_init_missing_endpoint() -> None:
"""Test AzureAIProjectAgentProvider initialization when endpoint is missing."""
- with patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings:
- mock_settings.return_value.project_endpoint = None
- mock_settings.return_value.model_deployment_name = "test-model"
+ with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings:
+ mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"}
with pytest.raises(ServiceInitializationError, match="Azure AI project endpoint is required"):
AzureAIProjectAgentProvider(credential=MagicMock())
@@ -130,9 +129,11 @@ async def test_provider_create_agent(
azure_ai_unit_test_env: dict[str, str],
) -> None:
"""Test AzureAIProjectAgentProvider.create_agent method."""
- with patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings:
- mock_settings.return_value.project_endpoint = azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"]
- mock_settings.return_value.model_deployment_name = azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]
+ with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings:
+ mock_load_settings.return_value = {
+ "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
+ "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
+ }
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
@@ -168,9 +169,11 @@ async def test_provider_create_agent_with_env_model(
azure_ai_unit_test_env: dict[str, str],
) -> None:
"""Test AzureAIProjectAgentProvider.create_agent uses model from env var."""
- with patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings:
- mock_settings.return_value.project_endpoint = azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"]
- mock_settings.return_value.model_deployment_name = azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]
+ with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings:
+ mock_load_settings.return_value = {
+ "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
+ "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
+ }
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
@@ -200,9 +203,8 @@ async def test_provider_create_agent_with_env_model(
async def test_provider_create_agent_missing_model(mock_project_client: MagicMock) -> None:
"""Test AzureAIProjectAgentProvider.create_agent raises when model is missing."""
- with patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings:
- mock_settings.return_value.project_endpoint = "https://test.com"
- mock_settings.return_value.model_deployment_name = None
+ with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings:
+ mock_load_settings.return_value = {"project_endpoint": "https://test.com", "model_deployment_name": None}
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
@@ -215,9 +217,11 @@ async def test_provider_create_agent_with_rai_config(
azure_ai_unit_test_env: dict[str, str],
) -> None:
"""Test AzureAIProjectAgentProvider.create_agent passes rai_config from default_options."""
- with patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings:
- mock_settings.return_value.project_endpoint = azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"]
- mock_settings.return_value.model_deployment_name = azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]
+ with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings:
+ mock_load_settings.return_value = {
+ "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
+ "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
+ }
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
@@ -258,9 +262,11 @@ async def test_provider_create_agent_with_reasoning(
azure_ai_unit_test_env: dict[str, str],
) -> None:
"""Test AzureAIProjectAgentProvider.create_agent passes reasoning from default_options."""
- with patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings:
- mock_settings.return_value.project_endpoint = azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"]
- mock_settings.return_value.model_deployment_name = azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]
+ with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings:
+ mock_load_settings.return_value = {
+ "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
+ "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
+ }
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
@@ -465,9 +471,11 @@ async def test_provider_context_manager(mock_project_client: MagicMock) -> None:
mock_client.close = AsyncMock()
mock_ai_project_client.return_value = mock_client
- with patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings:
- mock_settings.return_value.project_endpoint = "https://test.com"
- mock_settings.return_value.model_deployment_name = "test-model"
+ with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings:
+ mock_load_settings.return_value = {
+ "project_endpoint": "https://test.com",
+ "model_deployment_name": "test-model",
+ }
async with AzureAIProjectAgentProvider(credential=MagicMock()) as provider:
assert provider._project_client is mock_client # type: ignore
@@ -494,9 +502,11 @@ async def test_provider_close_method(mock_project_client: MagicMock) -> None:
mock_client.close = AsyncMock()
mock_ai_project_client.return_value = mock_client
- with patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings:
- mock_settings.return_value.project_endpoint = "https://test.com"
- mock_settings.return_value.model_deployment_name = "test-model"
+ with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings:
+ mock_load_settings.return_value = {
+ "project_endpoint": "https://test.com",
+ "model_deployment_name": "test-model",
+ }
provider = AzureAIProjectAgentProvider(credential=MagicMock())
await provider.close()
@@ -581,12 +591,14 @@ async def test_provider_create_agent_with_mcp_tool(
return [tools]
with (
- patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings,
+ patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings,
patch("agent_framework_azure_ai._project_provider.to_azure_ai_tools") as mock_to_azure_tools,
patch("agent_framework_azure_ai._project_provider.normalize_tools", side_effect=mock_normalize_tools),
):
- mock_settings.return_value.project_endpoint = azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"]
- mock_settings.return_value.model_deployment_name = azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]
+ mock_load_settings.return_value = {
+ "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
+ "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
+ }
mock_to_azure_tools.return_value = [{"type": "function", "name": "mcp_function_1"}]
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
@@ -642,12 +654,14 @@ async def test_provider_create_agent_with_mcp_and_regular_tools(
return [tools]
with (
- patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings,
+ patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings,
patch("agent_framework_azure_ai._project_provider.to_azure_ai_tools") as mock_to_azure_tools,
patch("agent_framework_azure_ai._project_provider.normalize_tools", side_effect=mock_normalize_tools),
):
- mock_settings.return_value.project_endpoint = azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"]
- mock_settings.return_value.model_deployment_name = azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]
+ mock_load_settings.return_value = {
+ "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
+ "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
+ }
mock_to_azure_tools.return_value = []
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
diff --git a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py
index 2b6deaf9bb..ba8573718e 100644
--- a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py
+++ b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py
@@ -30,13 +30,13 @@ from agent_framework import (
prepare_function_call_results,
validate_tool_mode,
)
-from agent_framework._pydantic import AFBaseSettings
+from agent_framework._settings import SecretString, load_settings
from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidResponseError
from agent_framework.observability import ChatTelemetryLayer
from boto3.session import Session as Boto3Session
from botocore.client import BaseClient
from botocore.config import Config as BotoConfig
-from pydantic import BaseModel, SecretStr, ValidationError
+from pydantic import BaseModel
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
@@ -205,16 +205,14 @@ FINISH_REASON_MAP: dict[str, FinishReasonLiteral] = {
}
-class BedrockSettings(AFBaseSettings):
+class BedrockSettings(TypedDict, total=False):
"""Bedrock configuration settings pulled from environment variables or .env files."""
- env_prefix: ClassVar[str] = "BEDROCK_"
-
- region: str = DEFAULT_REGION
- chat_model_id: str | None = None
- access_key: SecretStr | None = None
- secret_key: SecretStr | None = None
- session_token: SecretStr | None = None
+ region: str | None
+ chat_model_id: str | None
+ access_key: SecretString | None
+ secret_key: SecretString | None
+ session_token: SecretString | None
class BedrockChatClient(
@@ -280,24 +278,25 @@ class BedrockChatClient(
client = BedrockChatClient[MyOptions](model_id="")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
- try:
- settings = BedrockSettings(
- region=region,
- chat_model_id=model_id,
- access_key=access_key, # type: ignore[arg-type]
- secret_key=secret_key, # type: ignore[arg-type]
- session_token=session_token, # type: ignore[arg-type]
- env_file_path=env_file_path,
- env_file_encoding=env_file_encoding,
- )
- except ValidationError as ex:
- raise ServiceInitializationError("Failed to initialize Bedrock settings.", ex) from ex
+ settings = load_settings(
+ BedrockSettings,
+ env_prefix="BEDROCK_",
+ region=region,
+ chat_model_id=model_id,
+ access_key=access_key,
+ secret_key=secret_key,
+ session_token=session_token,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ )
+ if not settings.get("region"):
+ settings["region"] = DEFAULT_REGION
if client is None:
session = boto3_session or self._create_session(settings)
client = session.client(
"bedrock-runtime",
- region_name=settings.region,
+ region_name=settings["region"],
config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT),
)
@@ -307,17 +306,17 @@ class BedrockChatClient(
**kwargs,
)
self._bedrock_client = client
- self.model_id = settings.chat_model_id
- self.region = settings.region
+ self.model_id = settings["chat_model_id"]
+ self.region = settings["region"]
@staticmethod
def _create_session(settings: BedrockSettings) -> Boto3Session:
- session_kwargs: dict[str, Any] = {"region_name": settings.region or DEFAULT_REGION}
- if settings.access_key and settings.secret_key:
- session_kwargs["aws_access_key_id"] = settings.access_key.get_secret_value()
- session_kwargs["aws_secret_access_key"] = settings.secret_key.get_secret_value()
- if settings.session_token:
- session_kwargs["aws_session_token"] = settings.session_token.get_secret_value()
+ session_kwargs: dict[str, Any] = {"region_name": settings.get("region") or DEFAULT_REGION}
+ if settings.get("access_key") and settings.get("secret_key"):
+ session_kwargs["aws_access_key_id"] = settings["access_key"].get_secret_value() # type: ignore[union-attr]
+ session_kwargs["aws_secret_access_key"] = settings["secret_key"].get_secret_value() # type: ignore[union-attr]
+ if settings.get("session_token"):
+ session_kwargs["aws_session_token"] = settings["session_token"].get_secret_value() # type: ignore[union-attr]
return Boto3Session(**session_kwargs)
@override
diff --git a/python/packages/bedrock/tests/test_bedrock_settings.py b/python/packages/bedrock/tests/test_bedrock_settings.py
index 6a1956dd7c..8be9ca95e4 100644
--- a/python/packages/bedrock/tests/test_bedrock_settings.py
+++ b/python/packages/bedrock/tests/test_bedrock_settings.py
@@ -11,6 +11,7 @@ from agent_framework import (
FunctionTool,
Message,
)
+from agent_framework._settings import load_settings
from pydantic import BaseModel
from agent_framework_bedrock._chat_client import BedrockChatClient, BedrockSettings
@@ -33,9 +34,9 @@ def _dummy_weather(location: str) -> str: # pragma: no cover - helper
def test_settings_load_from_environment(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BEDROCK_REGION", "us-west-2")
monkeypatch.setenv("BEDROCK_CHAT_MODEL_ID", "anthropic.claude-v2")
- settings = BedrockSettings()
- assert settings.region == "us-west-2"
- assert settings.chat_model_id == "anthropic.claude-v2"
+ settings = load_settings(BedrockSettings, env_prefix="BEDROCK_")
+ assert settings["region"] == "us-west-2"
+ assert settings["chat_model_id"] == "anthropic.claude-v2"
def test_build_request_includes_tool_config() -> None:
diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py
index 72f17f2742..50c5b06d0f 100644
--- a/python/packages/claude/agent_framework_claude/_agent.py
+++ b/python/packages/claude/agent_framework_claude/_agent.py
@@ -21,8 +21,9 @@ from agent_framework import (
get_logger,
normalize_messages,
)
+from agent_framework._settings import load_settings
from agent_framework._types import normalize_tools
-from agent_framework.exceptions import ServiceException, ServiceInitializationError
+from agent_framework.exceptions import ServiceException
from claude_agent_sdk import (
AssistantMessage,
ClaudeSDKClient,
@@ -34,7 +35,6 @@ from claude_agent_sdk import (
ClaudeAgentOptions as SDKOptions,
)
from claude_agent_sdk.types import StreamEvent, TextBlock
-from pydantic import ValidationError
from ._settings import ClaudeAgentSettings
@@ -273,19 +273,18 @@ class ClaudeAgent(BaseAgent, Generic[OptionsT]):
self._mcp_servers: dict[str, Any] = opts.pop("mcp_servers", None) or {}
# Load settings from environment and options
- try:
- self._settings = ClaudeAgentSettings(
- cli_path=cli_path,
- model=model,
- cwd=cwd,
- permission_mode=permission_mode,
- max_turns=max_turns,
- max_budget_usd=max_budget_usd,
- env_file_path=env_file_path,
- env_file_encoding=env_file_encoding,
- )
- except ValidationError as ex:
- raise ServiceInitializationError("Failed to create Claude Agent settings.", ex) from ex
+ self._settings = load_settings(
+ ClaudeAgentSettings,
+ env_prefix="CLAUDE_AGENT_",
+ cli_path=cli_path,
+ model=model,
+ cwd=cwd,
+ permission_mode=permission_mode,
+ max_turns=max_turns,
+ max_budget_usd=max_budget_usd,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ )
# Separate built-in tools (strings) from custom tools (callables/FunctionTool)
self._builtin_tools: list[str] = []
@@ -411,18 +410,18 @@ class ClaudeAgent(BaseAgent, Generic[OptionsT]):
opts["resume"] = resume_session_id
# Apply settings from environment
- if self._settings.cli_path:
- opts["cli_path"] = self._settings.cli_path
- if self._settings.model:
- opts["model"] = self._settings.model
- if self._settings.cwd:
- opts["cwd"] = self._settings.cwd
- if self._settings.permission_mode:
- opts["permission_mode"] = self._settings.permission_mode
- if self._settings.max_turns:
- opts["max_turns"] = self._settings.max_turns
- if self._settings.max_budget_usd:
- opts["max_budget_usd"] = self._settings.max_budget_usd
+ if self._settings["cli_path"]:
+ opts["cli_path"] = self._settings["cli_path"]
+ if self._settings["model"]:
+ opts["model"] = self._settings["model"]
+ if self._settings["cwd"]:
+ opts["cwd"] = self._settings["cwd"]
+ if self._settings["permission_mode"]:
+ opts["permission_mode"] = self._settings["permission_mode"]
+ if self._settings["max_turns"]:
+ opts["max_turns"] = self._settings["max_turns"]
+ if self._settings["max_budget_usd"]:
+ opts["max_budget_usd"] = self._settings["max_budget_usd"]
# Apply default options
for key, value in self._default_options.items():
diff --git a/python/packages/claude/agent_framework_claude/_settings.py b/python/packages/claude/agent_framework_claude/_settings.py
index b01e189cc8..cccc0fe373 100644
--- a/python/packages/claude/agent_framework_claude/_settings.py
+++ b/python/packages/claude/agent_framework_claude/_settings.py
@@ -1,51 +1,29 @@
# Copyright (c) Microsoft. All rights reserved.
-from typing import ClassVar
-
-from agent_framework._pydantic import AFBaseSettings
+from typing import TypedDict
__all__ = ["ClaudeAgentSettings"]
-class ClaudeAgentSettings(AFBaseSettings):
+class ClaudeAgentSettings(TypedDict, total=False):
"""Claude Agent settings.
The settings are first loaded from environment variables with the prefix 'CLAUDE_AGENT_'.
If the environment variables are not found, the settings can be loaded from a .env file
- with the encoding 'utf-8'. If the settings are not found in the .env file, the settings
- are ignored; however, validation will fail alerting that the settings are missing.
+ with the encoding 'utf-8'.
- Keyword Args:
+ Keys:
cli_path: The path to Claude CLI executable.
model: The model to use (sonnet, opus, haiku).
cwd: The working directory for Claude CLI.
permission_mode: Permission mode (default, acceptEdits, plan, bypassPermissions).
max_turns: Maximum number of conversation turns.
max_budget_usd: Maximum budget in USD.
- env_file_path: If provided, the .env settings are read from this file path location.
- env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
-
- Examples:
- .. code-block:: python
-
- from agent_framework.anthropic import ClaudeAgentSettings
-
- # Using environment variables
- # Set CLAUDE_AGENT_MODEL=sonnet
- # CLAUDE_AGENT_PERMISSION_MODE=default
-
- # Or passing parameters directly
- settings = ClaudeAgentSettings(model="sonnet")
-
- # Or loading from a .env file
- settings = ClaudeAgentSettings(env_file_path="path/to/.env")
"""
- env_prefix: ClassVar[str] = "CLAUDE_AGENT_"
-
- cli_path: str | None = None
- model: str | None = None
- cwd: str | None = None
- permission_mode: str | None = None
- max_turns: int | None = None
- max_budget_usd: float | None = None
+ cli_path: str | None
+ model: str | None
+ cwd: str | None
+ permission_mode: str | None
+ max_turns: int | None
+ max_budget_usd: float | None
diff --git a/python/packages/claude/tests/test_claude_agent.py b/python/packages/claude/tests/test_claude_agent.py
index b3d39d82ab..cc28df81c3 100644
--- a/python/packages/claude/tests/test_claude_agent.py
+++ b/python/packages/claude/tests/test_claude_agent.py
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import AgentResponseUpdate, AgentThread, Content, Message, tool
+from agent_framework._settings import load_settings
from agent_framework_claude import ClaudeAgent, ClaudeAgentOptions, ClaudeAgentSettings
from agent_framework_claude._agent import TOOLS_MCP_SERVER_NAME
@@ -15,23 +16,21 @@ from agent_framework_claude._agent import TOOLS_MCP_SERVER_NAME
class TestClaudeAgentSettings:
"""Tests for ClaudeAgentSettings."""
- def test_env_prefix(self) -> None:
- """Test that env_prefix is correctly set."""
- assert ClaudeAgentSettings.env_prefix == "CLAUDE_AGENT_"
-
def test_default_values(self) -> None:
"""Test default values are None."""
- settings = ClaudeAgentSettings()
- assert settings.cli_path is None
- assert settings.model is None
- assert settings.cwd is None
- assert settings.permission_mode is None
- assert settings.max_turns is None
- assert settings.max_budget_usd is None
+ settings = load_settings(ClaudeAgentSettings, env_prefix="CLAUDE_AGENT_")
+ assert settings["cli_path"] is None
+ assert settings["model"] is None
+ assert settings["cwd"] is None
+ assert settings["permission_mode"] is None
+ assert settings["max_turns"] is None
+ assert settings["max_budget_usd"] is None
def test_explicit_values(self) -> None:
"""Test explicit values override defaults."""
- settings = ClaudeAgentSettings(
+ settings = load_settings(
+ ClaudeAgentSettings,
+ env_prefix="CLAUDE_AGENT_",
cli_path="/usr/local/bin/claude",
model="sonnet",
cwd="/home/user/project",
@@ -39,20 +38,20 @@ class TestClaudeAgentSettings:
max_turns=10,
max_budget_usd=5.0,
)
- assert settings.cli_path == "/usr/local/bin/claude"
- assert settings.model == "sonnet"
- assert settings.cwd == "/home/user/project"
- assert settings.permission_mode == "default"
- assert settings.max_turns == 10
- assert settings.max_budget_usd == 5.0
+ assert settings["cli_path"] == "/usr/local/bin/claude"
+ assert settings["model"] == "sonnet"
+ assert settings["cwd"] == "/home/user/project"
+ assert settings["permission_mode"] == "default"
+ assert settings["max_turns"] == 10
+ assert settings["max_budget_usd"] == 5.0
def test_env_variable_loading(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test loading from environment variables."""
monkeypatch.setenv("CLAUDE_AGENT_MODEL", "opus")
monkeypatch.setenv("CLAUDE_AGENT_MAX_TURNS", "20")
- settings = ClaudeAgentSettings()
- assert settings.model == "opus"
- assert settings.max_turns == 20
+ settings = load_settings(ClaudeAgentSettings, env_prefix="CLAUDE_AGENT_")
+ assert settings["model"] == "opus"
+ assert settings["max_turns"] == 20
# region Test ClaudeAgent Initialization
@@ -95,9 +94,9 @@ class TestClaudeAgentInit:
"max_turns": 10,
}
agent = ClaudeAgent(default_options=options)
- assert agent._settings.model == "sonnet" # type: ignore[reportPrivateUsage]
- assert agent._settings.permission_mode == "default" # type: ignore[reportPrivateUsage]
- assert agent._settings.max_turns == 10 # type: ignore[reportPrivateUsage]
+ assert agent._settings["model"] == "sonnet" # type: ignore[reportPrivateUsage]
+ assert agent._settings["permission_mode"] == "default" # type: ignore[reportPrivateUsage]
+ assert agent._settings["max_turns"] == 10 # type: ignore[reportPrivateUsage]
def test_with_function_tool(self) -> None:
"""Test agent with function tool."""
@@ -620,13 +619,13 @@ class TestClaudeAgentPermissions:
def test_default_permission_mode(self) -> None:
"""Test default permission mode."""
agent = ClaudeAgent()
- assert agent._settings.permission_mode is None # type: ignore[reportPrivateUsage]
+ assert agent._settings["permission_mode"] is None # type: ignore[reportPrivateUsage]
def test_permission_mode_from_settings(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test permission mode from environment settings."""
monkeypatch.setenv("CLAUDE_AGENT_PERMISSION_MODE", "acceptEdits")
- settings = ClaudeAgentSettings()
- assert settings.permission_mode == "acceptEdits"
+ settings = load_settings(ClaudeAgentSettings, env_prefix="CLAUDE_AGENT_")
+ assert settings["permission_mode"] == "acceptEdits"
def test_permission_mode_in_options(self) -> None:
"""Test permission mode in options."""
@@ -634,7 +633,7 @@ class TestClaudeAgentPermissions:
"permission_mode": "bypassPermissions",
}
agent = ClaudeAgent(default_options=options)
- assert agent._settings.permission_mode == "bypassPermissions" # type: ignore[reportPrivateUsage]
+ assert agent._settings["permission_mode"] == "bypassPermissions" # type: ignore[reportPrivateUsage]
# region Test ClaudeAgent Error Handling
diff --git a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py
index 7a2567e48f..c0e395b210 100644
--- a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py
+++ b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import AsyncIterable, Awaitable, Sequence
-from typing import Any, ClassVar, Literal, overload
+from typing import Any, Literal, TypedDict, overload
from agent_framework import (
AgentMiddlewareTypes,
@@ -17,23 +17,21 @@ from agent_framework import (
ResponseStream,
normalize_messages,
)
-from agent_framework._pydantic import AFBaseSettings
+from agent_framework._settings import load_settings
from agent_framework.exceptions import ServiceException, ServiceInitializationError
from microsoft_agents.copilotstudio.client import AgentType, ConnectionSettings, CopilotClient, PowerPlatformCloud
-from pydantic import ValidationError
from ._acquire_token import acquire_token
-class CopilotStudioSettings(AFBaseSettings):
+class CopilotStudioSettings(TypedDict, total=False):
"""Copilot Studio model settings.
The settings are first loaded from environment variables with the prefix 'COPILOTSTUDIOAGENT__'.
If the environment variables are not found, the settings can be loaded from a .env file
- with the encoding 'utf-8'. If the settings are not found in the .env file, the settings
- are ignored; however, validation will fail alerting that the settings are missing.
+ with the encoding 'utf-8'.
- Keyword Args:
+ Keys:
environmentid: Environment ID of environment with the Copilot Studio App.
Can be set via environment variable COPILOTSTUDIOAGENT__ENVIRONMENTID.
schemaname: The agent identifier or schema name of the Copilot to use.
@@ -42,32 +40,12 @@ class CopilotStudioSettings(AFBaseSettings):
Can be set via environment variable COPILOTSTUDIOAGENT__AGENTAPPID.
tenantid: The tenant ID of the App Registration used to login.
Can be set via environment variable COPILOTSTUDIOAGENT__TENANTID.
- env_file_path: If provided, the .env settings are read from this file path location.
- env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
-
- Examples:
- .. code-block:: python
-
- from agent_framework_copilotstudio import CopilotStudioSettings
-
- # Using environment variables
- # Set COPILOTSTUDIOAGENT__ENVIRONMENTID=env-123
- # Set COPILOTSTUDIOAGENT__SCHEMANAME=my-agent
- settings = CopilotStudioSettings()
-
- # Or passing parameters directly
- settings = CopilotStudioSettings(environmentid="env-123", schemaname="my-agent")
-
- # Or loading from a .env file
- settings = CopilotStudioSettings(env_file_path="path/to/.env")
"""
- env_prefix: ClassVar[str] = "COPILOTSTUDIOAGENT__"
-
- environmentid: str | None = None
- schemaname: str | None = None
- agentappid: str | None = None
- tenantid: str | None = None
+ environmentid: str | None
+ schemaname: str | None
+ agentappid: str | None
+ tenantid: str | None
class CopilotStudioAgent(BaseAgent):
@@ -144,54 +122,53 @@ class CopilotStudioAgent(BaseAgent):
middleware=middleware,
)
if not client:
- try:
- copilot_studio_settings = CopilotStudioSettings(
- environmentid=environment_id,
- schemaname=agent_identifier,
- agentappid=client_id,
- tenantid=tenant_id,
- env_file_path=env_file_path,
- env_file_encoding=env_file_encoding,
- )
- except ValidationError as ex:
- raise ServiceInitializationError("Failed to create Copilot Studio settings.", ex) from ex
+ copilot_studio_settings = load_settings(
+ CopilotStudioSettings,
+ env_prefix="COPILOTSTUDIOAGENT__",
+ environmentid=environment_id,
+ schemaname=agent_identifier,
+ agentappid=client_id,
+ tenantid=tenant_id,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ )
if not settings:
- if not copilot_studio_settings.environmentid:
+ if not copilot_studio_settings["environmentid"]:
raise ServiceInitializationError(
"Copilot Studio environment ID is required. Set via 'environment_id' parameter "
"or 'COPILOTSTUDIOAGENT__ENVIRONMENTID' environment variable."
)
- if not copilot_studio_settings.schemaname:
+ if not copilot_studio_settings["schemaname"]:
raise ServiceInitializationError(
"Copilot Studio agent identifier/schema name is required. Set via 'agent_identifier' parameter "
"or 'COPILOTSTUDIOAGENT__SCHEMANAME' environment variable."
)
settings = ConnectionSettings(
- environment_id=copilot_studio_settings.environmentid,
- agent_identifier=copilot_studio_settings.schemaname,
+ environment_id=copilot_studio_settings["environmentid"],
+ agent_identifier=copilot_studio_settings["schemaname"],
cloud=cloud,
copilot_agent_type=agent_type,
custom_power_platform_cloud=custom_power_platform_cloud,
)
if not token:
- if not copilot_studio_settings.agentappid:
+ if not copilot_studio_settings["agentappid"]:
raise ServiceInitializationError(
"Copilot Studio client ID is required. Set via 'client_id' parameter "
"or 'COPILOTSTUDIOAGENT__AGENTAPPID' environment variable."
)
- if not copilot_studio_settings.tenantid:
+ if not copilot_studio_settings["tenantid"]:
raise ServiceInitializationError(
"Copilot Studio tenant ID is required. Set via 'tenant_id' parameter "
"or 'COPILOTSTUDIOAGENT__TENANTID' environment variable."
)
token = acquire_token(
- client_id=copilot_studio_settings.agentappid,
- tenant_id=copilot_studio_settings.tenantid,
+ client_id=copilot_studio_settings["agentappid"],
+ tenant_id=copilot_studio_settings["tenantid"],
username=username,
token_cache=token_cache,
scopes=scopes,
diff --git a/python/packages/copilotstudio/tests/test_copilot_agent.py b/python/packages/copilotstudio/tests/test_copilot_agent.py
index 6172f871d3..fb16f151f3 100644
--- a/python/packages/copilotstudio/tests/test_copilot_agent.py
+++ b/python/packages/copilotstudio/tests/test_copilot_agent.py
@@ -38,49 +38,57 @@ class TestCopilotStudioAgent:
return MagicMock(spec=CopilotClient)
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
- @patch("agent_framework_copilotstudio._agent.CopilotStudioSettings")
- def test_init_missing_environment_id(self, mock_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
+ @patch("agent_framework_copilotstudio._agent.load_settings")
+ def test_init_missing_environment_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
mock_acquire_token.return_value = "fake-token"
- mock_settings.return_value.environmentid = None
- mock_settings.return_value.schemaname = "test-bot"
- mock_settings.return_value.tenantid = "test-tenant"
- mock_settings.return_value.agentappid = "test-client"
+ mock_load_settings.return_value = {
+ "environmentid": None,
+ "schemaname": "test-bot",
+ "tenantid": "test-tenant",
+ "agentappid": "test-client",
+ }
with pytest.raises(ServiceInitializationError, match="environment ID is required"):
CopilotStudioAgent()
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
- @patch("agent_framework_copilotstudio._agent.CopilotStudioSettings")
- def test_init_missing_bot_id(self, mock_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
+ @patch("agent_framework_copilotstudio._agent.load_settings")
+ def test_init_missing_bot_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
mock_acquire_token.return_value = "fake-token"
- mock_settings.return_value.environmentid = "test-env"
- mock_settings.return_value.schemaname = None
- mock_settings.return_value.tenantid = "test-tenant"
- mock_settings.return_value.agentappid = "test-client"
+ mock_load_settings.return_value = {
+ "environmentid": "test-env",
+ "schemaname": None,
+ "tenantid": "test-tenant",
+ "agentappid": "test-client",
+ }
with pytest.raises(ServiceInitializationError, match="agent identifier"):
CopilotStudioAgent()
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
- @patch("agent_framework_copilotstudio._agent.CopilotStudioSettings")
- def test_init_missing_tenant_id(self, mock_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
+ @patch("agent_framework_copilotstudio._agent.load_settings")
+ def test_init_missing_tenant_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
mock_acquire_token.return_value = "fake-token"
- mock_settings.return_value.environmentid = "test-env"
- mock_settings.return_value.schemaname = "test-bot"
- mock_settings.return_value.tenantid = None
- mock_settings.return_value.agentappid = "test-client"
+ mock_load_settings.return_value = {
+ "environmentid": "test-env",
+ "schemaname": "test-bot",
+ "tenantid": None,
+ "agentappid": "test-client",
+ }
with pytest.raises(ServiceInitializationError, match="tenant ID is required"):
CopilotStudioAgent()
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
- @patch("agent_framework_copilotstudio._agent.CopilotStudioSettings")
- def test_init_missing_client_id(self, mock_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
+ @patch("agent_framework_copilotstudio._agent.load_settings")
+ def test_init_missing_client_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
mock_acquire_token.return_value = "fake-token"
- mock_settings.return_value.environmentid = "test-env"
- mock_settings.return_value.schemaname = "test-bot"
- mock_settings.return_value.tenantid = "test-tenant"
- mock_settings.return_value.agentappid = None
+ mock_load_settings.return_value = {
+ "environmentid": "test-env",
+ "schemaname": "test-bot",
+ "tenantid": "test-tenant",
+ "agentappid": None,
+ }
with pytest.raises(ServiceInitializationError, match="client ID is required"):
CopilotStudioAgent()
@@ -93,11 +101,13 @@ class TestCopilotStudioAgent:
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
def test_init_empty_environment_id(self, mock_acquire_token: MagicMock) -> None:
mock_acquire_token.return_value = "fake-token"
- with patch("agent_framework_copilotstudio._agent.CopilotStudioSettings") as mock_settings:
- mock_settings.return_value.environmentid = ""
- mock_settings.return_value.schemaname = "test-bot"
- mock_settings.return_value.tenantid = "test-tenant"
- mock_settings.return_value.agentappid = "test-client"
+ with patch("agent_framework_copilotstudio._agent.load_settings") as mock_load_settings:
+ mock_load_settings.return_value = {
+ "environmentid": "",
+ "schemaname": "test-bot",
+ "tenantid": "test-tenant",
+ "agentappid": "test-client",
+ }
with pytest.raises(ServiceInitializationError, match="environment ID is required"):
CopilotStudioAgent()
@@ -105,11 +115,13 @@ class TestCopilotStudioAgent:
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
def test_init_empty_schema_name(self, mock_acquire_token: MagicMock) -> None:
mock_acquire_token.return_value = "fake-token"
- with patch("agent_framework_copilotstudio._agent.CopilotStudioSettings") as mock_settings:
- mock_settings.return_value.environmentid = "test-env"
- mock_settings.return_value.schemaname = ""
- mock_settings.return_value.tenantid = "test-tenant"
- mock_settings.return_value.agentappid = "test-client"
+ with patch("agent_framework_copilotstudio._agent.load_settings") as mock_load_settings:
+ mock_load_settings.return_value = {
+ "environmentid": "test-env",
+ "schemaname": "",
+ "tenantid": "test-tenant",
+ "agentappid": "test-client",
+ }
with pytest.raises(ServiceInitializationError, match="agent identifier"):
CopilotStudioAgent()
diff --git a/python/packages/core/agent_framework/_pydantic.py b/python/packages/core/agent_framework/_pydantic.py
deleted file mode 100644
index b8652745b3..0000000000
--- a/python/packages/core/agent_framework/_pydantic.py
+++ /dev/null
@@ -1,70 +0,0 @@
-# Copyright (c) Microsoft. All rights reserved.
-
-
-from __future__ import annotations
-
-from typing import Annotated, Any, ClassVar, TypeVar
-
-from pydantic import Field, UrlConstraints
-from pydantic.networks import AnyUrl
-from pydantic_settings import BaseSettings, SettingsConfigDict
-
-HTTPsUrl = Annotated[AnyUrl, UrlConstraints(max_length=2083, allowed_schemes=["https"])]
-
-__all__ = ["AFBaseSettings", "HTTPsUrl"]
-
-
-SettingsT = TypeVar("SettingsT", bound="AFBaseSettings")
-
-
-class AFBaseSettings(BaseSettings):
- """Base class for all settings classes in the Agent Framework.
-
- A subclass creates it's fields and overrides the env_prefix class variable
- with the prefix for the environment variables.
-
- In the case where a value is specified for the same Settings field in multiple ways,
- the selected value is determined as follows (in descending order of priority):
- - Arguments passed to the Settings class initializer.
- - Environment variables, e.g. my_prefix_special_function as described above.
- - Variables loaded from a dotenv (.env) file.
- - Variables loaded from the secrets directory.
- - The default field values for the Settings model.
- """
-
- env_prefix: ClassVar[str] = ""
- env_file_path: str | None = Field(default=None, exclude=True)
- env_file_encoding: str | None = Field(default="utf-8", exclude=True)
-
- model_config = SettingsConfigDict(
- extra="ignore",
- case_sensitive=False,
- )
-
- def __init__(
- self,
- **kwargs: Any,
- ) -> None:
- """Initialize the settings class."""
- # Remove any None values from the kwargs so that defaults are used.
- kwargs = {k: v for k, v in kwargs.items() if v is not None}
- super().__init__(**kwargs)
-
- def __new__(cls: type[SettingsT], *args: Any, **kwargs: Any) -> SettingsT:
- """Override the __new__ method to set the env_prefix."""
- # for both, if supplied but None, set to default
- if "env_file_encoding" in kwargs and kwargs["env_file_encoding"] is not None:
- env_file_encoding = kwargs["env_file_encoding"]
- else:
- env_file_encoding = "utf-8"
- if "env_file_path" in kwargs and kwargs["env_file_path"] is not None:
- env_file_path = kwargs["env_file_path"]
- else:
- env_file_path = ".env"
- cls.model_config.update( # type: ignore
- env_prefix=cls.env_prefix,
- env_file=env_file_path,
- env_file_encoding=env_file_encoding,
- )
- cls.model_rebuild()
- return super().__new__(cls) # type: ignore[return-value]
diff --git a/python/packages/core/agent_framework/_settings.py b/python/packages/core/agent_framework/_settings.py
new file mode 100644
index 0000000000..d378688d55
--- /dev/null
+++ b/python/packages/core/agent_framework/_settings.py
@@ -0,0 +1,262 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Generic settings loader with environment variable resolution.
+
+This module provides a ``load_settings()`` function that populates a ``TypedDict``
+from environment variables, ``.env`` files, and explicit overrides. It replaces
+the previous pydantic-settings-based ``AFBaseSettings`` with a lighter-weight,
+function-based approach that has no pydantic-settings dependency.
+
+Usage::
+
+ class MySettings(TypedDict, total=False):
+ api_key: str | None # optional — resolves to None if not set
+ model_id: str | None # optional by default
+
+
+ # Make model_id required at call time:
+ settings = load_settings(
+ MySettings,
+ env_prefix="MY_APP_",
+ required_fields=["model_id"],
+ model_id="gpt-4",
+ )
+ settings["api_key"] # type-checked dict access
+ settings["model_id"] # str | None per type, but guaranteed not None at runtime
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from collections.abc import Callable, Sequence
+from contextlib import suppress
+from typing import Any, Union, get_args, get_origin, get_type_hints
+
+from dotenv import load_dotenv
+
+from .exceptions import SettingNotFoundError
+
+if sys.version_info >= (3, 13):
+ from typing import TypeVar # type: ignore # pragma: no cover
+else:
+ from typing_extensions import TypeVar # type: ignore # pragma: no cover
+
+__all__ = ["SecretString", "load_settings"]
+
+SettingsT = TypeVar("SettingsT", default=dict[str, Any])
+
+
+class SecretString(str):
+ """A string subclass that masks its value in repr() to prevent accidental exposure.
+
+ SecretString behaves exactly like a regular string in all operations,
+ but its repr() shows '**********' instead of the actual value.
+ This helps prevent secrets from being accidentally logged or displayed.
+
+ It also provides a ``get_secret_value()`` method for backward compatibility
+ with code that previously used ``pydantic.SecretStr``.
+
+ Example:
+ ```python
+ api_key = SecretString("sk-secret-key")
+ print(api_key) # sk-secret-key (normal string behavior)
+ print(repr(api_key)) # SecretString('**********')
+ print(f"Key: {api_key}") # Key: sk-secret-key
+ print(api_key.get_secret_value()) # sk-secret-key
+ ```
+ """
+
+ def __repr__(self) -> str:
+ """Return a masked representation to prevent secret exposure."""
+ return "SecretString('**********')"
+
+ def get_secret_value(self) -> str:
+ """Return the underlying string value.
+
+ Provided for backward compatibility with ``pydantic.SecretStr``.
+ Since SecretString *is* a str, this simply returns ``str(self)``.
+ """
+ return str(self)
+
+
+def _coerce_value(value: str, target_type: type) -> Any:
+ """Coerce a string value to the target type."""
+ origin = get_origin(target_type)
+ args = get_args(target_type)
+
+ # Handle Union types (e.g., str | None) — try each non-None arm
+ if origin is type(None):
+ return None
+
+ if args and type(None) in args:
+ for arg in args:
+ if arg is not type(None):
+ with suppress(ValueError, TypeError):
+ return _coerce_value(value, arg)
+ return value
+
+ # Handle SecretString
+ if target_type is SecretString or (isinstance(target_type, type) and issubclass(target_type, SecretString)):
+ return SecretString(value)
+
+ # Handle basic types
+ if target_type is str:
+ return value
+ if target_type is int:
+ return int(value)
+ if target_type is float:
+ return float(value)
+ if target_type is bool:
+ return value.lower() in ("true", "1", "yes", "on")
+
+ return value
+
+
+def _check_override_type(value: Any, field_type: type, field_name: str) -> None:
+ """Validate that *value* is compatible with *field_type*.
+
+ Raises ``ServiceInitializationError`` when the override is clearly
+ incompatible (e.g. a ``dict`` passed where ``str`` is expected).
+ Callable values and ``None`` are always accepted.
+ """
+ if value is None:
+ return
+
+ # Callables are always allowed (e.g. lazy token providers)
+ if callable(value) and not isinstance(value, (str, bytes)):
+ return
+
+ # Collect the concrete types that *field_type* allows
+ origin = get_origin(field_type)
+ args = get_args(field_type)
+
+ allowed: tuple[type, ...]
+ if origin is Union or origin is type(int | str):
+ allowed = tuple(a for a in args if isinstance(a, type) and a is not type(None))
+ # If any arm is a Callable, allow anything callable
+ if any(get_origin(a) is Callable or a is Callable for a in args):
+ return
+ elif isinstance(field_type, type):
+ allowed = (field_type,)
+ else:
+ return # complex / unknown annotation — skip check
+
+ if not allowed:
+ return
+
+ if not isinstance(value, allowed):
+ # Allow str for SecretString fields (will be coerced)
+ if isinstance(value, str) and any(isinstance(a, type) and issubclass(a, str) for a in allowed):
+ return
+ # Allow int for float fields (standard numeric promotion)
+ if isinstance(value, int) and float in allowed:
+ return
+
+ from .exceptions import ServiceInitializationError
+
+ allowed_names = ", ".join(t.__name__ for t in allowed)
+ raise ServiceInitializationError(
+ f"Invalid type for setting '{field_name}': expected {allowed_names}, got {type(value).__name__}."
+ )
+
+
+def load_settings(
+ settings_type: type[SettingsT],
+ *,
+ env_prefix: str = "",
+ env_file_path: str | None = None,
+ env_file_encoding: str | None = None,
+ required_fields: Sequence[str] | None = None,
+ **overrides: Any,
+) -> SettingsT:
+ """Load settings from environment variables, a ``.env`` file, and explicit overrides.
+
+ The *settings_type* must be a ``TypedDict`` subclass. Values are resolved in
+ this order (highest priority first):
+
+ 1. Explicit keyword *overrides* (``None`` values are filtered out).
+ 2. Environment variables (````).
+ 3. A ``.env`` file (loaded via ``python-dotenv``; existing env vars take precedence).
+ 4. Default values — fields with class-level defaults on the TypedDict, or
+ ``None`` for optional fields.
+
+ Fields listed in *required_fields* are validated after resolution. If any
+ required field resolves to ``None``, a ``SettingNotFoundError`` is raised.
+ This allows callers to decide which fields are required based on runtime
+ context (e.g. ``endpoint`` is only required when no pre-built client is
+ provided).
+
+ Args:
+ settings_type: A ``TypedDict`` class describing the settings schema.
+ env_prefix: Prefix for environment variable lookup (e.g. ``"OPENAI_"``).
+ env_file_path: Path to ``.env`` file. Defaults to ``".env"`` when omitted.
+ env_file_encoding: Encoding for reading the ``.env`` file. Defaults to ``"utf-8"``.
+ required_fields: Field names that must resolve to a non-``None`` value.
+ **overrides: Field values. ``None`` values are ignored so that callers can
+ forward optional parameters without masking env-var / default resolution.
+
+ Returns:
+ A populated dict matching *settings_type*.
+
+ Raises:
+ SettingNotFoundError: If a required field could not be resolved from any source.
+ ServiceInitializationError: If an override value has an incompatible type.
+ """
+ encoding = env_file_encoding or "utf-8"
+
+ # Load .env file if it exists (existing env vars take precedence by default)
+ env_path = env_file_path or ".env"
+ if os.path.isfile(env_path):
+ load_dotenv(dotenv_path=env_path, encoding=encoding)
+
+ # Filter out None overrides so defaults / env vars are preserved
+ overrides = {k: v for k, v in overrides.items() if v is not None}
+
+ # Get field type hints from the TypedDict
+ hints = get_type_hints(settings_type)
+ required: set[str] = set(required_fields) if required_fields else set()
+
+ result: dict[str, Any] = {}
+ for field_name, field_type in hints.items():
+ # 1. Explicit override wins
+ if field_name in overrides:
+ override_value = overrides[field_name]
+ _check_override_type(override_value, field_type, field_name)
+ # Coerce plain str → SecretString if the annotation expects it
+ if isinstance(override_value, str) and not isinstance(override_value, SecretString):
+ with suppress(ValueError, TypeError):
+ coerced = _coerce_value(override_value, field_type)
+ if isinstance(coerced, SecretString):
+ override_value = coerced
+ result[field_name] = override_value
+ continue
+
+ # 2. Environment variable
+ env_var_name = f"{env_prefix}{field_name.upper()}"
+ env_value = os.getenv(env_var_name)
+ if env_value is not None:
+ try:
+ result[field_name] = _coerce_value(env_value, field_type)
+ except (ValueError, TypeError):
+ result[field_name] = env_value
+ continue
+
+ # 3. Default from TypedDict class-level defaults, or None for optional fields
+ if hasattr(settings_type, field_name):
+ result[field_name] = getattr(settings_type, field_name)
+ else:
+ result[field_name] = None
+
+ # Validate required fields after all resolution
+ if required:
+ for field_name in required:
+ if result.get(field_name) is None:
+ env_var_name = f"{env_prefix}{field_name.upper()}"
+ raise SettingNotFoundError(
+ f"Required setting '{field_name}' was not provided. "
+ f"Set it via the '{field_name}' parameter or the "
+ f"'{env_var_name}' environment variable."
+ )
+
+ return result # type: ignore[return-value]
diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py
index e573c51e23..3eb65335c9 100644
--- a/python/packages/core/agent_framework/_workflows/__init__.py
+++ b/python/packages/core/agent_framework/_workflows/__init__.py
@@ -13,7 +13,6 @@ from ._checkpoint import (
InMemoryCheckpointStorage,
WorkflowCheckpoint,
)
-from ._checkpoint_summary import WorkflowCheckpointSummary, get_checkpoint_summary
from ._const import (
DEFAULT_MAX_ITERATIONS,
)
@@ -107,7 +106,6 @@ __all__ = [
"WorkflowBuilder",
"WorkflowCheckpoint",
"WorkflowCheckpointException",
- "WorkflowCheckpointSummary",
"WorkflowContext",
"WorkflowConvergenceException",
"WorkflowErrorDetails",
@@ -124,7 +122,6 @@ __all__ = [
"WorkflowViz",
"create_edge_runner",
"executor",
- "get_checkpoint_summary",
"handler",
"resolve_agent_id",
"response_handler",
diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py
index 962da2bcd4..06b3bbb613 100644
--- a/python/packages/core/agent_framework/_workflows/_agent.py
+++ b/python/packages/core/agent_framework/_workflows/_agent.py
@@ -718,7 +718,7 @@ class WorkflowAgent(BaseAgent):
def _extract_contents(self, data: Any) -> list[Content]:
"""Recursively extract Content from workflow output data."""
if isinstance(data, list):
- return [c for item in data for c in self._extract_contents(item)]
+ return [c for item in data for c in self._extract_contents(item)] # type: ignore
if isinstance(data, Content):
return [data] # type: ignore[redundant-cast]
if isinstance(data, str):
diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py
index 0923e5c93c..8290391fb9 100644
--- a/python/packages/core/agent_framework/_workflows/_agent_executor.py
+++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py
@@ -13,9 +13,7 @@ from .._agents import SupportsAgentRun
from .._threads import AgentThread
from .._types import AgentResponse, AgentResponseUpdate, Message
from ._agent_utils import resolve_agent_id
-from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from ._const import WORKFLOW_RUN_KWARGS_KEY
-from ._conversation_state import encode_chat_messages
from ._executor import Executor, handler
from ._message_utils import normalize_messages_input
from ._request_info_mixin import response_handler
@@ -232,11 +230,11 @@ class AgentExecutor(Executor):
serialized_thread = await self._agent_thread.serialize()
return {
- "cache": encode_chat_messages(self._cache),
- "full_conversation": encode_chat_messages(self._full_conversation),
+ "cache": self._cache,
+ "full_conversation": self._full_conversation,
"agent_thread": serialized_thread,
- "pending_agent_requests": encode_checkpoint_value(self._pending_agent_requests),
- "pending_responses_to_agent": encode_checkpoint_value(self._pending_responses_to_agent),
+ "pending_agent_requests": self._pending_agent_requests,
+ "pending_responses_to_agent": self._pending_responses_to_agent,
}
@override
@@ -246,27 +244,11 @@ class AgentExecutor(Executor):
Args:
state: Checkpoint data dict
"""
- from ._conversation_state import decode_chat_messages
-
cache_payload = state.get("cache")
- if cache_payload:
- try:
- self._cache = decode_chat_messages(cache_payload)
- except Exception as exc:
- logger.warning("Failed to restore cache: %s", exc)
- self._cache = []
- else:
- self._cache = []
+ self._cache = cache_payload or []
full_conversation_payload = state.get("full_conversation")
- if full_conversation_payload:
- try:
- self._full_conversation = decode_chat_messages(full_conversation_payload)
- except Exception as exc:
- logger.warning("Failed to restore full conversation: %s", exc)
- self._full_conversation = []
- else:
- self._full_conversation = []
+ self._full_conversation = full_conversation_payload or []
thread_payload = state.get("agent_thread")
if thread_payload:
@@ -282,11 +264,11 @@ class AgentExecutor(Executor):
pending_requests_payload = state.get("pending_agent_requests")
if pending_requests_payload:
- self._pending_agent_requests = decode_checkpoint_value(pending_requests_payload)
+ self._pending_agent_requests = pending_requests_payload
pending_responses_payload = state.get("pending_responses_to_agent")
if pending_responses_payload:
- self._pending_responses_to_agent = decode_checkpoint_value(pending_responses_payload)
+ self._pending_responses_to_agent = pending_responses_payload
def reset(self) -> None:
"""Reset the internal cache of the executor."""
diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint.py b/python/packages/core/agent_framework/_workflows/_checkpoint.py
index 0334ee3893..4d3f87b89e 100644
--- a/python/packages/core/agent_framework/_workflows/_checkpoint.py
+++ b/python/packages/core/agent_framework/_workflows/_checkpoint.py
@@ -3,18 +3,29 @@
from __future__ import annotations
import asyncio
+import copy
import json
import logging
import os
import uuid
from collections.abc import Mapping
-from dataclasses import asdict, dataclass, field
+from dataclasses import dataclass, field, fields
from datetime import datetime, timezone
from pathlib import Path
-from typing import Any, Protocol
+from typing import TYPE_CHECKING, Any, Protocol, TypeAlias
+
+from ._exceptions import WorkflowCheckpointException
logger = logging.getLogger(__name__)
+if TYPE_CHECKING:
+ from ._events import WorkflowEvent
+ from ._runner_context import WorkflowMessage
+
+# Type alias for checkpoint IDs in case we want to change the
+# underlying type in the future (e.g., to UUID or a custom class)
+CheckpointID: TypeAlias = str
+
@dataclass(slots=True)
class WorkflowCheckpoint:
@@ -23,15 +34,31 @@ class WorkflowCheckpoint:
Checkpoints capture the full execution state of a workflow at a specific point,
enabling workflows to be paused and resumed.
+ Note that a checkpoint is not tied to a specific workflow instance, but rather to
+ a workflow definition (identified by workflow_name and graph_signature_hash). Thus,
+ the ID of the workflow instance that created the checkpoint is not included in the
+ checkpoint data. This allows checkpoints to be shared and restored across different
+ workflow instances of the same workflow definition.
+
Attributes:
+ workflow_name: Name of the workflow this checkpoint belongs to. This acts as a
+ logical grouping for checkpoints and can be used to filter checkpoints by
+ workflow. Workflows with the same name are expected to have compatible graph
+ structures for checkpointing.
+ graph_signature_hash: Hash of the workflow graph topology to validate checkpoint
+ compatibility during restore
checkpoint_id: Unique identifier for this checkpoint
- workflow_id: Identifier of the workflow this checkpoint belongs to
+ previous_checkpoint_id: ID of the previous checkpoint in the chain, if any. This
+ allows chaining checkpoints together to form a history of workflow states.
timestamp: ISO 8601 timestamp when checkpoint was created
messages: Messages exchanged between executors
state: Committed workflow state including user data and executor states.
- This contains only committed state; pending state changes are not
- included in checkpoints. Executor states are stored under the
- reserved key '_executor_state'.
+ This contains only committed state; pending state changes are not
+ included in checkpoints. Executor states are stored under the
+ reserved key '_executor_state'.
+ pending_request_info_events: Any pending request info events that have not
+ yet been processed at the time of checkpointing. This allows the workflow
+ to resume with the correct pending events after a restore.
iteration_count: Current iteration number when checkpoint was created
metadata: Additional metadata (e.g., superstep info, graph signature)
version: Checkpoint format version
@@ -41,14 +68,17 @@ class WorkflowCheckpoint:
See State class documentation for details on reserved keys.
"""
- checkpoint_id: str = field(default_factory=lambda: str(uuid.uuid4()))
- workflow_id: str = ""
+ workflow_name: str
+ graph_signature_hash: str
+
+ checkpoint_id: CheckpointID = field(default_factory=lambda: str(uuid.uuid4()))
+ previous_checkpoint_id: CheckpointID | None = None
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
# Core workflow state
- messages: dict[str, list[dict[str, Any]]] = field(default_factory=dict) # type: ignore[misc]
+ messages: dict[str, list[WorkflowMessage]] = field(default_factory=dict) # type: ignore[misc]
state: dict[str, Any] = field(default_factory=dict) # type: ignore[misc]
- pending_request_info_events: dict[str, dict[str, Any]] = field(default_factory=dict) # type: ignore[misc]
+ pending_request_info_events: dict[str, WorkflowEvent[Any]] = field(default_factory=dict) # type: ignore[misc]
# Runtime state
iteration_count: int = 0
@@ -58,34 +88,104 @@ class WorkflowCheckpoint:
version: str = "1.0"
def to_dict(self) -> dict[str, Any]:
- return asdict(self)
+ """Convert the WorkflowCheckpoint to a dictionary.
+
+ Notes:
+ 1. This method does not recursively convert nested dataclasses to dicts.
+ 2. This is a shallow conversion. The resulting dict will contain the same
+ references to nested objects as the original dataclass.
+ """
+ return {f.name: getattr(self, f.name) for f in fields(self)}
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> WorkflowCheckpoint:
- return cls(**data)
+ """Create a WorkflowCheckpoint from a dictionary.
+
+ Args:
+ data: Dictionary containing checkpoint fields.
+
+ Returns:
+ A new WorkflowCheckpoint instance.
+
+ Raises:
+ WorkflowCheckpointException: If required fields are missing.
+ """
+ try:
+ return cls(**data)
+ except Exception as ex:
+ raise WorkflowCheckpointException(f"Failed to create WorkflowCheckpoint from dict: {ex}") from ex
class CheckpointStorage(Protocol):
"""Protocol for checkpoint storage backends."""
- async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str:
- """Save a checkpoint and return its ID."""
+ async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID:
+ """Save a checkpoint and return its ID.
+
+ Args:
+ checkpoint: The WorkflowCheckpoint object to save.
+
+ Returns:
+ The unique ID of the saved checkpoint.
+ """
...
- async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
- """Load a checkpoint by ID."""
+ async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
+ """Load a checkpoint by ID.
+
+ Args:
+ checkpoint_id: The unique ID of the checkpoint to load.
+
+ Returns:
+ The WorkflowCheckpoint object corresponding to the given ID.
+
+ Raises:
+ WorkflowCheckpointException: If no checkpoint with the given ID exists.
+ """
...
- async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]:
- """List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
+ async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]:
+ """List checkpoint objects for a given workflow name.
+
+ Args:
+ workflow_name: The name of the workflow to list checkpoints for.
+
+ Returns:
+ A list of WorkflowCheckpoint objects for the specified workflow name.
+ """
...
- async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]:
- """List checkpoint objects. If workflow_id is provided, filter by that workflow."""
+ async def delete(self, checkpoint_id: CheckpointID) -> bool:
+ """Delete a checkpoint by ID.
+
+ Args:
+ checkpoint_id: The unique ID of the checkpoint to delete.
+
+ Returns:
+ True if the checkpoint was successfully deleted, False if no checkpoint with the given ID exists.
+ """
...
- async def delete_checkpoint(self, checkpoint_id: str) -> bool:
- """Delete a checkpoint by ID."""
+ async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None:
+ """Get the latest checkpoint for a given workflow name.
+
+ Args:
+ workflow_name: The name of the workflow to get the latest checkpoint for.
+
+ Returns:
+ The latest WorkflowCheckpoint object for the specified workflow name, or None if no checkpoints exist.
+ """
+ ...
+
+ async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]:
+ """List checkpoint IDs for a given workflow name.
+
+ Args:
+ workflow_name: The name of the workflow to list checkpoint IDs for.
+
+ Returns:
+ A list of checkpoint IDs for the specified workflow name.
+ """
...
@@ -94,34 +194,27 @@ class InMemoryCheckpointStorage:
def __init__(self) -> None:
"""Initialize the memory storage."""
- self._checkpoints: dict[str, WorkflowCheckpoint] = {}
+ self._checkpoints: dict[CheckpointID, WorkflowCheckpoint] = {}
- async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str:
+ async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID:
"""Save a checkpoint and return its ID."""
- self._checkpoints[checkpoint.checkpoint_id] = checkpoint
+ self._checkpoints[checkpoint.checkpoint_id] = copy.deepcopy(checkpoint)
logger.debug(f"Saved checkpoint {checkpoint.checkpoint_id} to memory")
return checkpoint.checkpoint_id
- async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
+ async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
"""Load a checkpoint by ID."""
checkpoint = self._checkpoints.get(checkpoint_id)
if checkpoint:
logger.debug(f"Loaded checkpoint {checkpoint_id} from memory")
- return checkpoint
+ return checkpoint
+ raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}")
- async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]:
- """List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
- if workflow_id is None:
- return list(self._checkpoints.keys())
- return [cp.checkpoint_id for cp in self._checkpoints.values() if cp.workflow_id == workflow_id]
+ async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]:
+ """List checkpoint objects for a given workflow name."""
+ return [cp for cp in self._checkpoints.values() if cp.workflow_name == workflow_name]
- async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]:
- """List checkpoint objects. If workflow_id is provided, filter by that workflow."""
- if workflow_id is None:
- return list(self._checkpoints.values())
- return [cp for cp in self._checkpoints.values() if cp.workflow_id == workflow_id]
-
- async def delete_checkpoint(self, checkpoint_id: str) -> bool:
+ async def delete(self, checkpoint_id: CheckpointID) -> bool:
"""Delete a checkpoint by ID."""
if checkpoint_id in self._checkpoints:
del self._checkpoints[checkpoint_id]
@@ -129,9 +222,31 @@ class InMemoryCheckpointStorage:
return True
return False
+ async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None:
+ """Get the latest checkpoint for a given workflow name."""
+ checkpoints = [cp for cp in self._checkpoints.values() if cp.workflow_name == workflow_name]
+ if not checkpoints:
+ return None
+ latest_checkpoint = max(checkpoints, key=lambda cp: datetime.fromisoformat(cp.timestamp))
+ logger.debug(f"Latest checkpoint for workflow {workflow_name} is {latest_checkpoint.checkpoint_id}")
+ return latest_checkpoint
+
+ async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]:
+ """List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
+ return [cp.checkpoint_id for cp in self._checkpoints.values() if cp.workflow_name == workflow_name]
+
class FileCheckpointStorage:
- """File-based checkpoint storage for persistence."""
+ """File-based checkpoint storage for persistence.
+
+ This storage implements a hybrid approach where the checkpoint metadata and structure are
+ stored in JSON format, while the actual state data (which may contain complex Python objects)
+ is serialized using pickle and embedded as base64-encoded strings within the JSON. This allows
+ for human-readable checkpoint files while preserving the ability to store complex Python objects.
+
+ SECURITY WARNING: Checkpoints use pickle for data serialization. Only load checkpoints
+ from trusted sources. Loading a malicious checkpoint file can execute arbitrary code.
+ """
def __init__(self, storage_path: str | Path):
"""Initialize the file storage."""
@@ -139,15 +254,45 @@ class FileCheckpointStorage:
self.storage_path.mkdir(parents=True, exist_ok=True)
logger.info(f"Initialized file checkpoint storage at {self.storage_path}")
- async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str:
- """Save a checkpoint and return its ID."""
- file_path = self.storage_path / f"{checkpoint.checkpoint_id}.json"
- checkpoint_dict = asdict(checkpoint)
+ def _validate_file_path(self, checkpoint_id: CheckpointID) -> Path:
+ """Validate that a checkpoint ID resolves to a path within the storage directory.
+
+ This can prevent someone from crafting a checkpoint ID that points to an arbitrary
+ file on the filesystem.
+
+ Args:
+ checkpoint_id: The checkpoint ID to validate.
+
+ Returns:
+ The validated file path.
+
+ Raises:
+ WorkflowCheckpointException: If the checkpoint ID would resolve outside the storage directory.
+ """
+ file_path = (self.storage_path / f"{checkpoint_id}.json").resolve()
+ if not file_path.is_relative_to(self.storage_path.resolve()):
+ raise WorkflowCheckpointException(f"Invalid checkpoint ID: {checkpoint_id}")
+ return file_path
+
+ async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID:
+ """Save a checkpoint and return its ID.
+
+ Args:
+ checkpoint: The WorkflowCheckpoint object to save.
+
+ Returns:
+ The unique ID of the saved checkpoint.
+ """
+ from ._checkpoint_encoding import encode_checkpoint_value
+
+ file_path = self._validate_file_path(checkpoint.checkpoint_id)
+ checkpoint_dict = checkpoint.to_dict()
+ encoded_checkpoint = encode_checkpoint_value(checkpoint_dict)
def _write_atomic() -> None:
tmp_path = file_path.with_suffix(".json.tmp")
with open(tmp_path, "w") as f:
- json.dump(checkpoint_dict, f, indent=2, ensure_ascii=False)
+ json.dump(encoded_checkpoint, f, indent=2, ensure_ascii=False)
os.replace(tmp_path, file_path)
await asyncio.to_thread(_write_atomic)
@@ -155,60 +300,78 @@ class FileCheckpointStorage:
logger.info(f"Saved checkpoint {checkpoint.checkpoint_id} to {file_path}")
return checkpoint.checkpoint_id
- async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
- """Load a checkpoint by ID."""
- file_path = self.storage_path / f"{checkpoint_id}.json"
+ async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
+ """Load a checkpoint by ID.
+
+ Args:
+ checkpoint_id: The unique ID of the checkpoint to load.
+
+ Returns:
+ The WorkflowCheckpoint object corresponding to the given ID.
+
+ Raises:
+ WorkflowCheckpointException: If no checkpoint with the given ID exists,
+ or if checkpoint decoding fails.
+ """
+ file_path = self._validate_file_path(checkpoint_id)
if not file_path.exists():
- return None
+ raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}")
def _read() -> dict[str, Any]:
with open(file_path) as f:
return json.load(f) # type: ignore[no-any-return]
- checkpoint_dict = await asyncio.to_thread(_read)
+ encoded_checkpoint = await asyncio.to_thread(_read)
- checkpoint = WorkflowCheckpoint(**checkpoint_dict)
+ from ._checkpoint_encoding import CheckpointDecodingError, decode_checkpoint_value
+
+ try:
+ decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint)
+ except CheckpointDecodingError as exc:
+ raise WorkflowCheckpointException(f"Failed to decode checkpoint {checkpoint_id}: {exc}") from exc
+ checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict)
logger.info(f"Loaded checkpoint {checkpoint_id} from {file_path}")
return checkpoint
- async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]:
- """List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
+ async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]:
+ """List checkpoint objects for a given workflow name.
- def _list_ids() -> list[str]:
- checkpoint_ids: list[str] = []
- for file_path in self.storage_path.glob("*.json"):
- try:
- with open(file_path) as f:
- data = json.load(f)
- if workflow_id is None or data.get("workflow_id") == workflow_id:
- checkpoint_ids.append(data.get("checkpoint_id", file_path.stem))
- except Exception as e:
- logger.warning(f"Failed to read checkpoint file {file_path}: {e}")
- return checkpoint_ids
+ Args:
+ workflow_name: The name of the workflow to list checkpoints for.
- return await asyncio.to_thread(_list_ids)
-
- async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]:
- """List checkpoint objects. If workflow_id is provided, filter by that workflow."""
+ Returns:
+ A list of WorkflowCheckpoint objects for the specified workflow name.
+ """
def _list_checkpoints() -> list[WorkflowCheckpoint]:
checkpoints: list[WorkflowCheckpoint] = []
for file_path in self.storage_path.glob("*.json"):
try:
with open(file_path) as f:
- data = json.load(f)
- if workflow_id is None or data.get("workflow_id") == workflow_id:
- checkpoints.append(WorkflowCheckpoint.from_dict(data))
+ encoded_checkpoint = json.load(f)
+ from ._checkpoint_encoding import decode_checkpoint_value
+
+ decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint)
+ checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict)
+ if checkpoint.workflow_name == workflow_name:
+ checkpoints.append(checkpoint)
except Exception as e:
logger.warning(f"Failed to read checkpoint file {file_path}: {e}")
return checkpoints
return await asyncio.to_thread(_list_checkpoints)
- async def delete_checkpoint(self, checkpoint_id: str) -> bool:
- """Delete a checkpoint by ID."""
- file_path = self.storage_path / f"{checkpoint_id}.json"
+ async def delete(self, checkpoint_id: CheckpointID) -> bool:
+ """Delete a checkpoint by ID.
+
+ Args:
+ checkpoint_id: The unique ID of the checkpoint to delete.
+
+ Returns:
+ True if the checkpoint was successfully deleted, False if no checkpoint with the given ID exists.
+ """
+ file_path = self._validate_file_path(checkpoint_id)
def _delete() -> bool:
if file_path.exists():
@@ -218,3 +381,43 @@ class FileCheckpointStorage:
return False
return await asyncio.to_thread(_delete)
+
+ async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None:
+ """Get the latest checkpoint for a given workflow name.
+
+ Args:
+ workflow_name: The name of the workflow to get the latest checkpoint for.
+
+ Returns:
+ The latest WorkflowCheckpoint object for the specified workflow name, or None if no checkpoints exist.
+ """
+ checkpoints = await self.list_checkpoints(workflow_name=workflow_name)
+ if not checkpoints:
+ return None
+ latest_checkpoint = max(checkpoints, key=lambda cp: datetime.fromisoformat(cp.timestamp))
+ logger.debug(f"Latest checkpoint for workflow {workflow_name} is {latest_checkpoint.checkpoint_id}")
+ return latest_checkpoint
+
+ async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]:
+ """List checkpoint IDs for a given workflow name.
+
+ Args:
+ workflow_name: The name of the workflow to list checkpoint IDs for.
+
+ Returns:
+ A list of checkpoint IDs for the specified workflow name.
+ """
+
+ def _list_ids() -> list[CheckpointID]:
+ checkpoint_ids: list[CheckpointID] = []
+ for file_path in self.storage_path.glob("*.json"):
+ try:
+ with open(file_path) as f:
+ data = json.load(f)
+ if data.get("workflow_name") == workflow_name:
+ checkpoint_ids.append(data.get("checkpoint_id", file_path.stem))
+ except Exception as e:
+ logger.warning(f"Failed to read checkpoint file {file_path}: {e}")
+ return checkpoint_ids
+
+ return await asyncio.to_thread(_list_ids)
diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py
index 644744c798..524f291c5e 100644
--- a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py
+++ b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py
@@ -2,269 +2,169 @@
from __future__ import annotations
-import contextlib
-import importlib
-import logging
-import sys
-from dataclasses import fields, is_dataclass
-from typing import Any, cast
+import base64
+import pickle # nosec # noqa: S403
+from typing import Any
-# Checkpoint serialization helpers
-MODEL_MARKER = "__af_model__"
-DATACLASS_MARKER = "__af_dataclass__"
+from agent_framework import get_logger
-# Guards to prevent runaway recursion while encoding arbitrary user data
-_MAX_ENCODE_DEPTH = 100
-_CYCLE_SENTINEL = ""
+"""Checkpoint encoding using JSON structure with pickle+base64 for arbitrary data.
-logger = logging.getLogger(__name__)
+This hybrid approach provides:
+- Human-readable JSON structure for debugging and inspection of primitives and collections
+- Full Python object fidelity via pickle for data values (non-JSON-native types)
+- Base64 encoding to embed binary pickle data in JSON strings
+
+SECURITY WARNING: Checkpoints use pickle for data serialization. Only load checkpoints
+from trusted sources. Loading a malicious checkpoint file can execute arbitrary code.
+"""
+
+
+logger = get_logger(__name__)
+
+# Marker to identify pickled values in serialized JSON
+_PICKLE_MARKER = "__pickled__"
+_TYPE_MARKER = "__type__"
+
+# Types that are natively JSON-serializable and don't need pickling
+_JSON_NATIVE_TYPES = (str, int, float, bool, type(None))
+
+
+class CheckpointDecodingError(Exception):
+ """Raised when checkpoint decoding fails due to type mismatch or corruption."""
def encode_checkpoint_value(value: Any) -> Any:
- """Recursively encode values into JSON-serializable structures.
+ """Encode a Python value for checkpoint storage.
- - Objects exposing to_dict/to_json -> { MODEL_MARKER: "module:Class", value: encoded }
- - dataclass instances -> { DATACLASS_MARKER: "module:Class", value: {field: encoded} }
- - dict -> encode keys as str and values recursively
- - list/tuple/set -> list of encoded items
- - other -> returned as-is if already JSON-serializable
+ JSON-native types (str, int, float, bool, None) pass through unchanged.
+ Collections (dict, list) are recursed with their values encoded.
+ All other types (dataclasses, custom objects, datetime, etc.) are pickled
+ and stored as base64-encoded strings.
- Includes cycle and depth protection to avoid infinite recursion.
+ Args:
+ value: Any Python value to encode.
+
+ Returns:
+ A JSON-serializable representation of the value.
"""
-
- def _enc(v: Any, stack: set[int], depth: int) -> Any:
- # Depth guard
- if depth > _MAX_ENCODE_DEPTH:
- logger.debug(f"Max encode depth reached at depth={depth} for type={type(v)}")
- return ""
-
- # Structured model handling (objects exposing to_dict/to_json)
- if _supports_model_protocol(v):
- cls = cast(type[Any], type(v)) # type: ignore
- try:
- if hasattr(v, "to_dict") and callable(getattr(v, "to_dict", None)):
- raw = v.to_dict() # type: ignore[attr-defined]
- strategy = "to_dict"
- elif hasattr(v, "to_json") and callable(getattr(v, "to_json", None)):
- serialized = v.to_json() # type: ignore[attr-defined]
- if isinstance(serialized, (bytes, bytearray)):
- try:
- serialized = serialized.decode()
- except Exception:
- serialized = serialized.decode(errors="replace")
- raw = serialized
- strategy = "to_json"
- else:
- raise AttributeError("Structured model lacks serialization hooks")
- return {
- MODEL_MARKER: f"{cls.__module__}:{cls.__name__}",
- "strategy": strategy,
- "value": _enc(raw, stack, depth + 1),
- }
- except Exception as exc: # best-effort fallback
- logger.debug(f"Structured model serialization failed for {cls}: {exc}")
- return str(v)
-
- # Dataclasses (instances only)
- if is_dataclass(v) and not isinstance(v, type):
- oid = id(v)
- if oid in stack:
- logger.debug("Cycle detected while encoding dataclass instance")
- return _CYCLE_SENTINEL
- stack.add(oid)
- try:
- # type(v) already narrows sufficiently; cast was redundant
- dc_cls: type[Any] = type(v)
- field_values: dict[str, Any] = {}
- for f in fields(v):
- field_values[f.name] = _enc(getattr(v, f.name), stack, depth + 1)
- return {
- DATACLASS_MARKER: f"{dc_cls.__module__}:{dc_cls.__name__}",
- "value": field_values,
- }
- finally:
- stack.remove(oid)
-
- # Collections
- if isinstance(v, dict):
- v_dict = cast("dict[object, object]", v)
- oid = id(v_dict)
- if oid in stack:
- logger.debug("Cycle detected while encoding dict")
- return _CYCLE_SENTINEL
- stack.add(oid)
- try:
- json_dict: dict[str, Any] = {}
- for k_any, val_any in v_dict.items(): # type: ignore[assignment]
- k_str: str = str(k_any)
- json_dict[k_str] = _enc(val_any, stack, depth + 1)
- return json_dict
- finally:
- stack.remove(oid)
-
- if isinstance(v, (list, tuple, set)):
- iterable_v = cast("list[object] | tuple[object, ...] | set[object]", v)
- oid = id(iterable_v)
- if oid in stack:
- logger.debug("Cycle detected while encoding iterable")
- return _CYCLE_SENTINEL
- stack.add(oid)
- try:
- seq: list[object] = list(iterable_v)
- encoded_list: list[Any] = []
- for item in seq:
- encoded_list.append(_enc(item, stack, depth + 1))
- return encoded_list
- finally:
- stack.remove(oid)
-
- # Primitives (or unknown objects): ensure JSON-serializable
- if isinstance(v, (str, int, float, bool)) or v is None:
- return v
- # Fallback: stringify unknown objects to avoid JSON serialization errors
- try:
- return str(v)
- except Exception:
- return f"<{type(v).__name__}>"
-
- return _enc(value, set(), 0)
+ return _encode(value)
def decode_checkpoint_value(value: Any) -> Any:
- """Recursively decode values previously encoded by encode_checkpoint_value."""
+ """Decode a value from checkpoint storage.
+
+ Reverses the encoding performed by encode_checkpoint_value.
+ Pickled values (identified by _PICKLE_MARKER) are decoded and unpickled.
+
+ WARNING: Only call this with trusted data. Pickle can execute
+ arbitrary code during deserialization. The post-unpickle type verification
+ detects accidental corruption or type mismatches, but cannot prevent
+ arbitrary code execution from malicious pickle payloads.
+
+ Args:
+ value: A JSON-deserialized value from checkpoint storage.
+
+ Returns:
+ The original Python value.
+
+ Raises:
+ CheckpointDecodingError: If the unpickled object's type doesn't match
+ the recorded type, indicating corruption, or if the base64/pickle
+ data is malformed.
+ """
+ return _decode(value)
+
+
+def _encode(value: Any) -> Any:
+ """Recursively encode a value for JSON storage."""
+ # JSON-native types pass through
+ if isinstance(value, _JSON_NATIVE_TYPES):
+ return value
+
+ # Recursively encode dict values (keys become strings)
if isinstance(value, dict):
- value_dict = cast(dict[str, Any], value) # encoded form always uses string keys
- # Structured model marker handling
- if MODEL_MARKER in value_dict and "value" in value_dict:
- type_key: str | None = value_dict.get(MODEL_MARKER) # type: ignore[assignment]
- strategy: str | None = value_dict.get("strategy") # type: ignore[assignment]
- raw_encoded: Any = value_dict.get("value")
- decoded_payload = decode_checkpoint_value(raw_encoded)
- if isinstance(type_key, str):
- try:
- cls = _import_qualified_name(type_key)
- except Exception as exc:
- logger.debug(f"Failed to import structured model {type_key}: {exc}")
- cls = None
+ return {str(k): _encode(v) for k, v in value.items()} # type: ignore
- if cls is not None:
- # Verify the class actually supports the model protocol
- if not _class_supports_model_protocol(cls):
- logger.debug(f"Class {type_key} does not support model protocol; returning raw value")
- return decoded_payload
- if strategy == "to_dict" and hasattr(cls, "from_dict"):
- with contextlib.suppress(Exception):
- return cls.from_dict(decoded_payload)
- if strategy == "to_json" and hasattr(cls, "from_json"):
- if isinstance(decoded_payload, (str, bytes, bytearray)):
- with contextlib.suppress(Exception):
- return cls.from_json(decoded_payload)
- if isinstance(decoded_payload, dict) and hasattr(cls, "from_dict"):
- with contextlib.suppress(Exception):
- return cls.from_dict(decoded_payload)
- return decoded_payload
- # Dataclass marker handling
- if DATACLASS_MARKER in value_dict and "value" in value_dict:
- type_key_dc: str | None = value_dict.get(DATACLASS_MARKER) # type: ignore[assignment]
- raw_dc: Any = value_dict.get("value")
- decoded_raw = decode_checkpoint_value(raw_dc)
- if isinstance(type_key_dc, str):
- try:
- module_name, class_name = type_key_dc.split(":", 1)
- module = sys.modules.get(module_name)
- if module is None:
- module = importlib.import_module(module_name)
- cls_dc: Any = getattr(module, class_name)
- # Verify the class is actually a dataclass type (not an instance)
- if not isinstance(cls_dc, type) or not is_dataclass(cls_dc):
- logger.debug(f"Class {type_key_dc} is not a dataclass type; returning raw value")
- return decoded_raw
- constructed = _instantiate_checkpoint_dataclass(cls_dc, decoded_raw)
- if constructed is not None:
- return constructed
- except Exception as exc:
- logger.debug(f"Failed to decode dataclass {type_key_dc}: {exc}; returning raw value")
- return decoded_raw
-
- # Regular dict: decode recursively
- decoded: dict[str, Any] = {}
- for k_any, v_any in value_dict.items():
- decoded[k_any] = decode_checkpoint_value(v_any)
- return decoded
+ # Recursively encode list items (lists are JSON-native collections)
if isinstance(value, list):
- # After isinstance check, treat value as list[Any] for decoding
- value_list: list[Any] = value # type: ignore[assignment]
- return [decode_checkpoint_value(v_any) for v_any in value_list]
+ return [_encode(item) for item in value] # type: ignore
+
+ # Everything else (tuples, sets, dataclasses, custom objects, etc.): pickle and base64 encode
+ return {
+ _PICKLE_MARKER: _pickle_to_base64(value),
+ _TYPE_MARKER: _type_to_key(type(value)), # type: ignore
+ }
+
+
+def _decode(value: Any) -> Any:
+ """Recursively decode a value from JSON storage."""
+ # JSON-native types pass through
+ if isinstance(value, _JSON_NATIVE_TYPES):
+ return value
+
+ # Handle encoded dicts
+ if isinstance(value, dict):
+ # Pickled value: decode, unpickle, and verify type
+ if _PICKLE_MARKER in value and _TYPE_MARKER in value:
+ obj = _base64_to_unpickle(value[_PICKLE_MARKER]) # type: ignore
+ _verify_type(obj, value.get(_TYPE_MARKER)) # type: ignore
+ return obj
+
+ # Regular dict: decode values recursively
+ return {k: _decode(v) for k, v in value.items()} # type: ignore
+
+ # Handle encoded lists
+ if isinstance(value, list):
+ return [_decode(item) for item in value] # type: ignore
+
return value
-def _class_supports_model_protocol(cls: type[Any]) -> bool:
- """Check if a class type supports the model serialization protocol.
+def _verify_type(obj: Any, expected_type_key: str) -> None:
+ """Verify that an unpickled object matches its recorded type.
- Checks for pairs of serialization/deserialization methods:
- - to_dict/from_dict
- - to_json/from_json
+ This is a post-deserialization integrity check that detects accidental
+ corruption or type mismatches. It does not prevent arbitrary code execution
+ from malicious pickle payloads, since ``pickle.loads()`` has already
+ executed by the time this function is called.
+
+ Args:
+ obj: The unpickled object.
+ expected_type_key: The recorded type key (module:qualname format).
+
+ Raises:
+ CheckpointDecodingError: If the types don't match.
"""
- has_to_dict = hasattr(cls, "to_dict") and callable(getattr(cls, "to_dict", None))
- has_from_dict = hasattr(cls, "from_dict") and callable(getattr(cls, "from_dict", None))
-
- has_to_json = hasattr(cls, "to_json") and callable(getattr(cls, "to_json", None))
- has_from_json = hasattr(cls, "from_json") and callable(getattr(cls, "from_json", None))
-
- return (has_to_dict and has_from_dict) or (has_to_json and has_from_json)
+ actual_type_key = _type_to_key(type(obj)) # type: ignore
+ if actual_type_key != expected_type_key:
+ raise CheckpointDecodingError(
+ f"Type mismatch during checkpoint decoding: "
+ f"expected '{expected_type_key}', got '{actual_type_key}'. "
+ f"The checkpoint may be corrupted or tampered with."
+ )
-def _supports_model_protocol(obj: object) -> bool:
- """Detect objects that expose dictionary serialization hooks."""
+def _pickle_to_base64(value: Any) -> str:
+ """Pickle a value and encode as base64 string."""
+ pickled = pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL)
+ return base64.b64encode(pickled).decode("ascii")
+
+
+def _base64_to_unpickle(encoded: str) -> Any:
+ """Decode base64 string and unpickle.
+
+ Raises:
+ CheckpointDecodingError: If the base64 data is corrupted or the pickle
+ format is incompatible.
+ """
try:
- obj_type: type[Any] = type(obj)
- except Exception:
- return False
-
- return _class_supports_model_protocol(obj_type)
-
-
-def _import_qualified_name(qualname: str) -> type[Any] | None:
- if ":" not in qualname:
- return None
- module_name, class_name = qualname.split(":", 1)
- module = sys.modules.get(module_name)
- if module is None:
- module = importlib.import_module(module_name)
- attr: Any = module
- for part in class_name.split("."):
- attr = getattr(attr, part)
- return attr if isinstance(attr, type) else None
-
-
-def _instantiate_checkpoint_dataclass(cls: type[Any], payload: Any) -> Any | None:
- if not isinstance(cls, type):
- logger.debug(f"Checkpoint decoder received non-type dataclass reference: {cls!r}")
- return None
-
- if isinstance(payload, dict):
- try:
- return cls(**payload) # type: ignore[arg-type]
- except TypeError as exc:
- logger.debug(f"Checkpoint decoder could not call {cls.__name__}(**payload): {exc}")
- except Exception as exc:
- logger.warning(f"Checkpoint decoder encountered unexpected error calling {cls.__name__}(**payload): {exc}")
- try:
- instance = object.__new__(cls)
- except Exception as exc:
- logger.debug(f"Checkpoint decoder could not allocate {cls.__name__} without __init__: {exc}")
- return None
- for key, val in payload.items(): # type: ignore[attr-defined]
- try:
- setattr(instance, key, val) # type: ignore[arg-type]
- except Exception as exc:
- logger.debug(f"Checkpoint decoder could not set attribute {key} on {cls.__name__}: {exc}")
- return instance
-
- try:
- return cls(payload) # type: ignore[call-arg]
- except TypeError as exc:
- logger.debug(f"Checkpoint decoder could not call {cls.__name__}({payload!r}): {exc}")
+ pickled = base64.b64decode(encoded.encode("ascii"))
+ return pickle.loads(pickled) # nosec # noqa: S301
except Exception as exc:
- logger.warning(f"Checkpoint decoder encountered unexpected error calling {cls.__name__}({payload!r}): {exc}")
- return None
+ raise CheckpointDecodingError(f"Failed to decode pickled checkpoint data: {exc}") from exc
+
+
+def _type_to_key(t: type) -> str:
+ """Convert a type to a module:qualname string."""
+ return f"{t.__module__}:{t.__qualname__}"
diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint_summary.py b/python/packages/core/agent_framework/_workflows/_checkpoint_summary.py
deleted file mode 100644
index fe00c1a287..0000000000
--- a/python/packages/core/agent_framework/_workflows/_checkpoint_summary.py
+++ /dev/null
@@ -1,49 +0,0 @@
-# Copyright (c) Microsoft. All rights reserved.
-
-import logging
-from dataclasses import dataclass
-
-from ._checkpoint import WorkflowCheckpoint
-from ._const import EXECUTOR_STATE_KEY
-from ._events import WorkflowEvent
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass
-class WorkflowCheckpointSummary:
- """Human-readable summary of a workflow checkpoint."""
-
- checkpoint_id: str
- timestamp: str
- iteration_count: int
- targets: list[str]
- executor_ids: list[str]
- status: str
- pending_request_info_events: list[WorkflowEvent]
-
-
-def get_checkpoint_summary(checkpoint: WorkflowCheckpoint) -> WorkflowCheckpointSummary:
- targets = sorted(checkpoint.messages.keys())
- executor_ids = sorted(checkpoint.state.get(EXECUTOR_STATE_KEY, {}).keys())
- pending_request_info_events = [
- WorkflowEvent.from_dict(request) for request in checkpoint.pending_request_info_events.values()
- ]
-
- status = "idle"
- if pending_request_info_events:
- status = "awaiting request response"
- elif not checkpoint.messages and "finalise" in executor_ids:
- status = "completed"
- elif checkpoint.messages:
- status = "awaiting next superstep"
-
- return WorkflowCheckpointSummary(
- checkpoint_id=checkpoint.checkpoint_id,
- timestamp=checkpoint.timestamp,
- iteration_count=checkpoint.iteration_count,
- targets=targets,
- executor_ids=executor_ids,
- status=status,
- pending_request_info_events=pending_request_info_events,
- )
diff --git a/python/packages/core/agent_framework/_workflows/_conversation_state.py b/python/packages/core/agent_framework/_workflows/_conversation_state.py
deleted file mode 100644
index 95945998df..0000000000
--- a/python/packages/core/agent_framework/_workflows/_conversation_state.py
+++ /dev/null
@@ -1,75 +0,0 @@
-# Copyright (c) Microsoft. All rights reserved.
-
-from collections.abc import Iterable
-from typing import Any, cast
-
-from agent_framework import Message
-
-from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
-
-"""Utilities for serializing and deserializing chat conversations for persistence.
-
-These helpers convert rich `Message` instances to checkpoint-friendly payloads
-using the same encoding primitives as the workflow runner. This preserves
-`additional_properties` and other metadata without relying on unsafe mechanisms
-such as pickling.
-"""
-
-
-def encode_chat_messages(messages: Iterable[Message]) -> list[dict[str, Any]]:
- """Serialize chat messages into checkpoint-safe payloads."""
- encoded: list[dict[str, Any]] = []
- for message in messages:
- encoded.append({
- "role": encode_checkpoint_value(message.role),
- "contents": [encode_checkpoint_value(content) for content in message.contents],
- "author_name": message.author_name,
- "message_id": message.message_id,
- "additional_properties": {
- key: encode_checkpoint_value(value) for key, value in message.additional_properties.items()
- },
- })
- return encoded
-
-
-def decode_chat_messages(payload: Iterable[dict[str, Any]]) -> list[Message]:
- """Restore chat messages from checkpoint-safe payloads."""
- restored: list[Message] = []
- for item in payload:
- if not isinstance(item, dict):
- continue
-
- role_value = decode_checkpoint_value(item.get("role"))
- if isinstance(role_value, str):
- role = role_value
- elif isinstance(role_value, dict) and "value" in role_value:
- # Handle legacy serialization format
- role = role_value["value"]
- else:
- role = "assistant"
-
- contents_field = item.get("contents", [])
- contents: list[Any] = []
- if isinstance(contents_field, list):
- contents_iter: list[Any] = contents_field # type: ignore[assignment]
- for entry in contents_iter:
- decoded_entry: Any = decode_checkpoint_value(entry)
- contents.append(decoded_entry)
-
- additional_field = item.get("additional_properties", {})
- additional: dict[str, Any] = {}
- if isinstance(additional_field, dict):
- additional_dict = cast(dict[str, Any], additional_field)
- for key, value in additional_dict.items():
- additional[key] = decode_checkpoint_value(value)
-
- restored.append(
- Message( # type: ignore[call-overload]
- role=role,
- contents=contents,
- author_name=item.get("author_name"),
- message_id=item.get("message_id"),
- additional_properties=additional,
- )
- )
- return restored
diff --git a/python/packages/core/agent_framework/_workflows/_events.py b/python/packages/core/agent_framework/_workflows/_events.py
index 18e974e3e7..c4694bf31b 100644
--- a/python/packages/core/agent_framework/_workflows/_events.py
+++ b/python/packages/core/agent_framework/_workflows/_events.py
@@ -12,7 +12,6 @@ from dataclasses import dataclass
from enum import Enum
from typing import Any, Generic, Literal, cast
-from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from ._typing_utils import deserialize_type, serialize_type
if sys.version_info >= (3, 13):
@@ -396,7 +395,7 @@ class WorkflowEvent(Generic[DataT]):
raise ValueError(f"to_dict() only supported for 'request_info' events, got '{self.type}'")
return {
"type": self.type,
- "data": encode_checkpoint_value(self.data),
+ "data": self.data,
"request_id": self._request_id,
"source_executor_id": self._source_executor_id,
"request_type": serialize_type(self._request_type) if self._request_type else None,
@@ -410,7 +409,7 @@ class WorkflowEvent(Generic[DataT]):
if prop not in data:
raise KeyError(f"Missing '{prop}' field in WorkflowEvent dictionary.")
- request_data = decode_checkpoint_value(data["data"])
+ request_data = data["data"]
request_type = deserialize_type(data["request_type"])
if request_type is not type(request_data):
diff --git a/python/packages/core/agent_framework/_workflows/_runner.py b/python/packages/core/agent_framework/_workflows/_runner.py
index 83ce5d8085..88281597a2 100644
--- a/python/packages/core/agent_framework/_workflows/_runner.py
+++ b/python/packages/core/agent_framework/_workflows/_runner.py
@@ -7,12 +7,7 @@ from collections import defaultdict
from collections.abc import AsyncGenerator, Sequence
from typing import Any
-from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
-from ._checkpoint_encoding import (
- DATACLASS_MARKER,
- MODEL_MARKER,
- decode_checkpoint_value,
-)
+from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint
from ._const import EXECUTOR_STATE_KEY
from ._edge import EdgeGroup
from ._edge_runner import EdgeRunner, create_edge_runner
@@ -41,8 +36,9 @@ class Runner:
executors: dict[str, Executor],
state: State,
ctx: RunnerContext,
+ workflow_name: str,
+ graph_signature_hash: str,
max_iterations: int = 100,
- workflow_id: str | None = None,
) -> None:
"""Initialize the runner with edges, state, and context.
@@ -51,24 +47,24 @@ class Runner:
executors: Map of executor IDs to executor instances.
state: The state for the workflow.
ctx: The runner context for the workflow.
+ workflow_name: The name of the workflow, used for checkpoint labeling.
+ graph_signature_hash: A hash representing the workflow graph topology for checkpoint validation.
max_iterations: The maximum number of iterations to run.
- workflow_id: The workflow ID for checkpointing.
"""
+ # Workflow instance related attributes
self._executors = executors
self._edge_runners = [create_edge_runner(group, executors) for group in edge_groups]
self._edge_runner_map = self._parse_edge_runners(self._edge_runners)
self._ctx = ctx
+ self._workflow_name = workflow_name
+ self._graph_signature_hash = graph_signature_hash
+
+ # Runner state related attributes
self._iteration = 0
self._max_iterations = max_iterations
self._state = state
- self._workflow_id = workflow_id
self._running = False
self._resumed_from_checkpoint = False # Track whether we resumed
- self.graph_signature_hash: str | None = None
-
- # Set workflow ID in context if provided
- if workflow_id:
- self._ctx.set_workflow_id(workflow_id)
@property
def context(self) -> RunnerContext:
@@ -85,6 +81,7 @@ class Runner:
raise WorkflowRunnerException("Runner is already running.")
self._running = True
+ previous_checkpoint_id: CheckpointID | None = None
try:
# Emit any events already produced prior to entering loop
if await self._ctx.has_events():
@@ -92,13 +89,12 @@ class Runner:
for event in await self._ctx.drain_events():
yield event
- # Create first checkpoint if there are messages from initial execution
- if await self._ctx.has_messages() and self._ctx.has_checkpointing():
- if not self._resumed_from_checkpoint:
- logger.info("Creating checkpoint after initial execution")
- await self._create_checkpoint_if_enabled("after_initial_execution")
- else:
- logger.info("Skipping 'after_initial_execution' checkpoint because we resumed from a checkpoint")
+ # Create the first checkpoint. Checkpoints are usually considered to be created at the end of an iteration,
+ # we can think of the first checkpoint as being created at the end of a "superstep 0" which captures the
+ # states after which the start executor has run. Note that we execute the start executor outside of the
+ # main iteration loop.
+ if await self._ctx.has_messages() and not self._resumed_from_checkpoint:
+ previous_checkpoint_id = await self._create_checkpoint_if_enabled(previous_checkpoint_id)
while self._iteration < self._max_iterations:
logger.info(f"Starting superstep {self._iteration + 1}")
@@ -145,7 +141,7 @@ class Runner:
self._state.commit()
# Create checkpoint after each superstep iteration
- await self._create_checkpoint_if_enabled(f"superstep_{self._iteration}")
+ previous_checkpoint_id = await self._create_checkpoint_if_enabled(previous_checkpoint_id)
yield WorkflowEvent.superstep_completed(iteration=self._iteration)
@@ -169,19 +165,6 @@ class Runner:
"""Inner loop to deliver a single message through an edge runner."""
return await edge_runner.send_message(message, self._state, self._ctx)
- def _normalize_message_payload(message: WorkflowMessage) -> None:
- data = message.data
- if not isinstance(data, dict):
- return
- if MODEL_MARKER not in data and DATACLASS_MARKER not in data:
- return
- try:
- decoded = decode_checkpoint_value(data)
- except Exception as exc: # pragma: no cover - defensive
- logger.debug("Failed to decode checkpoint payload during delivery: %s", exc)
- return
- message.data = decoded
-
# Route all messages through normal workflow edges
associated_edge_runners = self._edge_runner_map.get(source_executor_id, [])
if not associated_edge_runners:
@@ -190,7 +173,6 @@ class Runner:
return
for message in messages:
- _normalize_message_payload(message)
# Deliver a message through all edge runners associated with the source executor concurrently.
tasks = [_deliver_message_inner(edge_runner, message) for edge_runner in associated_edge_runners]
await asyncio.gather(*tasks)
@@ -199,35 +181,33 @@ class Runner:
tasks = [_deliver_messages(source_executor_id, messages) for source_executor_id, messages in messages.items()]
await asyncio.gather(*tasks)
- async def _create_checkpoint_if_enabled(self, checkpoint_type: str) -> str | None:
+ async def _create_checkpoint_if_enabled(self, previous_checkpoint_id: CheckpointID | None) -> CheckpointID | None:
"""Create a checkpoint if checkpointing is enabled and attach a label and metadata."""
if not self._ctx.has_checkpointing():
return None
try:
- # Snapshot executor states
+ # Save executor states into the shared state before creating the checkpoint,
+ # so that they are included in the checkpoint payload.
await self._save_executor_states()
- checkpoint_category = "initial" if checkpoint_type == "after_initial_execution" else "superstep"
- metadata = {
- "superstep": self._iteration,
- "checkpoint_type": checkpoint_category,
- }
- if self.graph_signature_hash:
- metadata["graph_signature"] = self.graph_signature_hash
+
checkpoint_id = await self._ctx.create_checkpoint(
+ self._workflow_name,
+ self._graph_signature_hash,
self._state,
+ previous_checkpoint_id,
self._iteration,
- metadata=metadata,
)
- logger.info(f"Created {checkpoint_type} checkpoint: {checkpoint_id}")
+
+ logger.info(f"Created checkpoint: {checkpoint_id}")
return checkpoint_id
except Exception as e:
- logger.warning(f"Failed to create {checkpoint_type} checkpoint: {e}")
+ logger.warning(f"Failed to create checkpoint: {e}")
return None
async def restore_from_checkpoint(
self,
- checkpoint_id: str,
+ checkpoint_id: CheckpointID,
checkpoint_storage: CheckpointStorage | None = None,
) -> None:
"""Restore workflow state from a checkpoint.
@@ -249,7 +229,7 @@ class Runner:
if self._ctx.has_checkpointing():
checkpoint = await self._ctx.load_checkpoint(checkpoint_id)
elif checkpoint_storage is not None:
- checkpoint = await checkpoint_storage.load_checkpoint(checkpoint_id)
+ checkpoint = await checkpoint_storage.load(checkpoint_id)
else:
raise WorkflowCheckpointException(
"Cannot load checkpoint: no checkpointing configured in context or external storage provided."
@@ -260,22 +240,14 @@ class Runner:
raise WorkflowCheckpointException(f"Checkpoint {checkpoint_id} not found")
# Validate the loaded checkpoint against the workflow
- graph_hash = getattr(self, "graph_signature_hash", None)
- checkpoint_hash = (checkpoint.metadata or {}).get("graph_signature")
- if graph_hash and checkpoint_hash and graph_hash != checkpoint_hash:
+ if self._graph_signature_hash != checkpoint.graph_signature_hash:
raise WorkflowCheckpointException(
"Workflow graph has changed since the checkpoint was created. "
"Please rebuild the original workflow before resuming."
)
- if graph_hash and not checkpoint_hash:
- logger.warning(
- "Checkpoint %s does not include graph signature metadata; skipping topology validation.",
- checkpoint_id,
- )
- self._workflow_id = checkpoint.workflow_id
# Restore state
- self._state.import_state(decode_checkpoint_value(checkpoint.state))
+ self._state.import_state(checkpoint.state)
# Restore executor states using the restored state
await self._restore_executor_states()
# Apply the checkpoint to the context
@@ -291,64 +263,19 @@ class Runner:
raise WorkflowCheckpointException(f"Failed to restore from checkpoint {checkpoint_id}") from e
async def _save_executor_states(self) -> None:
- """Populate executor state by calling checkpoint hooks on executors.
-
- Backward compatibility behavior:
- - If an executor defines an async or sync method `snapshot_state(self) -> dict`, use it.
- - Else if it has a plain attribute `state` that is a dict, use that.
-
- Updated behavior:
- - Executors should implement `on_checkpoint_save(self) -> dict` to provide state.
-
- This method will try the backward compatibility behavior first; if that does not yield state,
- it falls back to the updated behavior.
-
- Only JSON-serializable dicts should be provided by executors.
- """
+ """Populate executor state by calling checkpoint hooks on executors."""
for exec_id, executor in self._executors.items():
- state_dict: dict[str, Any] | None = None
- # Try backward compatibility behavior first
- # TODO(@taochen): Remove backward compatibility
- snapshot = getattr(executor, "snapshot_state", None)
- try:
- if callable(snapshot):
- maybe = snapshot()
- if asyncio.iscoroutine(maybe): # type: ignore[arg-type]
- maybe = await maybe # type: ignore[assignment]
- if isinstance(maybe, dict):
- state_dict = maybe # type: ignore[assignment]
- else:
- state_attr = getattr(executor, "state", None)
- if isinstance(state_attr, dict):
- state_dict = state_attr # type: ignore[assignment]
- except Exception as ex: # pragma: no cover
- logger.debug(f"Executor {exec_id} snapshot_state failed: {ex}")
-
- if state_dict is None:
- # Try the updated behavior only if backward compatibility did not yield state
- try:
- state_dict = await executor.on_checkpoint_save()
- except Exception as ex: # pragma: no cover
- raise WorkflowCheckpointException(f"Executor {exec_id} on_checkpoint_save failed") from ex
-
+ # Try the updated behavior only if backward compatibility did not yield state
try:
+ state_dict = await executor.on_checkpoint_save()
await self._set_executor_state(exec_id, state_dict)
+ except WorkflowCheckpointException:
+ raise
except Exception as ex: # pragma: no cover
- logger.debug(f"Failed to persist state for executor {exec_id}: {ex}")
+ raise WorkflowCheckpointException(f"Executor {exec_id} on_checkpoint_save failed") from ex
async def _restore_executor_states(self) -> None:
- """Restore executor state by calling restore hooks on executors.
-
- Backward compatibility behavior:
- - If an executor defines an async or sync method `restore_state(self, state: dict)`, use it.
- - Else, skip restoration for that executor.
-
- Updated behavior:
- - Executors should implement `on_checkpoint_restore(self, state: dict)` to restore state.
-
- This method will try the backward compatibility behavior first; if that does not restore state,
- it falls back to the updated behavior.
- """
+ """Restore executor state by calling restore hooks on executors."""
has_executor_states = self._state.has(EXECUTOR_STATE_KEY)
if not has_executor_states:
return
@@ -369,29 +296,11 @@ class Runner:
if not executor:
raise WorkflowCheckpointException(f"Executor {executor_id} not found during state restoration.")
- # Try backward compatibility behavior first
- # TODO(@taochen): Remove backward compatibility
- restored = False
- restore_method = getattr(executor, "restore_state", None)
+ # Try the updated behavior only if backward compatibility did not restore
try:
- if callable(restore_method):
- maybe = restore_method(state)
- if asyncio.iscoroutine(maybe): # type: ignore[arg-type]
- await maybe # type: ignore[arg-type]
- restored = True
+ await executor.on_checkpoint_restore(state) # pyright: ignore[reportUnknownArgumentType]
except Exception as ex: # pragma: no cover - defensive
- raise WorkflowCheckpointException(f"Executor {executor_id} restore_state failed") from ex
-
- if not restored:
- # Try the updated behavior only if backward compatibility did not restore
- try:
- await executor.on_checkpoint_restore(state) # pyright: ignore[reportUnknownArgumentType]
- restored = True
- except Exception as ex: # pragma: no cover - defensive
- raise WorkflowCheckpointException(f"Executor {executor_id} on_checkpoint_restore failed") from ex
-
- if not restored:
- logger.debug(f"Executor {executor_id} does not support state restoration; skipping.")
+ raise WorkflowCheckpointException(f"Executor {executor_id} on_checkpoint_restore failed") from ex
def _parse_edge_runners(self, edge_runners: list[EdgeRunner]) -> dict[str, list[EdgeRunner]]:
"""Parse the edge runners of the workflow into a mapping where each source executor ID maps to its edge runners.
diff --git a/python/packages/core/agent_framework/_workflows/_runner_context.py b/python/packages/core/agent_framework/_workflows/_runner_context.py
index db6558306a..d52e135e91 100644
--- a/python/packages/core/agent_framework/_workflows/_runner_context.py
+++ b/python/packages/core/agent_framework/_workflows/_runner_context.py
@@ -4,25 +4,17 @@ from __future__ import annotations
import asyncio
import logging
-import sys
-import uuid
from copy import copy
from dataclasses import dataclass
from enum import Enum
from typing import Any, Protocol, TypeVar, runtime_checkable
-from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
-from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
+from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint
from ._const import INTERNAL_SOURCE_ID
from ._events import WorkflowEvent
from ._state import State
from ._typing_utils import is_instance_of
-if sys.version_info >= (3, 11):
- from typing import TypedDict # type: ignore # pragma: no cover
-else:
- from typing_extensions import TypedDict # type: ignore # pragma: no cover
-
logger = logging.getLogger(__name__)
T = TypeVar("T")
@@ -69,13 +61,13 @@ class WorkflowMessage:
def to_dict(self) -> dict[str, Any]:
"""Convert the WorkflowMessage to a dictionary for serialization."""
return {
- "data": encode_checkpoint_value(self.data),
+ "data": self.data,
"source_id": self.source_id,
"target_id": self.target_id,
"type": self.type.value,
"trace_contexts": self.trace_contexts,
"source_span_ids": self.source_span_ids,
- "original_request_info_event": encode_checkpoint_value(self.original_request_info_event),
+ "original_request_info_event": self.original_request_info_event,
}
@staticmethod
@@ -89,28 +81,16 @@ class WorkflowMessage:
raise KeyError("Missing 'source_id' field in WorkflowMessage dictionary.")
return WorkflowMessage(
- data=decode_checkpoint_value(data["data"]),
+ data=data["data"],
source_id=data["source_id"],
target_id=data.get("target_id"),
type=MessageType(data.get("type", "standard")),
trace_contexts=data.get("trace_contexts"),
source_span_ids=data.get("source_span_ids"),
- original_request_info_event=decode_checkpoint_value(data.get("original_request_info_event")),
+ original_request_info_event=data.get("original_request_info_event"),
)
-class _WorkflowState(TypedDict):
- """TypedDict representing the serializable state of a workflow execution.
-
- This includes all state data needed for checkpointing and restoration.
- """
-
- messages: dict[str, list[dict[str, Any]]]
- state: dict[str, Any]
- iteration_count: int
- pending_request_info_events: dict[str, dict[str, Any]]
-
-
@runtime_checkable
class RunnerContext(Protocol):
"""Protocol for the execution context used by the runner.
@@ -192,11 +172,6 @@ class RunnerContext(Protocol):
"""Clear runtime checkpoint storage override."""
...
- # Checkpointing APIs (optional, enabled by storage)
- def set_workflow_id(self, workflow_id: str) -> None:
- """Set the workflow ID for the context."""
- ...
-
def reset_for_new_run(self) -> None:
"""Reset the context for a new workflow run."""
...
@@ -219,16 +194,23 @@ class RunnerContext(Protocol):
async def create_checkpoint(
self,
+ workflow_name: str,
+ graph_signature_hash: str,
state: State,
+ previous_checkpoint_id: CheckpointID | None,
iteration_count: int,
metadata: dict[str, Any] | None = None,
- ) -> str:
+ ) -> CheckpointID:
"""Create a checkpoint of the current workflow state.
Args:
+ workflow_name: The name of the workflow for which the checkpoint is being created.
+ graph_signature_hash: Hash of the workflow graph topology to
+ validate checkpoint compatibility during restore.
state: The state to include in the checkpoint.
This is needed to capture the full state of the workflow.
The state is not managed by the context itself.
+ previous_checkpoint_id: The ID of the previous checkpoint, if any, to form a checkpoint chain.
iteration_count: The current iteration count of the workflow.
metadata: Optional metadata to associate with the checkpoint.
@@ -237,7 +219,7 @@ class RunnerContext(Protocol):
"""
...
- async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
+ async def load_checkpoint(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint | None:
"""Load a checkpoint without mutating the current context state.
Args:
@@ -301,7 +283,6 @@ class InProcRunnerContext:
# Checkpointing configuration/state
self._checkpoint_storage = checkpoint_storage
self._runtime_checkpoint_storage: CheckpointStorage | None = None
- self._workflow_id: str | None = None
# Streaming flag - set by workflow's run(..., stream=True) vs run(..., stream=False)
self._streaming: bool = False
@@ -376,34 +357,36 @@ class InProcRunnerContext:
async def create_checkpoint(
self,
+ workflow_name: str,
+ graph_signature_hash: str,
state: State,
+ previous_checkpoint_id: CheckpointID | None,
iteration_count: int,
metadata: dict[str, Any] | None = None,
- ) -> str:
+ ) -> CheckpointID:
storage = self._get_effective_checkpoint_storage()
if not storage:
raise ValueError("Checkpoint storage not configured")
- self._workflow_id = self._workflow_id or str(uuid.uuid4())
- workflow_state = self._get_serialized_workflow_state(state, iteration_count)
-
checkpoint = WorkflowCheckpoint(
- workflow_id=self._workflow_id,
- messages=workflow_state["messages"],
- state=workflow_state["state"],
- pending_request_info_events=workflow_state["pending_request_info_events"],
- iteration_count=workflow_state["iteration_count"],
+ workflow_name=workflow_name,
+ graph_signature_hash=graph_signature_hash,
+ previous_checkpoint_id=previous_checkpoint_id,
+ messages=dict(self._messages),
+ state=state.export_state(),
+ pending_request_info_events=dict(self._pending_request_info_events),
+ iteration_count=iteration_count,
metadata=metadata or {},
)
- checkpoint_id = await storage.save_checkpoint(checkpoint)
- logger.info(f"Created checkpoint {checkpoint_id} for workflow {self._workflow_id}")
+ checkpoint_id = await storage.save(checkpoint)
+ logger.debug(f"Created checkpoint {checkpoint_id}")
return checkpoint_id
- async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
+ async def load_checkpoint(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
storage = self._get_effective_checkpoint_storage()
if not storage:
raise ValueError("Checkpoint storage not configured")
- return await storage.load_checkpoint(checkpoint_id)
+ return await storage.load(checkpoint_id)
def reset_for_new_run(self) -> None:
"""Reset the context for a new workflow run.
@@ -422,24 +405,16 @@ class InProcRunnerContext:
self._messages.clear()
messages_data = checkpoint.messages
for source_id, message_list in messages_data.items():
- self._messages[source_id] = [WorkflowMessage.from_dict(msg) for msg in message_list]
+ self._messages[source_id] = list(message_list)
# Restore pending request info events
self._pending_request_info_events.clear()
- pending_requests_data = checkpoint.pending_request_info_events
- for request_id, request_data in pending_requests_data.items():
- request_info_event = WorkflowEvent.from_dict(request_data)
+ for request_id, request_info_event in checkpoint.pending_request_info_events.items():
self._pending_request_info_events[request_id] = request_info_event
await self.add_event(request_info_event)
- # Restore workflow ID
- self._workflow_id = checkpoint.workflow_id
-
# endregion Checkpointing
- def set_workflow_id(self, workflow_id: str) -> None:
- self._workflow_id = workflow_id
-
def set_streaming(self, streaming: bool) -> None:
"""Set whether agents should stream incremental updates.
@@ -456,30 +431,14 @@ class InProcRunnerContext:
"""
return self._streaming
- def _get_serialized_workflow_state(self, state: State, iteration_count: int) -> _WorkflowState:
- serialized_messages: dict[str, list[dict[str, Any]]] = {}
- for source_id, message_list in self._messages.items():
- serialized_messages[source_id] = [msg.to_dict() for msg in message_list]
-
- serialized_pending_request_info_events: dict[str, dict[str, Any]] = {
- request_id: request.to_dict() for request_id, request in self._pending_request_info_events.items()
- }
-
- return {
- "messages": serialized_messages,
- "state": encode_checkpoint_value(state.export_state()),
- "iteration_count": iteration_count,
- "pending_request_info_events": serialized_pending_request_info_events,
- }
-
async def add_request_info_event(self, event: WorkflowEvent[Any]) -> None:
"""Add a request_info event to the context and track it for correlation.
Args:
event: The WorkflowEvent with type='request_info' to be added.
"""
- if event.request_id is None:
- raise ValueError("request_info event must have a request_id")
+ if event.type != "request_info":
+ raise ValueError("Event type must be 'request_info'")
self._pending_request_info_events[event.request_id] = event
await self.add_event(event)
diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py
index 88a92dc703..cd7dbb4a68 100644
--- a/python/packages/core/agent_framework/_workflows/_workflow.py
+++ b/python/packages/core/agent_framework/_workflows/_workflow.py
@@ -175,9 +175,9 @@ class Workflow(DictConvertible):
executors: dict[str, Executor],
start_executor: Executor,
runner_context: RunnerContext,
- max_iterations: int = DEFAULT_MAX_ITERATIONS,
- name: str | None = None,
+ name: str,
description: str | None = None,
+ max_iterations: int = DEFAULT_MAX_ITERATIONS,
output_executors: list[str] | None = None,
**kwargs: Any,
):
@@ -189,8 +189,12 @@ class Workflow(DictConvertible):
start_executor: The starting executor for the workflow.
runner_context: The RunnerContext instance to be used during workflow execution.
max_iterations: The maximum number of iterations the workflow will run for convergence.
- name: Optional human-readable name for the workflow.
- description: Optional description of what the workflow does.
+ name: A human-readable name for the workflow. This can be used to identify the workflow in
+ checkpoints, and telemetry. If the workflow is built using WorkflowBuilder, this will be the
+ name of the builder. This name should be unique across different workflow definitions for
+ better observability and management.
+ description: Optional description of what the workflow does. If the workflow is built using
+ WorkflowBuilder, this will be the description of the builder.
output_executors: Optional list of executor IDs whose outputs will be considered workflow outputs.
If None or empty, all executor outputs are treated as workflow outputs.
kwargs: Additional keyword arguments. Unused in this implementation.
@@ -199,9 +203,15 @@ class Workflow(DictConvertible):
self.executors = dict(executors)
self.start_executor_id = start_executor.id
self.max_iterations = max_iterations
- self.id = str(uuid.uuid4())
self.name = name
self.description = description
+ # Generate a unique ID for the workflow instance for monitoring purposes. This is not intended to be a
+ # stable identifier across instances created from the same builder, for that, use the name field.
+ self.id = str(uuid.uuid4())
+ # Capture a canonical fingerprint of the workflow graph so checkpoints can assert they are resumed with
+ # an equivalent topology.
+ self.graph_signature = self._compute_graph_signature()
+ self.graph_signature_hash = self._hash_graph_signature(self.graph_signature)
# Output events (WorkflowEvent with type='output') from these executors are treated as workflow outputs.
# If None or empty, all executor outputs are considered workflow outputs.
@@ -215,19 +225,14 @@ class Workflow(DictConvertible):
self.executors,
self._state,
runner_context,
+ self.name,
+ self.graph_signature_hash,
max_iterations=max_iterations,
- workflow_id=self.id,
)
# Flag to prevent concurrent workflow executions
self._is_running = False
- # Capture a canonical fingerprint of the workflow graph so checkpoints
- # can assert they are resumed with an equivalent topology.
- self._graph_signature = self._compute_graph_signature()
- self._graph_signature_hash = self._hash_graph_signature(self._graph_signature)
- self._runner.graph_signature_hash = self._graph_signature_hash
-
def _ensure_not_running(self) -> None:
"""Ensure the workflow is not already running."""
if self._is_running:
@@ -241,6 +246,7 @@ class Workflow(DictConvertible):
def to_dict(self) -> dict[str, Any]:
"""Serialize the workflow definition into a JSON-ready dictionary."""
data: dict[str, Any] = {
+ "name": self.name,
"id": self.id,
"start_executor_id": self.start_executor_id,
"max_iterations": self.max_iterations,
@@ -249,9 +255,6 @@ class Workflow(DictConvertible):
"output_executors": self._output_executors,
}
- # Add optional name and description if provided
- if self.name is not None:
- data["name"] = self.name
if self.description is not None:
data["description"] = self.description
@@ -565,6 +568,15 @@ class Workflow(DictConvertible):
):
if event.type == "output" and not self._should_yield_output_event(event):
continue
+ if event.type == "request_info" and event.request_id in (responses or {}):
+ # Don't yield request_info events for which we have responses to send -
+ # these are considered "handled". This prevents the caller from seeing
+ # events for requests they are already responding to.
+ # This usually happens when responses are provided with a checkpoint
+ # (restore then send), because the request_info events are stored in the
+ # checkpoint and would be emitted on restoration by the runner regardless
+ # of if a response is provided or not.
+ continue
yield event
async def _run_cleanup(self, checkpoint_storage: CheckpointStorage | None) -> None:
@@ -753,7 +765,7 @@ class Workflow(DictConvertible):
if isinstance(executor, WorkflowExecutor):
executor_sig = {
"type": executor_sig,
- "sub_workflow": executor.workflow._graph_signature,
+ "sub_workflow": executor.workflow.graph_signature,
}
executors_signature[executor_id] = executor_sig
@@ -796,7 +808,6 @@ class Workflow(DictConvertible):
"start_executor": self.start_executor_id,
"executors": executors_signature,
"edge_groups": edge_groups_signature,
- "max_iterations": self.max_iterations,
}
@staticmethod
@@ -804,10 +815,6 @@ class Workflow(DictConvertible):
canonical = json.dumps(signature, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
- @property
- def graph_signature_hash(self) -> str:
- return self._graph_signature_hash
-
@property
def input_types(self) -> list[type[Any] | types.UnionType]:
"""Get the input types of the workflow.
diff --git a/python/packages/core/agent_framework/_workflows/_workflow_builder.py b/python/packages/core/agent_framework/_workflows/_workflow_builder.py
index d7bdf9a918..1a71b3a49b 100644
--- a/python/packages/core/agent_framework/_workflows/_workflow_builder.py
+++ b/python/packages/core/agent_framework/_workflows/_workflow_builder.py
@@ -2,6 +2,7 @@
import logging
import sys
+import uuid
from collections.abc import Callable, Sequence
from typing import Any
@@ -88,7 +89,11 @@ class WorkflowBuilder:
Args:
max_iterations: Maximum number of iterations for workflow convergence. Default is 100.
- name: Optional human-readable name for the workflow.
+ name: A human-readable name for the workflow builder. This name will be the identifier
+ for all workflow instances created from this builder. If not provided, a unique name
+ will be generated. This will be useful for versioning, monitoring, checkpointing, and
+ debugging workflows. Keeping this name unique across versions of your workflow definitions
+ is recommended for better observability and management.
description: Optional description of what the workflow does.
start_executor: The starting executor for the workflow. Can be an Executor instance
or SupportsAgentRun instance.
@@ -101,7 +106,7 @@ class WorkflowBuilder:
self._start_executor: Executor | None = None
self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage
self._max_iterations: int = max_iterations
- self._name: str | None = name
+ self._name: str = name or f"WorkflowBuilder-{uuid.uuid4()!s}"
self._description: str | None = description
# Maps underlying SupportsAgentRun object id -> wrapped Executor so we reuse the same wrapper
# across start_executor / add_edge calls. This avoids multiple AgentExecutor instances
@@ -658,19 +663,18 @@ class WorkflowBuilder:
executors,
start_executor,
context,
- self._max_iterations,
- name=self._name,
+ self._name,
description=self._description,
+ max_iterations=self._max_iterations,
output_executors=output_executors,
)
build_attributes: dict[str, Any] = {
+ OtelAttr.WORKFLOW_BUILDER_NAME: self._name,
OtelAttr.WORKFLOW_ID: workflow.id,
OtelAttr.WORKFLOW_DEFINITION: workflow.to_json(),
}
- if workflow.name:
- build_attributes[OtelAttr.WORKFLOW_NAME] = workflow.name
- if workflow.description:
- build_attributes[OtelAttr.WORKFLOW_DESCRIPTION] = workflow.description
+ if self._description:
+ build_attributes[OtelAttr.WORKFLOW_BUILDER_DESCRIPTION] = self._description
span.set_attributes(build_attributes)
# Add workflow build completed event
diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py
index 3e5fd449bc..0d2c86070c 100644
--- a/python/packages/core/agent_framework/_workflows/_workflow_executor.py
+++ b/python/packages/core/agent_framework/_workflows/_workflow_executor.py
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from ._workflow import Workflow
-from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
+from ._checkpoint_encoding import decode_checkpoint_value
from ._const import WORKFLOW_RUN_KWARGS_KEY
from ._events import (
WorkflowEvent,
@@ -454,8 +454,7 @@ class WorkflowExecutor(Executor):
"""Get the current state of the WorkflowExecutor for checkpointing purposes."""
return {
"execution_contexts": {
- execution_id: encode_checkpoint_value(execution_context)
- for execution_id, execution_context in self._execution_contexts.items()
+ execution_id: execution_context for execution_id, execution_context in self._execution_contexts.items()
},
"request_to_execution": dict(self._request_to_execution),
}
@@ -654,21 +653,6 @@ class WorkflowExecutor(Executor):
try:
# Resume the sub-workflow with all collected responses
result = await self.workflow.run(responses=responses_to_send)
- # Remove handled requests from result. The result may contain the original
- # RequestInfoEvents that were already handled. This is due to checkpointing
- # and rehydration of the workflow that re-adds the RequestInfoEvents to the
- # workflow's _runner_context thus the event queue. When the workflow is resumed,
- # those events will be emitted at the very beginning of the superstep, prior to
- # processing messages/responses, creating the illusion that the workflow is
- # requesting the same information again.
- for request_id in responses_to_send:
- event_to_remove = next(
- (event for event in result if event.type == "request_info" and event.request_id == request_id),
- None,
- )
- if event_to_remove:
- result.remove(event_to_remove)
-
# Process the workflow result using shared logic
await self._process_workflow_result(result, execution_context, ctx)
finally:
diff --git a/python/packages/core/agent_framework/azure/_assistants_client.py b/python/packages/core/agent_framework/azure/_assistants_client.py
index 52d219529b..89399ed833 100644
--- a/python/packages/core/agent_framework/azure/_assistants_client.py
+++ b/python/packages/core/agent_framework/azure/_assistants_client.py
@@ -7,12 +7,13 @@ from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, ClassVar, Generic
from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI
-from pydantic import ValidationError
+from .._settings import load_settings
from ..exceptions import ServiceInitializationError
from ..openai import OpenAIAssistantsClient
from ..openai._assistants_client import OpenAIAssistantsOptions
-from ._shared import AzureOpenAISettings
+from ._entra_id_authentication import get_entra_auth_token
+from ._shared import DEFAULT_AZURE_TOKEN_ENDPOINT, AzureOpenAISettings, _apply_azure_defaults
if TYPE_CHECKING:
from azure.core.credentials import TokenCredential
@@ -137,23 +138,21 @@ class AzureOpenAIAssistantsClient(
client: AzureOpenAIAssistantsClient[MyOptions] = AzureOpenAIAssistantsClient()
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
- try:
- azure_openai_settings = AzureOpenAISettings(
- # pydantic settings will see if there is a value, if not, will try the env var or .env file
- api_key=api_key, # type: ignore
- base_url=base_url, # type: ignore
- endpoint=endpoint, # type: ignore
- chat_deployment_name=deployment_name,
- api_version=api_version,
- env_file_path=env_file_path,
- env_file_encoding=env_file_encoding,
- token_endpoint=token_endpoint,
- default_api_version=self.DEFAULT_AZURE_API_VERSION,
- )
- except ValidationError as ex:
- raise ServiceInitializationError("Failed to create Azure OpenAI settings.", ex) from ex
+ azure_openai_settings = load_settings(
+ AzureOpenAISettings,
+ env_prefix="AZURE_OPENAI_",
+ api_key=api_key,
+ base_url=base_url,
+ endpoint=endpoint,
+ chat_deployment_name=deployment_name,
+ api_version=api_version,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ token_endpoint=token_endpoint,
+ )
+ _apply_azure_defaults(azure_openai_settings, default_api_version=self.DEFAULT_AZURE_API_VERSION)
- if not azure_openai_settings.chat_deployment_name:
+ if not azure_openai_settings["chat_deployment_name"]:
raise ServiceInitializationError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable."
@@ -162,40 +161,41 @@ class AzureOpenAIAssistantsClient(
# Handle authentication: try API key first, then AD token, then Entra ID
if (
not async_client
- and not azure_openai_settings.api_key
+ and not azure_openai_settings["api_key"]
and not ad_token
and not ad_token_provider
- and azure_openai_settings.token_endpoint
+ and azure_openai_settings["token_endpoint"]
and credential
):
- ad_token = azure_openai_settings.get_azure_auth_token(credential)
+ token_ep = azure_openai_settings["token_endpoint"] or DEFAULT_AZURE_TOKEN_ENDPOINT
+ ad_token = get_entra_auth_token(credential, token_ep)
- if not async_client and not azure_openai_settings.api_key and not ad_token and not ad_token_provider:
+ if not async_client and not azure_openai_settings["api_key"] and not ad_token and not ad_token_provider:
raise ServiceInitializationError("The Azure OpenAI API key, ad_token, or ad_token_provider is required.")
# Create Azure client if not provided
if not async_client:
client_params: dict[str, Any] = {
- "api_version": azure_openai_settings.api_version,
+ "api_version": azure_openai_settings["api_version"],
"default_headers": default_headers,
}
- if azure_openai_settings.api_key:
- client_params["api_key"] = azure_openai_settings.api_key.get_secret_value()
+ if azure_openai_settings["api_key"]:
+ client_params["api_key"] = azure_openai_settings["api_key"].get_secret_value()
elif ad_token:
client_params["azure_ad_token"] = ad_token
elif ad_token_provider:
client_params["azure_ad_token_provider"] = ad_token_provider
- if azure_openai_settings.base_url:
- client_params["base_url"] = str(azure_openai_settings.base_url)
- elif azure_openai_settings.endpoint:
- client_params["azure_endpoint"] = str(azure_openai_settings.endpoint)
+ if azure_openai_settings["base_url"]:
+ client_params["base_url"] = str(azure_openai_settings["base_url"])
+ elif azure_openai_settings["endpoint"]:
+ client_params["azure_endpoint"] = str(azure_openai_settings["endpoint"])
async_client = AsyncAzureOpenAI(**client_params)
super().__init__(
- model_id=azure_openai_settings.chat_deployment_name,
+ model_id=azure_openai_settings["chat_deployment_name"],
assistant_id=assistant_id,
assistant_name=assistant_name,
assistant_description=assistant_description,
diff --git a/python/packages/core/agent_framework/azure/_chat_client.py b/python/packages/core/agent_framework/azure/_chat_client.py
index 0fcf99823a..485df4a6ed 100644
--- a/python/packages/core/agent_framework/azure/_chat_client.py
+++ b/python/packages/core/agent_framework/azure/_chat_client.py
@@ -12,7 +12,7 @@ from azure.core.credentials import TokenCredential
from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
-from pydantic import BaseModel, ValidationError
+from pydantic import BaseModel
from agent_framework import (
Annotation,
@@ -28,9 +28,11 @@ from agent_framework.observability import ChatTelemetryLayer
from agent_framework.openai import OpenAIChatOptions
from agent_framework.openai._chat_client import RawOpenAIChatClient
+from .._settings import load_settings
from ._shared import (
AzureOpenAIConfigMixin,
AzureOpenAISettings,
+ _apply_azure_defaults,
)
if sys.version_info >= (3, 13):
@@ -247,37 +249,35 @@ class AzureOpenAIChatClient( # type: ignore[misc]
client: AzureOpenAIChatClient[MyOptions] = AzureOpenAIChatClient()
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
- try:
- # Filter out any None values from the arguments
- azure_openai_settings = AzureOpenAISettings(
- # pydantic settings will see if there is a value, if not, will try the env var or .env file
- api_key=api_key, # type: ignore
- base_url=base_url, # type: ignore
- endpoint=endpoint, # type: ignore
- chat_deployment_name=deployment_name,
- api_version=api_version,
- env_file_path=env_file_path,
- env_file_encoding=env_file_encoding,
- token_endpoint=token_endpoint,
- )
- except ValidationError as exc:
- raise ServiceInitializationError(f"Failed to validate settings: {exc}") from exc
+ azure_openai_settings = load_settings(
+ AzureOpenAISettings,
+ env_prefix="AZURE_OPENAI_",
+ api_key=api_key,
+ base_url=base_url,
+ endpoint=endpoint,
+ chat_deployment_name=deployment_name,
+ api_version=api_version,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ token_endpoint=token_endpoint,
+ )
+ _apply_azure_defaults(azure_openai_settings)
- if not azure_openai_settings.chat_deployment_name:
+ if not azure_openai_settings["chat_deployment_name"]:
raise ServiceInitializationError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable."
)
super().__init__(
- deployment_name=azure_openai_settings.chat_deployment_name,
- endpoint=azure_openai_settings.endpoint,
- base_url=azure_openai_settings.base_url,
- api_version=azure_openai_settings.api_version, # type: ignore
- api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None,
+ deployment_name=azure_openai_settings["chat_deployment_name"],
+ endpoint=azure_openai_settings["endpoint"],
+ base_url=azure_openai_settings["base_url"],
+ api_version=azure_openai_settings["api_version"], # type: ignore
+ api_key=azure_openai_settings["api_key"].get_secret_value() if azure_openai_settings["api_key"] else None,
ad_token=ad_token,
ad_token_provider=ad_token_provider,
- token_endpoint=azure_openai_settings.token_endpoint,
+ token_endpoint=azure_openai_settings["token_endpoint"],
credential=credential,
default_headers=default_headers,
client=async_client,
diff --git a/python/packages/core/agent_framework/azure/_responses_client.py b/python/packages/core/agent_framework/azure/_responses_client.py
index 0049979a6a..65335482fe 100644
--- a/python/packages/core/agent_framework/azure/_responses_client.py
+++ b/python/packages/core/agent_framework/azure/_responses_client.py
@@ -5,15 +5,15 @@ from __future__ import annotations
import sys
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Generic
-from urllib.parse import urljoin
+from urllib.parse import urljoin, urlparse
from azure.ai.projects.aio import AIProjectClient
from azure.core.credentials import TokenCredential
from openai import AsyncOpenAI
from openai.lib.azure import AsyncAzureADTokenProvider
-from pydantic import ValidationError
from .._middleware import ChatMiddlewareLayer
+from .._settings import load_settings
from .._telemetry import AGENT_FRAMEWORK_USER_AGENT
from .._tools import FunctionInvocationConfiguration, FunctionInvocationLayer
from ..exceptions import ServiceInitializationError
@@ -22,6 +22,7 @@ from ..openai._responses_client import RawOpenAIResponsesClient
from ._shared import (
AzureOpenAIConfigMixin,
AzureOpenAISettings,
+ _apply_azure_defaults,
)
if sys.version_info >= (3, 13):
@@ -82,7 +83,8 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
env_file_encoding: str | None = None,
instruction_role: str | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
- function_invocation_configuration: FunctionInvocationConfiguration | None = None,
+ function_invocation_configuration: FunctionInvocationConfiguration
+ | None = None,
**kwargs: Any,
) -> None:
"""Initialize an Azure OpenAI Responses client.
@@ -188,54 +190,58 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
deployment_name = str(model_id)
# Project client path: create OpenAI client from an Azure AI Foundry project
- if async_client is None and (project_client is not None or project_endpoint is not None):
+ if async_client is None and (
+ project_client is not None or project_endpoint is not None
+ ):
async_client = self._create_client_from_project(
project_client=project_client,
project_endpoint=project_endpoint,
credential=credential,
)
- try:
- azure_openai_settings = AzureOpenAISettings(
- # pydantic settings will see if there is a value, if not, will try the env var or .env file
- api_key=api_key, # type: ignore
- base_url=base_url, # type: ignore
- endpoint=endpoint, # type: ignore
- responses_deployment_name=deployment_name,
- api_version=api_version,
- env_file_path=env_file_path,
- env_file_encoding=env_file_encoding,
- token_endpoint=token_endpoint,
- default_api_version="preview",
+ azure_openai_settings = load_settings(
+ AzureOpenAISettings,
+ env_prefix="AZURE_OPENAI_",
+ api_key=api_key,
+ base_url=base_url,
+ endpoint=endpoint,
+ responses_deployment_name=deployment_name,
+ api_version=api_version,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ token_endpoint=token_endpoint,
+ )
+ _apply_azure_defaults(azure_openai_settings, default_api_version="preview")
+ # TODO(peterychang): This is a temporary hack to ensure that the base_url is set correctly
+ # while this feature is in preview.
+ # But we should only do this if we're on azure. Private deployments may not need this.
+ if (
+ not azure_openai_settings.get("base_url")
+ and azure_openai_settings.get("endpoint")
+ and (hostname := urlparse(str(azure_openai_settings["endpoint"])).hostname)
+ and hostname.endswith(".openai.azure.com")
+ ):
+ azure_openai_settings["base_url"] = urljoin(
+ str(azure_openai_settings["endpoint"]), "/openai/v1/"
)
- # TODO(peterychang): This is a temporary hack to ensure that the base_url is set correctly
- # while this feature is in preview.
- # But we should only do this if we're on azure. Private deployments may not need this.
- if (
- not azure_openai_settings.base_url
- and azure_openai_settings.endpoint
- and azure_openai_settings.endpoint.host
- and azure_openai_settings.endpoint.host.endswith(".openai.azure.com")
- ):
- azure_openai_settings.base_url = urljoin(str(azure_openai_settings.endpoint), "/openai/v1/") # type: ignore
- except ValidationError as exc:
- raise ServiceInitializationError(f"Failed to validate settings: {exc}") from exc
- if not azure_openai_settings.responses_deployment_name:
+ if not azure_openai_settings["responses_deployment_name"]:
raise ServiceInitializationError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME' environment variable."
)
super().__init__(
- deployment_name=azure_openai_settings.responses_deployment_name,
- endpoint=azure_openai_settings.endpoint,
- base_url=azure_openai_settings.base_url,
- api_version=azure_openai_settings.api_version, # type: ignore
- api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None,
+ deployment_name=azure_openai_settings["responses_deployment_name"],
+ endpoint=azure_openai_settings["endpoint"],
+ base_url=azure_openai_settings["base_url"],
+ api_version=azure_openai_settings["api_version"], # type: ignore
+ api_key=azure_openai_settings["api_key"].get_secret_value()
+ if azure_openai_settings["api_key"]
+ else None,
ad_token=ad_token,
ad_token_provider=ad_token_provider,
- token_endpoint=azure_openai_settings.token_endpoint,
+ token_endpoint=azure_openai_settings["token_endpoint"],
credential=credential,
default_headers=default_headers,
client=async_client,
diff --git a/python/packages/core/agent_framework/azure/_shared.py b/python/packages/core/agent_framework/azure/_shared.py
index 5ef0585f96..c3e4399555 100644
--- a/python/packages/core/agent_framework/azure/_shared.py
+++ b/python/packages/core/agent_framework/azure/_shared.py
@@ -11,28 +11,26 @@ from typing import Any, ClassVar, Final
from azure.core.credentials import TokenCredential
from openai import AsyncOpenAI
from openai.lib.azure import AsyncAzureOpenAI
-from pydantic import SecretStr, model_validator
-from .._pydantic import AFBaseSettings, HTTPsUrl
+from .._settings import SecretString
from .._telemetry import APP_INFO, prepend_agent_framework_to_user_agent
from ..exceptions import ServiceInitializationError
from ..openai._shared import OpenAIBase
from ._entra_id_authentication import get_entra_auth_token
-if sys.version_info >= (3, 11):
- from typing import Self # pragma: no cover
-else:
- from typing_extensions import Self # pragma: no cover
-
-
logger: logging.Logger = logging.getLogger(__name__)
+if sys.version_info >= (3, 11):
+ from typing import TypedDict # type: ignore # pragma: no cover
+else:
+ from typing_extensions import TypedDict # type: ignore # pragma: no cover
+
DEFAULT_AZURE_API_VERSION: Final[str] = "2024-10-21"
DEFAULT_AZURE_TOKEN_ENDPOINT: Final[str] = "https://cognitiveservices.azure.com/.default" # noqa: S105
-class AzureOpenAISettings(AFBaseSettings):
+class AzureOpenAISettings(TypedDict, total=False):
"""AzureOpenAI model settings.
The settings are first loaded from environment variables with the prefix 'AZURE_OPENAI_'.
@@ -62,7 +60,7 @@ class AzureOpenAISettings(AFBaseSettings):
found in the Keys & Endpoint section when examining your resource in
the Azure portal. You can use either KEY1 or KEY2.
Can be set via environment variable AZURE_OPENAI_API_KEY.
- api_version: The API version to use. The default value is `default_api_version`.
+ api_version: The API version to use. The default value is `DEFAULT_AZURE_API_VERSION`.
Can be set via environment variable AZURE_OPENAI_API_VERSION.
base_url: The url of the Azure deployment. This value
can be found in the Keys & Endpoint section when examining
@@ -71,14 +69,8 @@ class AzureOpenAISettings(AFBaseSettings):
use endpoint if you only want to supply the endpoint.
Can be set via environment variable AZURE_OPENAI_BASE_URL.
token_endpoint: The token endpoint to use to retrieve the authentication token.
- The default value is `default_token_endpoint`.
+ The default value is `DEFAULT_AZURE_TOKEN_ENDPOINT`.
Can be set via environment variable AZURE_OPENAI_TOKEN_ENDPOINT.
- default_api_version: The default API version to use if not specified.
- The default value is "2024-10-21".
- default_token_endpoint: The default token endpoint to use if not specified.
- The default value is "https://cognitiveservices.azure.com/.default".
- env_file_path: The path to the .env file to load settings from.
- env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
Examples:
.. code-block:: python
@@ -89,60 +81,46 @@ class AzureOpenAISettings(AFBaseSettings):
# Set AZURE_OPENAI_ENDPOINT=https://your-endpoint.openai.azure.com
# Set AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=gpt-4
# Set AZURE_OPENAI_API_KEY=your-key
- settings = AzureOpenAISettings()
+ settings = load_settings(AzureOpenAISettings, env_prefix="AZURE_OPENAI_")
# Or passing parameters directly
- settings = AzureOpenAISettings(
- endpoint="https://your-endpoint.openai.azure.com", chat_deployment_name="gpt-4", api_key="your-key"
+ settings = load_settings(
+ AzureOpenAISettings,
+ env_prefix="AZURE_OPENAI_",
+ endpoint="https://your-endpoint.openai.azure.com",
+ chat_deployment_name="gpt-4",
+ api_key="your-key",
)
# Or loading from a .env file
- settings = AzureOpenAISettings(env_file_path="path/to/.env")
+ settings = load_settings(AzureOpenAISettings, env_prefix="AZURE_OPENAI_", env_file_path="path/to/.env")
"""
- env_prefix: ClassVar[str] = "AZURE_OPENAI_"
+ chat_deployment_name: str | None
+ responses_deployment_name: str | None
+ endpoint: str | None
+ base_url: str | None
+ api_key: SecretString | None
+ api_version: str | None
+ token_endpoint: str | None
- chat_deployment_name: str | None = None
- responses_deployment_name: str | None = None
- endpoint: HTTPsUrl | None = None
- base_url: HTTPsUrl | None = None
- api_key: SecretStr | None = None
- api_version: str | None = None
- token_endpoint: str | None = None
- default_api_version: str = DEFAULT_AZURE_API_VERSION
- default_token_endpoint: str = DEFAULT_AZURE_TOKEN_ENDPOINT
- def get_azure_auth_token(
- self, credential: TokenCredential, token_endpoint: str | None = None, **kwargs: Any
- ) -> str | None:
- """Retrieve a Microsoft Entra Auth Token for a given token endpoint for the use with Azure OpenAI.
+def _apply_azure_defaults(
+ settings: AzureOpenAISettings,
+ default_api_version: str = DEFAULT_AZURE_API_VERSION,
+ default_token_endpoint: str = DEFAULT_AZURE_TOKEN_ENDPOINT,
+) -> None:
+ """Apply default values for api_version and token_endpoint after loading settings.
- The required role for the token is `Cognitive Services OpenAI Contributor`.
- The token endpoint may be specified as an environment variable, via the .env
- file or as an argument. If the token endpoint is not provided, the default is None.
- The `token_endpoint` argument takes precedence over the `token_endpoint` attribute.
-
- Args:
- credential: The Azure AD credential to use.
- token_endpoint: The token endpoint to use. Defaults to `https://cognitiveservices.azure.com/.default`.
-
- Keyword Args:
- **kwargs: Additional keyword arguments to pass to the token retrieval method.
-
- Returns:
- The Azure token or None if the token could not be retrieved.
-
- Raises:
- ServiceInitializationError: If the token endpoint is not provided.
- """
- endpoint_to_use = token_endpoint or self.token_endpoint or self.default_token_endpoint
- return get_entra_auth_token(credential, endpoint_to_use, **kwargs)
-
- @model_validator(mode="after")
- def _validate_fields(self) -> Self:
- self.api_version = self.api_version or self.default_api_version
- self.token_endpoint = self.token_endpoint or self.default_token_endpoint
- return self
+ Args:
+ settings: The loaded Azure OpenAI settings dict.
+ default_api_version: The default API version to use if not set.
+ default_token_endpoint: The default token endpoint to use if not set.
+ """
+ if not settings.get("api_version"):
+ settings["api_version"] = default_api_version
+ if not settings.get("token_endpoint"):
+ settings["token_endpoint"] = default_token_endpoint
class AzureOpenAIConfigMixin(OpenAIBase):
@@ -154,8 +132,8 @@ class AzureOpenAIConfigMixin(OpenAIBase):
def __init__(
self,
deployment_name: str,
- endpoint: HTTPsUrl | None = None,
- base_url: HTTPsUrl | None = None,
+ endpoint: str | None = None,
+ base_url: str | None = None,
api_version: str = DEFAULT_AZURE_API_VERSION,
api_key: str | None = None,
ad_token: str | None = None,
@@ -170,7 +148,7 @@ class AzureOpenAIConfigMixin(OpenAIBase):
"""Internal class for configuring a connection to an Azure OpenAI service.
The `validate_call` decorator is used with a configuration that allows arbitrary types.
- This is necessary for types like `HTTPsUrl` and `OpenAIModelTypes`.
+ This is necessary for types like `str` and `OpenAIModelTypes`.
Args:
deployment_name: Name of the deployment.
diff --git a/python/packages/core/agent_framework/exceptions.py b/python/packages/core/agent_framework/exceptions.py
index 971b612ea3..45296ad74c 100644
--- a/python/packages/core/agent_framework/exceptions.py
+++ b/python/packages/core/agent_framework/exceptions.py
@@ -146,3 +146,9 @@ class ContentError(AgentFrameworkException):
"""An error occurred while processing content."""
pass
+
+
+class SettingNotFoundError(AgentFrameworkException):
+ """A required setting could not be resolved from any source."""
+
+ pass
diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py
index c97ae0168a..ac3c493309 100644
--- a/python/packages/core/agent_framework/observability.py
+++ b/python/packages/core/agent_framework/observability.py
@@ -18,11 +18,10 @@ from opentelemetry import metrics, trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.attributes import service_attributes
from opentelemetry.semconv_ai import Meters, SpanAttributes
-from pydantic import PrivateAttr
from . import __version__ as version_info
from ._logging import get_logger
-from ._pydantic import AFBaseSettings
+from ._settings import load_settings
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
@@ -196,6 +195,8 @@ class OtelAttr(str, Enum):
# Workflow attributes
WORKFLOW_ID = "workflow.id"
+ WORKFLOW_BUILDER_NAME = "workflow_builder.name"
+ WORKFLOW_BUILDER_DESCRIPTION = "workflow_builder.description"
WORKFLOW_NAME = "workflow.name"
WORKFLOW_DESCRIPTION = "workflow.description"
WORKFLOW_DEFINITION = "workflow.definition"
@@ -564,7 +565,16 @@ def create_metric_views() -> list[View]:
]
-class ObservabilitySettings(AFBaseSettings):
+class _ObservabilitySettingsData(TypedDict, total=False):
+ """TypedDict schema for observability settings fields."""
+
+ enable_instrumentation: bool | None
+ enable_sensitive_data: bool | None
+ enable_console_exporters: bool | None
+ vs_code_extension_port: int | None
+
+
+class ObservabilitySettings:
"""Settings for Agent Framework Observability.
If the environment variables are not found, the settings can
@@ -601,23 +611,27 @@ class ObservabilitySettings(AFBaseSettings):
settings = ObservabilitySettings(enable_instrumentation=True, enable_console_exporters=True)
"""
- env_prefix: ClassVar[str] = ""
-
- enable_instrumentation: bool = False
- enable_sensitive_data: bool = False
- enable_console_exporters: bool = False
- vs_code_extension_port: int | None = None
- _resource: Resource = PrivateAttr()
- _executed_setup: bool = PrivateAttr(default=False)
-
def __init__(self, **kwargs: Any) -> None:
"""Initialize the settings and create the resource."""
- super().__init__(**kwargs)
- # Create resource with env file settings
- self._resource = create_resource(
- env_file_path=self.env_file_path,
- env_file_encoding=self.env_file_encoding,
+ env_file_path = kwargs.pop("env_file_path", None)
+ env_file_encoding = kwargs.pop("env_file_encoding", None)
+ data = load_settings(
+ _ObservabilitySettingsData,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ **kwargs,
)
+ self.enable_instrumentation: bool = data.get("enable_instrumentation") or False
+ self.enable_sensitive_data: bool = data.get("enable_sensitive_data") or False
+ self.enable_console_exporters: bool = data.get("enable_console_exporters") or False
+ self.vs_code_extension_port: int | None = data.get("vs_code_extension_port")
+ self.env_file_path = env_file_path
+ self.env_file_encoding = env_file_encoding
+ self._resource = create_resource(
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ )
+ self._executed_setup = False
@property
def ENABLED(self) -> bool:
diff --git a/python/packages/core/agent_framework/openai/_assistant_provider.py b/python/packages/core/agent_framework/openai/_assistant_provider.py
index 8095f04fe1..90820ec5d2 100644
--- a/python/packages/core/agent_framework/openai/_assistant_provider.py
+++ b/python/packages/core/agent_framework/openai/_assistant_provider.py
@@ -8,7 +8,9 @@ from typing import TYPE_CHECKING, Any, Generic, cast
from openai import AsyncOpenAI
from openai.types.beta.assistant import Assistant
-from pydantic import BaseModel, SecretStr, ValidationError
+from pydantic import BaseModel
+
+from agent_framework._settings import SecretString, load_settings
from .._agents import Agent
from .._memory import ContextProvider
@@ -107,7 +109,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
self,
client: AsyncOpenAI | None = None,
*,
- api_key: str | SecretStr | Callable[[], str | Awaitable[str]] | None = None,
+ api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None,
org_id: str | None = None,
base_url: str | None = None,
env_file_path: str | None = None,
@@ -147,35 +149,34 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
if client is None:
# Load settings and create client
- try:
- settings = OpenAISettings(
- api_key=api_key, # type: ignore[reportArgumentType]
- org_id=org_id,
- base_url=base_url,
- env_file_path=env_file_path,
- env_file_encoding=env_file_encoding,
- )
- except ValidationError as ex:
- raise ServiceInitializationError("Failed to create OpenAI settings.", ex) from ex
+ settings = load_settings(
+ OpenAISettings,
+ env_prefix="OPENAI_",
+ api_key=api_key,
+ org_id=org_id,
+ base_url=base_url,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ )
- if not settings.api_key:
+ if not settings["api_key"]:
raise ServiceInitializationError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
# Get API key value
api_key_value: str | Callable[[], str | Awaitable[str]] | None
- if isinstance(settings.api_key, SecretStr):
- api_key_value = settings.api_key.get_secret_value()
+ if isinstance(settings["api_key"], SecretString):
+ api_key_value = settings["api_key"].get_secret_value()
else:
- api_key_value = settings.api_key
+ api_key_value = settings["api_key"]
# Create client
client_args: dict[str, Any] = {"api_key": api_key_value}
- if settings.org_id:
- client_args["organization"] = settings.org_id
- if settings.base_url:
- client_args["base_url"] = settings.base_url
+ if settings["org_id"]:
+ client_args["organization"] = settings["org_id"]
+ if settings["base_url"]:
+ client_args["base_url"] = settings["base_url"]
self._client = AsyncOpenAI(**client_args)
diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py
index 2c243fbb04..218dacbea8 100644
--- a/python/packages/core/agent_framework/openai/_assistants_client.py
+++ b/python/packages/core/agent_framework/openai/_assistants_client.py
@@ -27,10 +27,11 @@ from openai.types.beta.threads import (
from openai.types.beta.threads.run_create_params import AdditionalMessage
from openai.types.beta.threads.run_submit_tool_outputs_params import ToolOutput
from openai.types.beta.threads.runs import RunStep
-from pydantic import BaseModel, ValidationError
+from pydantic import BaseModel
from .._clients import BaseChatClient
from .._middleware import ChatMiddlewareLayer
+from .._settings import load_settings
from .._tools import (
FunctionInvocationConfiguration,
FunctionInvocationLayer,
@@ -343,35 +344,34 @@ class OpenAIAssistantsClient( # type: ignore[misc]
client: OpenAIAssistantsClient[MyOptions] = OpenAIAssistantsClient(model_id="gpt-4")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
- try:
- openai_settings = OpenAISettings(
- api_key=api_key, # type: ignore[reportArgumentType]
- base_url=base_url,
- org_id=org_id,
- chat_model_id=model_id,
- env_file_path=env_file_path,
- env_file_encoding=env_file_encoding,
- )
- except ValidationError as ex:
- raise ServiceInitializationError("Failed to create OpenAI settings.", ex) from ex
+ openai_settings = load_settings(
+ OpenAISettings,
+ env_prefix="OPENAI_",
+ api_key=api_key,
+ base_url=base_url,
+ org_id=org_id,
+ chat_model_id=model_id,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ )
- if not async_client and not openai_settings.api_key:
+ if not async_client and not openai_settings["api_key"]:
raise ServiceInitializationError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
- if not openai_settings.chat_model_id:
+ if not openai_settings["chat_model_id"]:
raise ServiceInitializationError(
"OpenAI model ID is required. "
"Set via 'model_id' parameter or 'OPENAI_CHAT_MODEL_ID' environment variable."
)
super().__init__(
- model_id=openai_settings.chat_model_id,
- api_key=self._get_api_key(openai_settings.api_key),
- org_id=openai_settings.org_id,
+ model_id=openai_settings["chat_model_id"],
+ api_key=self._get_api_key(openai_settings["api_key"]),
+ org_id=openai_settings["org_id"],
default_headers=default_headers,
client=async_client,
- base_url=openai_settings.base_url,
+ base_url=openai_settings["base_url"],
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
)
diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py
index f2335ff6be..b806848b75 100644
--- a/python/packages/core/agent_framework/openai/_chat_client.py
+++ b/python/packages/core/agent_framework/openai/_chat_client.py
@@ -17,11 +17,12 @@ from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
from openai.types.chat.chat_completion_message_custom_tool_call import ChatCompletionMessageCustomToolCall
from openai.types.chat.completion_create_params import WebSearchOptions
-from pydantic import BaseModel, ValidationError
+from pydantic import BaseModel
from .._clients import BaseChatClient
from .._logging import get_logger
from .._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer
+from .._settings import load_settings
from .._tools import (
FunctionInvocationConfiguration,
FunctionInvocationLayer,
@@ -718,33 +719,32 @@ class OpenAIChatClient( # type: ignore[misc]
client: OpenAIChatClient[MyOptions] = OpenAIChatClient(model_id="")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
- try:
- openai_settings = OpenAISettings(
- api_key=api_key, # type: ignore[reportArgumentType]
- base_url=base_url,
- org_id=org_id,
- chat_model_id=model_id,
- env_file_path=env_file_path,
- env_file_encoding=env_file_encoding,
- )
- except ValidationError as ex:
- raise ServiceInitializationError("Failed to create OpenAI settings.", ex) from ex
+ openai_settings = load_settings(
+ OpenAISettings,
+ env_prefix="OPENAI_",
+ api_key=api_key,
+ base_url=base_url,
+ org_id=org_id,
+ chat_model_id=model_id,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ )
- if not async_client and not openai_settings.api_key:
+ if not async_client and not openai_settings["api_key"]:
raise ServiceInitializationError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
- if not openai_settings.chat_model_id:
+ if not openai_settings["chat_model_id"]:
raise ServiceInitializationError(
"OpenAI model ID is required. "
"Set via 'model_id' parameter or 'OPENAI_CHAT_MODEL_ID' environment variable."
)
super().__init__(
- model_id=openai_settings.chat_model_id,
- api_key=self._get_api_key(openai_settings.api_key),
- base_url=openai_settings.base_url if openai_settings.base_url else None,
- org_id=openai_settings.org_id,
+ model_id=openai_settings["chat_model_id"],
+ api_key=self._get_api_key(openai_settings["api_key"]),
+ base_url=openai_settings["base_url"] if openai_settings["base_url"] else None,
+ org_id=openai_settings["org_id"],
default_headers=default_headers,
client=async_client,
instruction_role=instruction_role,
diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py
index f239221c49..9ec0751850 100644
--- a/python/packages/core/agent_framework/openai/_responses_client.py
+++ b/python/packages/core/agent_framework/openai/_responses_client.py
@@ -33,11 +33,12 @@ from openai.types.responses.tool_param import (
Mcp,
)
from openai.types.responses.web_search_tool_param import WebSearchToolParam
-from pydantic import BaseModel, ValidationError
+from pydantic import BaseModel
from .._clients import BaseChatClient
from .._logging import get_logger
from .._middleware import ChatMiddlewareLayer
+from .._settings import load_settings
from .._tools import (
FunctionInvocationConfiguration,
FunctionInvocationLayer,
@@ -1810,36 +1811,35 @@ class OpenAIResponsesClient( # type: ignore[misc]
client: OpenAIResponsesClient[MyOptions] = OpenAIResponsesClient(model_id="gpt-4o")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
- try:
- openai_settings = OpenAISettings(
- api_key=api_key, # type: ignore[reportArgumentType]
- org_id=org_id,
- base_url=base_url,
- responses_model_id=model_id,
- env_file_path=env_file_path,
- env_file_encoding=env_file_encoding,
- )
- except ValidationError as ex:
- raise ServiceInitializationError("Failed to create OpenAI settings.", ex) from ex
+ openai_settings = load_settings(
+ OpenAISettings,
+ env_prefix="OPENAI_",
+ api_key=api_key,
+ org_id=org_id,
+ base_url=base_url,
+ responses_model_id=model_id,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ )
- if not async_client and not openai_settings.api_key:
+ if not async_client and not openai_settings["api_key"]:
raise ServiceInitializationError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
- if not openai_settings.responses_model_id:
+ if not openai_settings["responses_model_id"]:
raise ServiceInitializationError(
"OpenAI model ID is required. "
"Set via 'model_id' parameter or 'OPENAI_RESPONSES_MODEL_ID' environment variable."
)
super().__init__(
- model_id=openai_settings.responses_model_id,
- api_key=self._get_api_key(openai_settings.api_key),
- org_id=openai_settings.org_id,
+ model_id=openai_settings["responses_model_id"],
+ api_key=self._get_api_key(openai_settings["api_key"]),
+ org_id=openai_settings["org_id"],
default_headers=default_headers,
client=async_client,
instruction_role=instruction_role,
- base_url=openai_settings.base_url,
+ base_url=openai_settings["base_url"],
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
**kwargs,
diff --git a/python/packages/core/agent_framework/openai/_shared.py b/python/packages/core/agent_framework/openai/_shared.py
index 008bd6ac12..c41f4b6247 100644
--- a/python/packages/core/agent_framework/openai/_shared.py
+++ b/python/packages/core/agent_framework/openai/_shared.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+import sys
from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence
from copy import copy
from typing import Any, ClassVar, Union
@@ -20,11 +21,10 @@ from openai.types.images_response import ImagesResponse
from openai.types.responses.response import Response
from openai.types.responses.response_stream_event import ResponseStreamEvent
from packaging.version import parse
-from pydantic import SecretStr
from .._logging import get_logger
-from .._pydantic import AFBaseSettings
from .._serialization import SerializationMixin
+from .._settings import SecretString
from .._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent
from .._tools import FunctionTool
from ..exceptions import ServiceInitializationError
@@ -47,6 +47,11 @@ RESPONSE_TYPE = Union[
OPTION_TYPE = dict[str, Any]
+if sys.version_info >= (3, 11):
+ from typing import TypedDict # type: ignore # pragma: no cover
+else:
+ from typing_extensions import TypedDict # type: ignore # pragma: no cover
+
__all__ = ["OpenAISettings"]
@@ -74,7 +79,7 @@ def _check_openai_version_for_callable_api_key() -> None:
logger.warning(f"Could not check OpenAI version for callable API key support: {e}")
-class OpenAISettings(AFBaseSettings):
+class OpenAISettings(TypedDict, total=False):
"""OpenAI environment settings.
The settings are first loaded from environment variables with the prefix 'OPENAI_'.
@@ -93,8 +98,6 @@ class OpenAISettings(AFBaseSettings):
Can be set via environment variable OPENAI_CHAT_MODEL_ID.
responses_model_id: The OpenAI responses model ID to use, for example, gpt-4o or o1.
Can be set via environment variable OPENAI_RESPONSES_MODEL_ID.
- env_file_path: The path to the .env file to load settings from.
- env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
Examples:
.. code-block:: python
@@ -104,22 +107,20 @@ class OpenAISettings(AFBaseSettings):
# Using environment variables
# Set OPENAI_API_KEY=sk-...
# Set OPENAI_CHAT_MODEL_ID=gpt-4
- settings = OpenAISettings()
+ settings = load_settings(OpenAISettings, env_prefix="OPENAI_")
# Or passing parameters directly
- settings = OpenAISettings(api_key="sk-...", chat_model_id="gpt-4")
+ settings = load_settings(OpenAISettings, env_prefix="OPENAI_", api_key="sk-...", chat_model_id="gpt-4")
# Or loading from a .env file
- settings = OpenAISettings(env_file_path="path/to/.env")
+ settings = load_settings(OpenAISettings, env_prefix="OPENAI_", env_file_path="path/to/.env")
"""
- env_prefix: ClassVar[str] = "OPENAI_"
-
- api_key: SecretStr | Callable[[], str | Awaitable[str]] | None = None
- base_url: str | None = None
- org_id: str | None = None
- chat_model_id: str | None = None
- responses_model_id: str | None = None
+ api_key: SecretString | Callable[[], str | Awaitable[str]] | None
+ base_url: str | None
+ org_id: str | None
+ chat_model_id: str | None
+ responses_model_id: str | None
class OpenAIBase(SerializationMixin):
@@ -181,19 +182,18 @@ class OpenAIBase(SerializationMixin):
return self.client
def _get_api_key(
- self, api_key: str | SecretStr | Callable[[], str | Awaitable[str]] | None
+ self, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None
) -> str | Callable[[], str | Awaitable[str]] | None:
"""Get the appropriate API key value for client initialization.
Args:
- api_key: The API key parameter which can be a string, SecretStr, callable, or None.
+ api_key: The API key parameter which can be a string, SecretString, callable, or None.
Returns:
For callable API keys: returns the callable directly.
- For SecretStr API keys: returns the string value.
- For string/None API keys: returns as-is.
+ For SecretString/string/None API keys: returns as-is (SecretString is a str subclass).
"""
- if isinstance(api_key, SecretStr):
+ if isinstance(api_key, SecretString):
return api_key.get_secret_value()
# Check version compatibility for callable API keys
diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml
index 5a90b479e7..6bd92dbd48 100644
--- a/python/packages/core/pyproject.toml
+++ b/python/packages/core/pyproject.toml
@@ -26,7 +26,7 @@ dependencies = [
# utilities
"typing-extensions",
"pydantic>=2,<3",
- "pydantic-settings>=2,<3",
+ "python-dotenv>=1,<2",
# telemetry
"opentelemetry-api>=1.39.0",
"opentelemetry-sdk>=1.39.0",
diff --git a/python/packages/core/tests/azure/test_azure_assistants_client.py b/python/packages/core/tests/azure/test_azure_assistants_client.py
index bd940b13e5..bffa678a33 100644
--- a/python/packages/core/tests/azure/test_azure_assistants_client.py
+++ b/python/packages/core/tests/azure/test_azure_assistants_client.py
@@ -19,6 +19,7 @@ from agent_framework import (
SupportsChatGetResponse,
tool,
)
+from agent_framework._settings import SecretString
from agent_framework.azure import AzureOpenAIAssistantsClient
from agent_framework.exceptions import ServiceInitializationError
@@ -556,19 +557,21 @@ def test_azure_assistants_client_entra_id_authentication() -> None:
mock_credential = MagicMock()
with (
- patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class,
+ patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings,
+ patch("agent_framework.azure._assistants_client.get_entra_auth_token") as mock_get_token,
patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client,
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
):
- mock_settings = MagicMock()
- mock_settings.chat_deployment_name = "test-deployment"
- mock_settings.api_key = None # No API key to trigger Entra ID path
- mock_settings.token_endpoint = "https://login.microsoftonline.com/test"
- mock_settings.get_azure_auth_token.return_value = "entra-token-12345"
- mock_settings.api_version = "2024-05-01-preview"
- mock_settings.endpoint = "https://test-endpoint.openai.azure.com"
- mock_settings.base_url = None
- mock_settings_class.return_value = mock_settings
+ mock_load_settings.return_value = {
+ "chat_deployment_name": "test-deployment",
+ "responses_deployment_name": None,
+ "api_key": None,
+ "token_endpoint": "https://login.microsoftonline.com/test",
+ "api_version": "2024-05-01-preview",
+ "endpoint": "https://test-endpoint.openai.azure.com",
+ "base_url": None,
+ }
+ mock_get_token.return_value = "entra-token-12345"
client = AzureOpenAIAssistantsClient(
deployment_name="test-deployment",
@@ -579,7 +582,7 @@ def test_azure_assistants_client_entra_id_authentication() -> None:
)
# Verify Entra ID token was requested
- mock_settings.get_azure_auth_token.assert_called_once_with(mock_credential)
+ mock_get_token.assert_called_once_with(mock_credential, "https://login.microsoftonline.com/test")
# Verify client was created with the token
mock_azure_client.assert_called_once()
@@ -592,12 +595,16 @@ def test_azure_assistants_client_entra_id_authentication() -> None:
def test_azure_assistants_client_no_authentication_error() -> None:
"""Test authentication validation error when no auth provided."""
- with patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class:
- mock_settings = MagicMock()
- mock_settings.chat_deployment_name = "test-deployment"
- mock_settings.api_key = None # No API key
- mock_settings.token_endpoint = None # No token endpoint
- mock_settings_class.return_value = mock_settings
+ with patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings:
+ mock_load_settings.return_value = {
+ "chat_deployment_name": "test-deployment",
+ "responses_deployment_name": None,
+ "api_key": None,
+ "token_endpoint": None,
+ "api_version": "2024-05-01-preview",
+ "endpoint": "https://test-endpoint.openai.azure.com",
+ "base_url": None,
+ }
# Test missing authentication raises error
with pytest.raises(ServiceInitializationError, match="API key, ad_token, or ad_token_provider is required"):
@@ -611,17 +618,19 @@ def test_azure_assistants_client_no_authentication_error() -> None:
def test_azure_assistants_client_ad_token_authentication() -> None:
"""Test ad_token authentication client parameter path."""
with (
- patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class,
+ patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings,
patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client,
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
):
- mock_settings = MagicMock()
- mock_settings.chat_deployment_name = "test-deployment"
- mock_settings.api_key = None # No API key
- mock_settings.api_version = "2024-05-01-preview"
- mock_settings.endpoint = "https://test-endpoint.openai.azure.com"
- mock_settings.base_url = None
- mock_settings_class.return_value = mock_settings
+ mock_load_settings.return_value = {
+ "chat_deployment_name": "test-deployment",
+ "responses_deployment_name": None,
+ "api_key": None,
+ "token_endpoint": None,
+ "api_version": "2024-05-01-preview",
+ "endpoint": "https://test-endpoint.openai.azure.com",
+ "base_url": None,
+ }
client = AzureOpenAIAssistantsClient(
deployment_name="test-deployment",
@@ -645,17 +654,19 @@ def test_azure_assistants_client_ad_token_provider_authentication() -> None:
mock_token_provider = MagicMock(spec=AsyncAzureADTokenProvider)
with (
- patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class,
+ patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings,
patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client,
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
):
- mock_settings = MagicMock()
- mock_settings.chat_deployment_name = "test-deployment"
- mock_settings.api_key = None # No API key
- mock_settings.api_version = "2024-05-01-preview"
- mock_settings.endpoint = "https://test-endpoint.openai.azure.com"
- mock_settings.base_url = None
- mock_settings_class.return_value = mock_settings
+ mock_load_settings.return_value = {
+ "chat_deployment_name": "test-deployment",
+ "responses_deployment_name": None,
+ "api_key": None,
+ "token_endpoint": None,
+ "api_version": "2024-05-01-preview",
+ "endpoint": "https://test-endpoint.openai.azure.com",
+ "base_url": None,
+ }
client = AzureOpenAIAssistantsClient(
deployment_name="test-deployment",
@@ -675,17 +686,19 @@ def test_azure_assistants_client_ad_token_provider_authentication() -> None:
def test_azure_assistants_client_base_url_configuration() -> None:
"""Test base_url client parameter path."""
with (
- patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class,
+ patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings,
patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client,
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
):
- mock_settings = MagicMock()
- mock_settings.chat_deployment_name = "test-deployment"
- mock_settings.api_key.get_secret_value.return_value = "test-api-key"
- mock_settings.base_url = "https://custom-base-url.com"
- mock_settings.endpoint = None # No endpoint, should use base_url
- mock_settings.api_version = "2024-05-01-preview"
- mock_settings_class.return_value = mock_settings
+ mock_load_settings.return_value = {
+ "chat_deployment_name": "test-deployment",
+ "responses_deployment_name": None,
+ "api_key": SecretString("test-api-key"),
+ "token_endpoint": None,
+ "api_version": "2024-05-01-preview",
+ "endpoint": None,
+ "base_url": "https://custom-base-url.com",
+ }
client = AzureOpenAIAssistantsClient(
deployment_name="test-deployment", api_key="test-api-key", base_url="https://custom-base-url.com"
@@ -704,17 +717,19 @@ def test_azure_assistants_client_base_url_configuration() -> None:
def test_azure_assistants_client_azure_endpoint_configuration() -> None:
"""Test azure_endpoint client parameter path."""
with (
- patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class,
+ patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings,
patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client,
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
):
- mock_settings = MagicMock()
- mock_settings.chat_deployment_name = "test-deployment"
- mock_settings.api_key.get_secret_value.return_value = "test-api-key"
- mock_settings.base_url = None # No base_url
- mock_settings.endpoint = "https://test-endpoint.openai.azure.com"
- mock_settings.api_version = "2024-05-01-preview"
- mock_settings_class.return_value = mock_settings
+ mock_load_settings.return_value = {
+ "chat_deployment_name": "test-deployment",
+ "responses_deployment_name": None,
+ "api_key": SecretString("test-api-key"),
+ "token_endpoint": None,
+ "api_version": "2024-05-01-preview",
+ "endpoint": "https://test-endpoint.openai.azure.com",
+ "base_url": None,
+ }
client = AzureOpenAIAssistantsClient(
deployment_name="test-deployment",
diff --git a/python/packages/core/tests/azure/test_azure_chat_client.py b/python/packages/core/tests/azure/test_azure_chat_client.py
index f0b34cc13d..7618755a76 100644
--- a/python/packages/core/tests/azure/test_azure_chat_client.py
+++ b/python/packages/core/tests/azure/test_azure_chat_client.py
@@ -109,8 +109,11 @@ def test_init_with_empty_endpoint_and_base_url(azure_openai_unit_test_env: dict[
@pytest.mark.parametrize("override_env_param_dict", [{"AZURE_OPENAI_ENDPOINT": "http://test.com"}], indirect=True)
def test_init_with_invalid_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None:
- with pytest.raises(ServiceInitializationError):
- AzureOpenAIChatClient()
+ # Note: URL scheme validation was previously handled by pydantic's HTTPsUrl type.
+ # After migrating to load_settings with TypedDict, endpoint is a plain string and no longer
+ # validated at the settings level. The Azure OpenAI SDK may reject invalid URLs at runtime.
+ client = AzureOpenAIChatClient()
+ assert client is not None
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_BASE_URL"]], indirect=True)
diff --git a/python/packages/core/tests/core/test_settings.py b/python/packages/core/tests/core/test_settings.py
new file mode 100644
index 0000000000..12ff683924
--- /dev/null
+++ b/python/packages/core/tests/core/test_settings.py
@@ -0,0 +1,238 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Tests for load_settings() function."""
+
+import os
+import tempfile
+from typing import TypedDict
+
+import pytest
+
+from agent_framework._settings import SecretString, load_settings
+
+
+class SimpleSettings(TypedDict, total=False):
+ api_key: str | None
+ timeout: int | None
+ enabled: bool | None
+ rate_limit: float | None
+
+
+class RequiredFieldSettings(TypedDict, total=False):
+ name: str | None
+ optional_field: str | None
+
+
+class SecretSettings(TypedDict, total=False):
+ api_key: SecretString | None
+ username: str | None
+
+
+class TestLoadSettingsBasic:
+ """Test basic load_settings functionality."""
+
+ def test_fields_are_none_when_unset(self) -> None:
+ settings = load_settings(SimpleSettings, env_prefix="TEST_APP_")
+
+ assert settings["api_key"] is None
+ assert settings["timeout"] is None
+ assert settings["enabled"] is None
+ assert settings["rate_limit"] is None
+
+ def test_overrides(self) -> None:
+ settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", timeout=60, enabled=False)
+
+ assert settings["timeout"] == 60
+ assert settings["enabled"] is False
+
+ def test_none_overrides_are_filtered(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("TEST_APP_TIMEOUT", "120")
+
+ settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", timeout=None)
+
+ # timeout=None is filtered, so env var wins
+ assert settings["timeout"] == 120
+
+ def test_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("TEST_APP_API_KEY", "test-key-123")
+ monkeypatch.setenv("TEST_APP_TIMEOUT", "120")
+ monkeypatch.setenv("TEST_APP_ENABLED", "false")
+
+ settings = load_settings(SimpleSettings, env_prefix="TEST_APP_")
+
+ assert settings["api_key"] == "test-key-123"
+ assert settings["timeout"] == 120
+ assert settings["enabled"] is False
+
+ def test_overrides_beat_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("TEST_APP_TIMEOUT", "120")
+
+ settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", timeout=60)
+
+ assert settings["timeout"] == 60
+
+ def test_no_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("API_KEY", "no-prefix-key")
+
+ settings = load_settings(SimpleSettings, api_key=None)
+
+ assert settings["api_key"] == "no-prefix-key"
+
+
+class TestDotenvFile:
+ """Test .env file loading."""
+
+ def test_load_from_dotenv(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("TEST_APP_API_KEY", raising=False)
+ monkeypatch.delenv("TEST_APP_TIMEOUT", raising=False)
+
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
+ f.write("TEST_APP_API_KEY=dotenv-key\n")
+ f.write("TEST_APP_TIMEOUT=90\n")
+ f.flush()
+ env_path = f.name
+
+ try:
+ settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", env_file_path=env_path)
+
+ assert settings["api_key"] == "dotenv-key"
+ assert settings["timeout"] == 90
+ finally:
+ os.unlink(env_path)
+
+ def test_env_vars_override_dotenv(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("TEST_APP_API_KEY", "real-env-key")
+
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
+ f.write("TEST_APP_API_KEY=dotenv-key\n")
+ f.flush()
+ env_path = f.name
+
+ try:
+ settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", env_file_path=env_path)
+
+ assert settings["api_key"] == "real-env-key"
+ finally:
+ os.unlink(env_path)
+
+ def test_missing_dotenv_file(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("TEST_APP_API_KEY", raising=False)
+ settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", env_file_path="/nonexistent/.env")
+
+ assert settings["api_key"] is None
+
+
+class TestSecretString:
+ """Test SecretString type handling."""
+
+ def test_secretstring_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("SECRET_API_KEY", "secret-value")
+
+ settings = load_settings(SecretSettings, env_prefix="SECRET_")
+
+ assert isinstance(settings["api_key"], SecretString)
+ assert settings["api_key"] == "secret-value"
+
+ def test_secretstring_from_override(self) -> None:
+ settings = load_settings(SecretSettings, env_prefix="SECRET_", api_key="kwarg-secret")
+
+ assert isinstance(settings["api_key"], SecretString)
+ assert settings["api_key"] == "kwarg-secret"
+
+ def test_secretstring_masked_in_repr(self) -> None:
+ s = SecretString("my-secret")
+ assert "my-secret" not in repr(s)
+ assert "**********" in repr(s)
+
+ def test_get_secret_value_compat(self) -> None:
+ s = SecretString("my-secret")
+
+ assert s.get_secret_value() == "my-secret"
+ assert isinstance(s.get_secret_value(), str)
+
+
+class TestTypeCoercion:
+ """Test type coercion from string values."""
+
+ def test_int_coercion(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("TEST_APP_TIMEOUT", "42")
+
+ settings = load_settings(SimpleSettings, env_prefix="TEST_APP_")
+
+ assert settings["timeout"] == 42
+ assert isinstance(settings["timeout"], int)
+
+ def test_float_coercion(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("TEST_APP_RATE_LIMIT", "2.5")
+
+ settings = load_settings(SimpleSettings, env_prefix="TEST_APP_")
+
+ assert settings["rate_limit"] == 2.5
+ assert isinstance(settings["rate_limit"], float)
+
+ def test_bool_coercion_true_values(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ for true_val in ["true", "True", "TRUE", "1", "yes", "on"]:
+ monkeypatch.setenv("TEST_APP_ENABLED", true_val)
+ settings = load_settings(SimpleSettings, env_prefix="TEST_APP_")
+ assert settings["enabled"] is True, f"Failed for {true_val}"
+
+ def test_bool_coercion_false_values(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ for false_val in ["false", "False", "FALSE", "0", "no", "off"]:
+ monkeypatch.setenv("TEST_APP_ENABLED", false_val)
+ settings = load_settings(SimpleSettings, env_prefix="TEST_APP_")
+ assert settings["enabled"] is False, f"Failed for {false_val}"
+
+
+class TestRequiredFields:
+ """Test required field validation."""
+
+ def test_required_field_provided(self) -> None:
+ settings = load_settings(
+ RequiredFieldSettings,
+ env_prefix="TEST_",
+ required_fields=["name"],
+ name="my-app",
+ )
+
+ assert settings["name"] == "my-app"
+ assert settings["optional_field"] is None
+
+ def test_required_field_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("TEST_NAME", "env-app")
+
+ settings = load_settings(RequiredFieldSettings, env_prefix="TEST_", required_fields=["name"])
+
+ assert settings["name"] == "env-app"
+
+ def test_required_field_missing_raises(self) -> None:
+ from agent_framework.exceptions import SettingNotFoundError
+
+ with pytest.raises(SettingNotFoundError, match="Required setting 'name'"):
+ load_settings(RequiredFieldSettings, env_prefix="TEST_", required_fields=["name"])
+
+ def test_without_required_fields_param_allows_none(self) -> None:
+ settings = load_settings(RequiredFieldSettings, env_prefix="TEST_")
+
+ assert settings["name"] is None
+
+
+class TestOverrideTypeValidation:
+ """Test override type validation."""
+
+ def test_invalid_type_raises(self) -> None:
+ from agent_framework.exceptions import ServiceInitializationError
+
+ with pytest.raises(ServiceInitializationError, match="Invalid type for setting 'api_key'"):
+ load_settings(SimpleSettings, env_prefix="TEST_", api_key={"bad": "type"})
+
+ def test_valid_types_accepted(self) -> None:
+ settings = load_settings(SimpleSettings, env_prefix="TEST_", timeout=42, enabled=True)
+
+ assert settings["timeout"] == 42
+ assert settings["enabled"] is True
+
+ def test_str_accepted_for_secretstring(self) -> None:
+ settings = load_settings(SecretSettings, env_prefix="TEST_", api_key="plain-string")
+
+ assert isinstance(settings["api_key"], SecretString)
+ assert settings["api_key"] == "plain-string"
diff --git a/python/packages/core/tests/openai/test_assistant_provider.py b/python/packages/core/tests/openai/test_assistant_provider.py
index 8a2b561d77..a9dbb039b6 100644
--- a/python/packages/core/tests/openai/test_assistant_provider.py
+++ b/python/packages/core/tests/openai/test_assistant_provider.py
@@ -131,9 +131,15 @@ class TestOpenAIAssistantProviderInit:
"""Test initialization fails without API key when settings return None."""
from unittest.mock import patch
- # Mock OpenAISettings to return None for api_key
- with patch("agent_framework.openai._assistant_provider.OpenAISettings") as mock_settings:
- mock_settings.return_value.api_key = None
+ # Mock load_settings to return a dict with None for api_key
+ with patch("agent_framework.openai._assistant_provider.load_settings") as mock_load:
+ mock_load.return_value = {
+ "api_key": None,
+ "org_id": None,
+ "base_url": None,
+ "chat_model_id": None,
+ "responses_model_id": None,
+ }
with pytest.raises(ServiceInitializationError) as exc_info:
OpenAIAssistantProvider()
diff --git a/python/packages/core/tests/openai/test_openai_assistants_client.py b/python/packages/core/tests/openai/test_openai_assistants_client.py
index 2bf56a94aa..c50b026cb7 100644
--- a/python/packages/core/tests/openai/test_openai_assistants_client.py
+++ b/python/packages/core/tests/openai/test_openai_assistants_client.py
@@ -146,7 +146,7 @@ def test_init_auto_create_client(
def test_init_validation_fail() -> None:
"""Test OpenAIAssistantsClient initialization with validation failure."""
with pytest.raises(ServiceInitializationError):
- # Force failure by providing invalid model ID type - this should cause validation to fail
+ # Force failure by providing invalid model ID type
OpenAIAssistantsClient(model_id=123, api_key="valid-key") # type: ignore
diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py
index d3cef6f1fa..b4f431fd84 100644
--- a/python/packages/core/tests/workflow/test_agent_executor.py
+++ b/python/packages/core/tests/workflow/test_agent_executor.py
@@ -84,16 +84,17 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
assert initial_agent.call_count == 1
# Verify checkpoint was created
- checkpoints = await storage.list_checkpoints()
- assert len(checkpoints) > 0
-
- # Find a suitable checkpoint to restore (prefer superstep checkpoint)
- checkpoints.sort(key=lambda cp: cp.timestamp)
- restore_checkpoint = next(
- (cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"),
- checkpoints[-1],
+ checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
+ assert len(checkpoints) >= 2, (
+ "Expected at least 2 checkpoints. The first one is after the start executor, "
+ "and the second one is after the agent execution."
)
+ # Get the second checkpoint which should contain the state after processing
+ # the first message by the start executor in the sequential workflow
+ checkpoints.sort(key=lambda cp: cp.timestamp)
+ restore_checkpoint = checkpoints[1]
+
# Verify checkpoint contains executor state with both cache and thread
assert "_executor_state" in restore_checkpoint.state
executor_states = restore_checkpoint.state["_executor_state"]
diff --git a/python/packages/core/tests/workflow/test_checkpoint.py b/python/packages/core/tests/workflow/test_checkpoint.py
index 9f6d57b2e1..b05d625502 100644
--- a/python/packages/core/tests/workflow/test_checkpoint.py
+++ b/python/packages/core/tests/workflow/test_checkpoint.py
@@ -2,21 +2,66 @@
import json
import tempfile
+from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
+import pytest
+
from agent_framework import (
FileCheckpointStorage,
InMemoryCheckpointStorage,
WorkflowCheckpoint,
+ WorkflowCheckpointException,
+ WorkflowEvent,
)
+from agent_framework._workflows._runner_context import WorkflowMessage
+
+
+# Module-level dataclasses for pickle serialization in roundtrip tests
+@dataclass
+class _TestToolApprovalRequest:
+ """Request data for tool approval in tests."""
+
+ tool_name: str
+ arguments: dict
+ timestamp: datetime
+
+
+@dataclass
+class _TestExecutorState:
+ """Executor state for tests."""
+
+ counter: int
+ history: list[str]
+
+
+@dataclass
+class _TestApprovalRequest:
+ """Approval request data for tests."""
+
+ action: str
+ params: tuple
+
+
+@dataclass
+class _TestCustomData:
+ """Custom data for tests."""
+
+ name: str
+ value: int
+ tags: list[str]
+
+
+# region test WorkflowCheckpoint
def test_workflow_checkpoint_default_values():
- checkpoint = WorkflowCheckpoint()
+ checkpoint = WorkflowCheckpoint(workflow_name="test-workflow", graph_signature_hash="test-hash")
assert checkpoint.checkpoint_id != ""
- assert checkpoint.workflow_id == ""
+ assert checkpoint.workflow_name == "test-workflow"
+ assert checkpoint.graph_signature_hash == "test-hash"
assert checkpoint.timestamp != ""
assert checkpoint.messages == {}
assert checkpoint.state == {}
@@ -30,7 +75,8 @@ def test_workflow_checkpoint_custom_values():
custom_timestamp = datetime.now(timezone.utc).isoformat()
checkpoint = WorkflowCheckpoint(
checkpoint_id="test-checkpoint-123",
- workflow_id="test-workflow-456",
+ workflow_name="test-workflow-456",
+ graph_signature_hash="test-hash-456",
timestamp=custom_timestamp,
messages={"executor1": [{"data": "test"}]},
pending_request_info_events={"req123": {"data": "test"}},
@@ -41,7 +87,8 @@ def test_workflow_checkpoint_custom_values():
)
assert checkpoint.checkpoint_id == "test-checkpoint-123"
- assert checkpoint.workflow_id == "test-workflow-456"
+ assert checkpoint.workflow_name == "test-workflow-456"
+ assert checkpoint.graph_signature_hash == "test-hash-456"
assert checkpoint.timestamp == custom_timestamp
assert checkpoint.messages == {"executor1": [{"data": "test"}]}
assert checkpoint.state == {"key": "value"}
@@ -51,23 +98,83 @@ def test_workflow_checkpoint_custom_values():
assert checkpoint.version == "2.0"
+def test_workflow_checkpoint_to_dict():
+ checkpoint = WorkflowCheckpoint(
+ checkpoint_id="test-id",
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ messages={"executor1": [{"data": "test"}]},
+ state={"key": "value"},
+ iteration_count=5,
+ )
+
+ result = checkpoint.to_dict()
+
+ assert result["checkpoint_id"] == "test-id"
+ assert result["workflow_name"] == "test-workflow"
+ assert result["graph_signature_hash"] == "test-hash"
+ assert result["messages"] == {"executor1": [{"data": "test"}]}
+ assert result["state"] == {"key": "value"}
+ assert result["iteration_count"] == 5
+
+
+def test_workflow_checkpoint_previous_checkpoint_id():
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ previous_checkpoint_id="previous-id-123",
+ )
+
+ assert checkpoint.previous_checkpoint_id == "previous-id-123"
+
+
+# endregion
+
+# region InMemoryCheckpointStorage
+
+
+def test_checkpoint_storage_protocol_compliance():
+ # This test ensures both implementations have all required methods
+ memory_storage = InMemoryCheckpointStorage()
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ file_storage = FileCheckpointStorage(temp_dir)
+
+ for storage in [memory_storage, file_storage]:
+ # Test that all protocol methods exist and are callable
+ assert hasattr(storage, "save")
+ assert callable(storage.save)
+ assert hasattr(storage, "load")
+ assert callable(storage.load)
+ assert hasattr(storage, "list_checkpoints")
+ assert callable(storage.list_checkpoints)
+ assert hasattr(storage, "delete")
+ assert callable(storage.delete)
+ assert hasattr(storage, "list_checkpoint_ids")
+ assert callable(storage.list_checkpoint_ids)
+ assert hasattr(storage, "get_latest")
+ assert callable(storage.get_latest)
+
+
async def test_memory_checkpoint_storage_save_and_load():
storage = InMemoryCheckpointStorage()
checkpoint = WorkflowCheckpoint(
- workflow_id="test-workflow",
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
messages={"executor1": [{"data": "hello"}]},
pending_request_info_events={"req123": {"data": "test"}},
)
# Save checkpoint
- saved_id = await storage.save_checkpoint(checkpoint)
+ saved_id = await storage.save(checkpoint)
assert saved_id == checkpoint.checkpoint_id
# Load checkpoint
- loaded_checkpoint = await storage.load_checkpoint(checkpoint.checkpoint_id)
+ loaded_checkpoint = await storage.load(checkpoint.checkpoint_id)
assert loaded_checkpoint is not None
assert loaded_checkpoint.checkpoint_id == checkpoint.checkpoint_id
- assert loaded_checkpoint.workflow_id == checkpoint.workflow_id
+ assert loaded_checkpoint.workflow_name == checkpoint.workflow_name
+ assert loaded_checkpoint.graph_signature_hash == checkpoint.graph_signature_hash
assert loaded_checkpoint.messages == checkpoint.messages
assert loaded_checkpoint.pending_request_info_events == checkpoint.pending_request_info_events
@@ -75,96 +182,607 @@ async def test_memory_checkpoint_storage_save_and_load():
async def test_memory_checkpoint_storage_load_nonexistent():
storage = InMemoryCheckpointStorage()
- result = await storage.load_checkpoint("nonexistent-id")
- assert result is None
+ with pytest.raises(WorkflowCheckpointException):
+ await storage.load("nonexistent-id")
-async def test_memory_checkpoint_storage_list_checkpoints():
+async def test_memory_checkpoint_storage_list():
storage = InMemoryCheckpointStorage()
# Create checkpoints for different workflows
- checkpoint1 = WorkflowCheckpoint(workflow_id="workflow-1")
- checkpoint2 = WorkflowCheckpoint(workflow_id="workflow-1")
- checkpoint3 = WorkflowCheckpoint(workflow_id="workflow-2")
+ checkpoint1 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-1")
+ checkpoint2 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-2")
+ checkpoint3 = WorkflowCheckpoint(workflow_name="workflow-2", graph_signature_hash="hash-3")
- await storage.save_checkpoint(checkpoint1)
- await storage.save_checkpoint(checkpoint2)
- await storage.save_checkpoint(checkpoint3)
+ await storage.save(checkpoint1)
+ await storage.save(checkpoint2)
+ await storage.save(checkpoint3)
- # Test list_checkpoint_ids for workflow-1
- workflow1_checkpoint_ids = await storage.list_checkpoint_ids("workflow-1")
+ # Test list_ids for workflow-1
+ workflow1_checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="workflow-1")
assert len(workflow1_checkpoint_ids) == 2
assert checkpoint1.checkpoint_id in workflow1_checkpoint_ids
assert checkpoint2.checkpoint_id in workflow1_checkpoint_ids
- # Test list_checkpoints for workflow-1 (returns objects)
- workflow1_checkpoints = await storage.list_checkpoints("workflow-1")
+ # Test list for workflow-1 (returns objects)
+ workflow1_checkpoints = await storage.list_checkpoints(workflow_name="workflow-1")
assert len(workflow1_checkpoints) == 2
assert all(isinstance(cp, WorkflowCheckpoint) for cp in workflow1_checkpoints)
assert {cp.checkpoint_id for cp in workflow1_checkpoints} == {checkpoint1.checkpoint_id, checkpoint2.checkpoint_id}
- # Test list_checkpoint_ids for workflow-2
- workflow2_checkpoint_ids = await storage.list_checkpoint_ids("workflow-2")
+ # Test list_ids for workflow-2
+ workflow2_checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="workflow-2")
assert len(workflow2_checkpoint_ids) == 1
assert checkpoint3.checkpoint_id in workflow2_checkpoint_ids
- # Test list_checkpoints for workflow-2 (returns objects)
- workflow2_checkpoints = await storage.list_checkpoints("workflow-2")
+ # Test list for workflow-2 (returns objects)
+ workflow2_checkpoints = await storage.list_checkpoints(workflow_name="workflow-2")
assert len(workflow2_checkpoints) == 1
assert workflow2_checkpoints[0].checkpoint_id == checkpoint3.checkpoint_id
- # Test list_checkpoint_ids for non-existent workflow
- empty_checkpoint_ids = await storage.list_checkpoint_ids("nonexistent-workflow")
+ # Test list_ids for non-existent workflow
+ empty_checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="nonexistent-workflow")
assert len(empty_checkpoint_ids) == 0
- # Test list_checkpoints for non-existent workflow
- empty_checkpoints = await storage.list_checkpoints("nonexistent-workflow")
+ # Test list for non-existent workflow
+ empty_checkpoints = await storage.list_checkpoints(workflow_name="nonexistent-workflow")
assert len(empty_checkpoints) == 0
- # Test list_checkpoint_ids without workflow filter (all checkpoints)
- all_checkpoint_ids = await storage.list_checkpoint_ids()
- assert len(all_checkpoint_ids) == 3
- expected_ids = {checkpoint1.checkpoint_id, checkpoint2.checkpoint_id, checkpoint3.checkpoint_id}
- assert expected_ids.issubset(set(all_checkpoint_ids))
-
- # Test list_checkpoints without workflow filter (all checkpoints)
- all_checkpoints = await storage.list_checkpoints()
- assert len(all_checkpoints) == 3
- assert all(isinstance(cp, WorkflowCheckpoint) for cp in all_checkpoints)
-
async def test_memory_checkpoint_storage_delete():
storage = InMemoryCheckpointStorage()
- checkpoint = WorkflowCheckpoint(workflow_id="test-workflow")
+ checkpoint = WorkflowCheckpoint(workflow_name="test-workflow", graph_signature_hash="test-hash")
# Save checkpoint
- await storage.save_checkpoint(checkpoint)
- assert await storage.load_checkpoint(checkpoint.checkpoint_id) is not None
+ await storage.save(checkpoint)
+ assert await storage.load(checkpoint.checkpoint_id) is not None
# Delete checkpoint
- result = await storage.delete_checkpoint(checkpoint.checkpoint_id)
+ result = await storage.delete(checkpoint.checkpoint_id)
assert result is True
# Verify deletion
- assert await storage.load_checkpoint(checkpoint.checkpoint_id) is None
+ with pytest.raises(WorkflowCheckpointException):
+ await storage.load(checkpoint.checkpoint_id)
# Try to delete again
- result = await storage.delete_checkpoint(checkpoint.checkpoint_id)
+ result = await storage.delete(checkpoint.checkpoint_id)
assert result is False
+async def test_memory_checkpoint_storage_get_latest():
+ import asyncio
+
+ storage = InMemoryCheckpointStorage()
+
+ # Create checkpoints with small delays to ensure different timestamps
+ checkpoint1 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-1")
+ await asyncio.sleep(0.01)
+ checkpoint2 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-2")
+ await asyncio.sleep(0.01)
+ checkpoint3 = WorkflowCheckpoint(workflow_name="workflow-2", graph_signature_hash="hash-3")
+
+ await storage.save(checkpoint1)
+ await storage.save(checkpoint2)
+ await storage.save(checkpoint3)
+
+ # Test get_latest for workflow-1
+ latest = await storage.get_latest(workflow_name="workflow-1")
+ assert latest is not None
+ assert latest.checkpoint_id == checkpoint2.checkpoint_id
+
+ # Test get_latest for workflow-2
+ latest2 = await storage.get_latest(workflow_name="workflow-2")
+ assert latest2 is not None
+ assert latest2.checkpoint_id == checkpoint3.checkpoint_id
+
+ # Test get_latest for non-existent workflow
+ latest_none = await storage.get_latest(workflow_name="nonexistent-workflow")
+ assert latest_none is None
+
+
+async def test_workflow_checkpoint_chaining_via_previous_checkpoint_id():
+ """Test that consecutive checkpoints created by a workflow are properly chained via previous_checkpoint_id."""
+ from typing_extensions import Never
+
+ from agent_framework import WorkflowBuilder, WorkflowContext, handler
+ from agent_framework._workflows._executor import Executor
+
+ class StartExecutor(Executor):
+ @handler
+ async def run(self, message: str, ctx: WorkflowContext[str]) -> None:
+ await ctx.send_message(message, target_id="middle")
+
+ class MiddleExecutor(Executor):
+ @handler
+ async def process(self, message: str, ctx: WorkflowContext[str]) -> None:
+ await ctx.send_message(message + "-processed", target_id="finish")
+
+ class FinishExecutor(Executor):
+ @handler
+ async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None:
+ await ctx.yield_output(message + "-done")
+
+ storage = InMemoryCheckpointStorage()
+
+ start = StartExecutor(id="start")
+ middle = MiddleExecutor(id="middle")
+ finish = FinishExecutor(id="finish")
+
+ workflow = (
+ WorkflowBuilder(max_iterations=10, start_executor=start, checkpoint_storage=storage)
+ .add_edge(start, middle)
+ .add_edge(middle, finish)
+ .build()
+ )
+
+ # Run workflow - this creates checkpoints at each superstep
+ _ = [event async for event in workflow.run("hello", stream=True)]
+
+ # Get all checkpoints sorted by timestamp
+ checkpoints = sorted(await storage.list_checkpoints(workflow_name=workflow.name), key=lambda c: c.timestamp)
+
+ # Should have multiple checkpoints (one initial + one per superstep)
+ assert len(checkpoints) >= 2, f"Expected at least 2 checkpoints, got {len(checkpoints)}"
+
+ # Verify chaining: first checkpoint has no previous
+ assert checkpoints[0].previous_checkpoint_id is None
+
+ # Subsequent checkpoints should chain to the previous one
+ for i in range(1, len(checkpoints)):
+ assert checkpoints[i].previous_checkpoint_id == checkpoints[i - 1].checkpoint_id, (
+ f"Checkpoint {i} should chain to checkpoint {i - 1}"
+ )
+
+
+async def test_memory_checkpoint_storage_roundtrip_json_native_types():
+ """Test that JSON-native types (str, int, float, bool, None) roundtrip correctly."""
+ storage = InMemoryCheckpointStorage()
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ state={
+ "string": "hello world",
+ "integer": 42,
+ "negative_int": -100,
+ "float": 3.14159,
+ "negative_float": -2.71828,
+ "bool_true": True,
+ "bool_false": False,
+ "null_value": None,
+ "zero": 0,
+ "empty_string": "",
+ },
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ assert loaded.state == checkpoint.state
+
+
+async def test_memory_checkpoint_storage_roundtrip_datetime():
+ """Test that datetime objects roundtrip correctly."""
+ storage = InMemoryCheckpointStorage()
+
+ now = datetime.now(timezone.utc)
+ specific_datetime = datetime(2025, 6, 15, 10, 30, 45, 123456, tzinfo=timezone.utc)
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ state={
+ "current_time": now,
+ "specific_time": specific_datetime,
+ "nested": {"created_at": now, "updated_at": specific_datetime},
+ },
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ assert loaded.state["current_time"] == now
+ assert loaded.state["specific_time"] == specific_datetime
+ assert loaded.state["nested"]["created_at"] == now
+ assert loaded.state["nested"]["updated_at"] == specific_datetime
+
+
+async def test_memory_checkpoint_storage_roundtrip_dataclass():
+ """Test that dataclass objects roundtrip correctly."""
+ storage = InMemoryCheckpointStorage()
+
+ custom_obj = _TestCustomData(name="test", value=42, tags=["a", "b", "c"])
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ state={
+ "custom_data": custom_obj,
+ "nested": {"inner_data": custom_obj},
+ },
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ assert loaded.state["custom_data"] == custom_obj
+ assert loaded.state["custom_data"].name == "test"
+ assert loaded.state["custom_data"].value == 42
+ assert loaded.state["custom_data"].tags == ["a", "b", "c"]
+ assert loaded.state["nested"]["inner_data"] == custom_obj
+ assert isinstance(loaded.state["custom_data"], _TestCustomData)
+
+
+async def test_memory_checkpoint_storage_roundtrip_tuple_and_set():
+ """Test that tuples and frozensets roundtrip correctly (type preserved in memory)."""
+ storage = InMemoryCheckpointStorage()
+
+ original_tuple = (1, "two", 3.0, None)
+ original_frozenset = frozenset({1, 2, 3})
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ state={
+ "my_tuple": original_tuple,
+ "my_frozenset": original_frozenset,
+ "nested_tuple": {"inner": (10, 20, 30)},
+ },
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ # In-memory storage preserves exact types (no JSON serialization)
+ assert loaded.state["my_tuple"] == original_tuple
+ assert isinstance(loaded.state["my_tuple"], tuple)
+ assert loaded.state["my_frozenset"] == original_frozenset
+ assert isinstance(loaded.state["my_frozenset"], frozenset)
+ assert loaded.state["nested_tuple"]["inner"] == (10, 20, 30)
+ assert isinstance(loaded.state["nested_tuple"]["inner"], tuple)
+
+
+async def test_memory_checkpoint_storage_roundtrip_complex_nested_structures():
+ """Test complex nested structures with mixed types roundtrip correctly."""
+ storage = InMemoryCheckpointStorage()
+
+ # Create complex nested structure mixing JSON-native and non-native types
+ complex_state = {
+ "level1": {
+ "level2": {
+ "level3": {
+ "deep_string": "hello",
+ "deep_int": 123,
+ "deep_datetime": datetime(2025, 1, 1, tzinfo=timezone.utc),
+ "deep_tuple": (1, 2, 3),
+ }
+ },
+ "list_of_dicts": [
+ {"a": 1, "b": datetime(2025, 2, 1, tzinfo=timezone.utc)},
+ {"c": 2, "d": (4, 5, 6)},
+ ],
+ },
+ "mixed_list": [
+ "string",
+ 42,
+ 3.14,
+ True,
+ None,
+ datetime(2025, 3, 1, tzinfo=timezone.utc),
+ (7, 8, 9),
+ ],
+ }
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ state=complex_state,
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ # Verify deep nested values
+ assert loaded.state["level1"]["level2"]["level3"]["deep_string"] == "hello"
+ assert loaded.state["level1"]["level2"]["level3"]["deep_int"] == 123
+ assert loaded.state["level1"]["level2"]["level3"]["deep_datetime"] == datetime(2025, 1, 1, tzinfo=timezone.utc)
+ assert loaded.state["level1"]["level2"]["level3"]["deep_tuple"] == (1, 2, 3)
+ assert isinstance(loaded.state["level1"]["level2"]["level3"]["deep_tuple"], tuple)
+
+ # Verify list of dicts
+ assert loaded.state["level1"]["list_of_dicts"][0]["a"] == 1
+ assert loaded.state["level1"]["list_of_dicts"][0]["b"] == datetime(2025, 2, 1, tzinfo=timezone.utc)
+ assert loaded.state["level1"]["list_of_dicts"][1]["d"] == (4, 5, 6)
+ assert isinstance(loaded.state["level1"]["list_of_dicts"][1]["d"], tuple)
+
+ # Verify mixed list with correct types
+ assert loaded.state["mixed_list"][0] == "string"
+ assert loaded.state["mixed_list"][1] == 42
+ assert loaded.state["mixed_list"][5] == datetime(2025, 3, 1, tzinfo=timezone.utc)
+ assert loaded.state["mixed_list"][6] == (7, 8, 9)
+ assert isinstance(loaded.state["mixed_list"][6], tuple)
+
+
+async def test_memory_checkpoint_storage_roundtrip_messages_with_complex_data():
+ """Test that messages dict with Message objects roundtrips correctly."""
+ storage = InMemoryCheckpointStorage()
+
+ msg1 = WorkflowMessage(
+ data={"text": "hello", "timestamp": datetime(2025, 1, 1, tzinfo=timezone.utc)},
+ source_id="source",
+ target_id="target",
+ )
+ msg2 = WorkflowMessage(
+ data=(1, 2, 3),
+ source_id="s2",
+ target_id=None,
+ )
+ msg3 = WorkflowMessage(
+ data="simple string",
+ source_id="s3",
+ target_id="t3",
+ )
+
+ messages = {
+ "executor1": [msg1, msg2],
+ "executor2": [msg3],
+ }
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ messages=messages,
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ # Verify messages structure and types
+ assert len(loaded.messages["executor1"]) == 2
+ loaded_msg1 = loaded.messages["executor1"][0]
+ loaded_msg2 = loaded.messages["executor1"][1]
+ loaded_msg3 = loaded.messages["executor2"][0]
+
+ # Verify Message type is preserved
+ assert isinstance(loaded_msg1, WorkflowMessage)
+ assert isinstance(loaded_msg2, WorkflowMessage)
+ assert isinstance(loaded_msg3, WorkflowMessage)
+
+ # Verify Message fields
+ assert loaded_msg1.data["text"] == "hello"
+ assert loaded_msg1.data["timestamp"] == datetime(2025, 1, 1, tzinfo=timezone.utc)
+ assert loaded_msg1.source_id == "source"
+ assert loaded_msg1.target_id == "target"
+
+ assert loaded_msg2.data == (1, 2, 3)
+ assert isinstance(loaded_msg2.data, tuple)
+ assert loaded_msg2.source_id == "s2"
+ assert loaded_msg2.target_id is None
+
+ assert loaded_msg3.data == "simple string"
+ assert loaded_msg3.source_id == "s3"
+ assert loaded_msg3.target_id == "t3"
+
+
+async def test_memory_checkpoint_storage_roundtrip_pending_request_info_events():
+ """Test that pending_request_info_events with WorkflowEvent objects roundtrip correctly."""
+ storage = InMemoryCheckpointStorage()
+
+ # Create request_info events using the proper WorkflowEvent factory
+ event1 = WorkflowEvent.request_info(
+ request_id="req123",
+ source_executor_id="executor1",
+ request_data="What is your name?",
+ response_type=str,
+ )
+ event2 = WorkflowEvent.request_info(
+ request_id="req456",
+ source_executor_id="executor2",
+ request_data=_TestToolApprovalRequest(
+ tool_name="search",
+ arguments={"query": "test"},
+ timestamp=datetime(2025, 1, 1, tzinfo=timezone.utc),
+ ),
+ response_type=bool,
+ )
+
+ pending_events = {
+ "req123": event1,
+ "req456": event2,
+ }
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ pending_request_info_events=pending_events,
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ # Verify WorkflowEvent type is preserved
+ loaded_event1 = loaded.pending_request_info_events["req123"]
+ loaded_event2 = loaded.pending_request_info_events["req456"]
+
+ assert isinstance(loaded_event1, WorkflowEvent)
+ assert isinstance(loaded_event2, WorkflowEvent)
+
+ # Verify event1 fields
+ assert loaded_event1.type == "request_info"
+ assert loaded_event1.request_id == "req123"
+ assert loaded_event1.source_executor_id == "executor1"
+ assert loaded_event1.data == "What is your name?"
+ assert loaded_event1.response_type is str
+
+ # Verify event2 fields with complex data
+ assert loaded_event2.type == "request_info"
+ assert loaded_event2.request_id == "req456"
+ assert loaded_event2.source_executor_id == "executor2"
+ assert isinstance(loaded_event2.data, _TestToolApprovalRequest)
+ assert loaded_event2.data.tool_name == "search"
+ assert loaded_event2.data.arguments == {"query": "test"}
+ assert loaded_event2.data.timestamp == datetime(2025, 1, 1, tzinfo=timezone.utc)
+ assert loaded_event2.response_type is bool
+
+
+async def test_memory_checkpoint_storage_roundtrip_full_checkpoint():
+ """Test complete WorkflowCheckpoint roundtrip with all fields populated using proper types."""
+ storage = InMemoryCheckpointStorage()
+
+ # Create proper WorkflowMessage objects
+ msg1 = WorkflowMessage(data="msg1", source_id="s", target_id="t")
+ msg2 = WorkflowMessage(data=datetime(2025, 1, 1, tzinfo=timezone.utc), source_id="a", target_id="b")
+
+ # Create proper WorkflowEvent for pending request
+ pending_event = WorkflowEvent.request_info(
+ request_id="req1",
+ source_executor_id="exec1",
+ request_data=_TestApprovalRequest(action="approve", params=(1, 2, 3)),
+ response_type=bool,
+ )
+
+ checkpoint = WorkflowCheckpoint(
+ checkpoint_id="full-test-checkpoint",
+ workflow_name="comprehensive-test",
+ graph_signature_hash="hash-abc123",
+ previous_checkpoint_id="previous-checkpoint-id",
+ timestamp=datetime(2025, 6, 15, 12, 0, 0, tzinfo=timezone.utc).isoformat(),
+ messages={
+ "exec1": [msg1],
+ "exec2": [msg2],
+ },
+ state={
+ "user_data": {"name": "test", "created": datetime(2025, 1, 1, tzinfo=timezone.utc)},
+ "_executor_state": {
+ "exec1": _TestExecutorState(counter=5, history=["a", "b", "c"]),
+ },
+ },
+ pending_request_info_events={
+ "req1": pending_event,
+ },
+ iteration_count=10,
+ metadata={
+ "superstep": 5,
+ "started_at": datetime(2025, 6, 15, 11, 0, 0, tzinfo=timezone.utc),
+ },
+ version="1.0",
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ # Verify all scalar fields
+ assert loaded.checkpoint_id == checkpoint.checkpoint_id
+ assert loaded.workflow_name == checkpoint.workflow_name
+ assert loaded.graph_signature_hash == checkpoint.graph_signature_hash
+ assert loaded.previous_checkpoint_id == checkpoint.previous_checkpoint_id
+ assert loaded.timestamp == checkpoint.timestamp
+ assert loaded.iteration_count == checkpoint.iteration_count
+ assert loaded.version == checkpoint.version
+
+ # Verify complex nested state data
+ assert loaded.state["user_data"]["created"] == datetime(2025, 1, 1, tzinfo=timezone.utc)
+ assert loaded.state["_executor_state"]["exec1"].counter == 5
+ assert loaded.state["_executor_state"]["exec1"].history == ["a", "b", "c"]
+ assert isinstance(loaded.state["_executor_state"]["exec1"], _TestExecutorState)
+
+ # Verify messages are proper Message objects
+ loaded_msg1 = loaded.messages["exec1"][0]
+ loaded_msg2 = loaded.messages["exec2"][0]
+ assert isinstance(loaded_msg1, WorkflowMessage)
+ assert isinstance(loaded_msg2, WorkflowMessage)
+ assert loaded_msg1.data == "msg1"
+ assert loaded_msg1.source_id == "s"
+ assert loaded_msg2.data == datetime(2025, 1, 1, tzinfo=timezone.utc)
+
+ # Verify pending events are proper WorkflowEvent objects
+ loaded_event = loaded.pending_request_info_events["req1"]
+ assert isinstance(loaded_event, WorkflowEvent)
+ assert loaded_event.type == "request_info"
+ assert loaded_event.request_id == "req1"
+ assert isinstance(loaded_event.data, _TestApprovalRequest)
+ assert loaded_event.data.params == (1, 2, 3)
+
+ # Verify metadata
+ assert loaded.metadata["superstep"] == 5
+ assert loaded.metadata["started_at"] == datetime(2025, 6, 15, 11, 0, 0, tzinfo=timezone.utc)
+
+
+async def test_memory_checkpoint_storage_roundtrip_bytes():
+ """Test that bytes objects roundtrip correctly."""
+ storage = InMemoryCheckpointStorage()
+
+ binary_data = b"\x00\x01\x02\xff\xfe\xfd"
+ unicode_bytes = "Hello 世界".encode()
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ state={
+ "binary_data": binary_data,
+ "unicode_bytes": unicode_bytes,
+ "nested": {"inner_bytes": binary_data},
+ },
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ assert loaded.state["binary_data"] == binary_data
+ assert loaded.state["unicode_bytes"] == unicode_bytes
+ assert loaded.state["nested"]["inner_bytes"] == binary_data
+ assert isinstance(loaded.state["binary_data"], bytes)
+
+
+async def test_memory_checkpoint_storage_roundtrip_empty_collections():
+ """Test that empty collections roundtrip correctly (types preserved in memory)."""
+ storage = InMemoryCheckpointStorage()
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ state={
+ "empty_dict": {},
+ "empty_list": [],
+ "empty_tuple": (),
+ "nested_empty": {"inner_dict": {}, "inner_list": []},
+ },
+ messages={},
+ pending_request_info_events={},
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ assert loaded.state["empty_dict"] == {}
+ assert loaded.state["empty_list"] == []
+ # In-memory storage preserves exact types (no JSON serialization)
+ assert loaded.state["empty_tuple"] == ()
+ assert isinstance(loaded.state["empty_tuple"], tuple)
+ assert loaded.state["nested_empty"]["inner_dict"] == {}
+ assert loaded.messages == {}
+ assert loaded.pending_request_info_events == {}
+
+
+# endregion
+
+# region FileCheckpointStorage
+
+
async def test_file_checkpoint_storage_save_and_load():
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
checkpoint = WorkflowCheckpoint(
- workflow_id="test-workflow",
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
messages={"executor1": [{"data": "hello", "source_id": "test", "target_id": None}]},
state={"key": "value"},
pending_request_info_events={"req123": {"data": "test"}},
)
# Save checkpoint
- saved_id = await storage.save_checkpoint(checkpoint)
+ saved_id = await storage.save(checkpoint)
assert saved_id == checkpoint.checkpoint_id
# Verify file was created
@@ -172,10 +790,11 @@ async def test_file_checkpoint_storage_save_and_load():
assert file_path.exists()
# Load checkpoint
- loaded_checkpoint = await storage.load_checkpoint(checkpoint.checkpoint_id)
+ loaded_checkpoint = await storage.load(checkpoint.checkpoint_id)
assert loaded_checkpoint is not None
assert loaded_checkpoint.checkpoint_id == checkpoint.checkpoint_id
- assert loaded_checkpoint.workflow_id == checkpoint.workflow_id
+ assert loaded_checkpoint.workflow_name == checkpoint.workflow_name
+ assert loaded_checkpoint.graph_signature_hash == checkpoint.graph_signature_hash
assert loaded_checkpoint.messages == checkpoint.messages
assert loaded_checkpoint.state == checkpoint.state
assert loaded_checkpoint.pending_request_info_events == checkpoint.pending_request_info_events
@@ -185,72 +804,64 @@ async def test_file_checkpoint_storage_load_nonexistent():
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
- result = await storage.load_checkpoint("nonexistent-id")
- assert result is None
+ with pytest.raises(WorkflowCheckpointException):
+ await storage.load("nonexistent-id")
-async def test_file_checkpoint_storage_list_checkpoints():
+async def test_file_checkpoint_storage_list():
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
# Create checkpoints for different workflows
- checkpoint1 = WorkflowCheckpoint(workflow_id="workflow-1")
- checkpoint2 = WorkflowCheckpoint(workflow_id="workflow-1")
- checkpoint3 = WorkflowCheckpoint(workflow_id="workflow-2")
+ checkpoint1 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-1")
+ checkpoint2 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-2")
+ checkpoint3 = WorkflowCheckpoint(workflow_name="workflow-2", graph_signature_hash="hash-3")
- await storage.save_checkpoint(checkpoint1)
- await storage.save_checkpoint(checkpoint2)
- await storage.save_checkpoint(checkpoint3)
+ await storage.save(checkpoint1)
+ await storage.save(checkpoint2)
+ await storage.save(checkpoint3)
- # Test list_checkpoint_ids for workflow-1
- workflow1_checkpoint_ids = await storage.list_checkpoint_ids("workflow-1")
+ # Test list_ids for workflow-1
+ workflow1_checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="workflow-1")
assert len(workflow1_checkpoint_ids) == 2
assert checkpoint1.checkpoint_id in workflow1_checkpoint_ids
assert checkpoint2.checkpoint_id in workflow1_checkpoint_ids
- # Test list_checkpoints for workflow-1 (returns objects)
- workflow1_checkpoints = await storage.list_checkpoints("workflow-1")
+ # Test list for workflow-1 (returns objects)
+ workflow1_checkpoints = await storage.list_checkpoints(workflow_name="workflow-1")
assert len(workflow1_checkpoints) == 2
assert all(isinstance(cp, WorkflowCheckpoint) for cp in workflow1_checkpoints)
checkpoint_ids = {cp.checkpoint_id for cp in workflow1_checkpoints}
assert checkpoint_ids == {checkpoint1.checkpoint_id, checkpoint2.checkpoint_id}
- # Test list_checkpoint_ids for workflow-2
- workflow2_checkpoint_ids = await storage.list_checkpoint_ids("workflow-2")
+ # Test list_ids for workflow-2
+ workflow2_checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="workflow-2")
assert len(workflow2_checkpoint_ids) == 1
assert checkpoint3.checkpoint_id in workflow2_checkpoint_ids
- # Test list_checkpoints for workflow-2 (returns objects)
- workflow2_checkpoints = await storage.list_checkpoints("workflow-2")
+ # Test list for workflow-2 (returns objects)
+ workflow2_checkpoints = await storage.list_checkpoints(workflow_name="workflow-2")
assert len(workflow2_checkpoints) == 1
assert workflow2_checkpoints[0].checkpoint_id == checkpoint3.checkpoint_id
- # Test list all checkpoints
- all_checkpoint_ids = await storage.list_checkpoint_ids()
- assert len(all_checkpoint_ids) == 3
-
- all_checkpoints = await storage.list_checkpoints()
- assert len(all_checkpoints) == 3
- assert all(isinstance(cp, WorkflowCheckpoint) for cp in all_checkpoints)
-
async def test_file_checkpoint_storage_delete():
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
- checkpoint = WorkflowCheckpoint(workflow_id="test-workflow")
+ checkpoint = WorkflowCheckpoint(workflow_name="test-workflow", graph_signature_hash="test-hash")
# Save checkpoint
- await storage.save_checkpoint(checkpoint)
+ await storage.save(checkpoint)
file_path = Path(temp_dir) / f"{checkpoint.checkpoint_id}.json"
assert file_path.exists()
# Delete checkpoint
- result = await storage.delete_checkpoint(checkpoint.checkpoint_id)
+ result = await storage.delete(checkpoint.checkpoint_id)
assert result is True
assert not file_path.exists()
# Try to delete again
- result = await storage.delete_checkpoint(checkpoint.checkpoint_id)
+ result = await storage.delete(checkpoint.checkpoint_id)
assert result is False
@@ -264,8 +875,8 @@ async def test_file_checkpoint_storage_directory_creation():
assert nested_path.is_dir()
# Should be able to save checkpoints
- checkpoint = WorkflowCheckpoint(workflow_id="test")
- await storage.save_checkpoint(checkpoint)
+ checkpoint = WorkflowCheckpoint(workflow_name="test-workflow", graph_signature_hash="test-hash")
+ await storage.save(checkpoint)
file_path = nested_path / f"{checkpoint.checkpoint_id}.json"
assert file_path.exists()
@@ -280,8 +891,8 @@ async def test_file_checkpoint_storage_corrupted_file():
with open(corrupted_file, "w") as f: # noqa: ASYNC230
f.write("{ invalid json }")
- # list_checkpoints should handle the corrupted file gracefully
- checkpoints = await storage.list_checkpoints("any-workflow")
+ # list should handle the corrupted file gracefully
+ checkpoints = await storage.list_checkpoints(workflow_name="any-workflow")
assert checkpoints == []
@@ -291,15 +902,16 @@ async def test_file_checkpoint_storage_json_serialization():
# Create checkpoint with complex nested data
checkpoint = WorkflowCheckpoint(
- workflow_id="complex-workflow",
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
messages={"executor1": [{"data": {"nested": {"value": 42}}, "source_id": "test", "target_id": None}]},
state={"list": [1, 2, 3], "dict": {"a": "b", "c": {"d": "e"}}, "bool": True, "null": None},
pending_request_info_events={"req123": {"data": "test"}},
)
# Save and load
- await storage.save_checkpoint(checkpoint)
- loaded = await storage.load_checkpoint(checkpoint.checkpoint_id)
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
assert loaded is not None
assert loaded.messages == checkpoint.messages
@@ -317,22 +929,513 @@ async def test_file_checkpoint_storage_json_serialization():
assert data["pending_request_info_events"]["req123"]["data"] == "test"
-def test_checkpoint_storage_protocol_compliance():
- # This test ensures both implementations have all required methods
- memory_storage = InMemoryCheckpointStorage()
+async def test_file_checkpoint_storage_get_latest():
+ import asyncio
with tempfile.TemporaryDirectory() as temp_dir:
- file_storage = FileCheckpointStorage(temp_dir)
+ storage = FileCheckpointStorage(temp_dir)
- for storage in [memory_storage, file_storage]:
- # Test that all protocol methods exist and are callable
- assert hasattr(storage, "save_checkpoint")
- assert callable(storage.save_checkpoint)
- assert hasattr(storage, "load_checkpoint")
- assert callable(storage.load_checkpoint)
- assert hasattr(storage, "list_checkpoint_ids")
- assert callable(storage.list_checkpoint_ids)
- assert hasattr(storage, "list_checkpoints")
- assert callable(storage.list_checkpoints)
- assert hasattr(storage, "delete_checkpoint")
- assert callable(storage.delete_checkpoint)
+ # Create checkpoints with small delays to ensure different timestamps
+ checkpoint1 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-1")
+ await asyncio.sleep(0.01)
+ checkpoint2 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-2")
+ await asyncio.sleep(0.01)
+ checkpoint3 = WorkflowCheckpoint(workflow_name="workflow-2", graph_signature_hash="hash-3")
+
+ await storage.save(checkpoint1)
+ await storage.save(checkpoint2)
+ await storage.save(checkpoint3)
+
+ # Test get_latest for workflow-1
+ latest = await storage.get_latest(workflow_name="workflow-1")
+ assert latest is not None
+ assert latest.checkpoint_id == checkpoint2.checkpoint_id
+
+ # Test get_latest for workflow-2
+ latest2 = await storage.get_latest(workflow_name="workflow-2")
+ assert latest2 is not None
+ assert latest2.checkpoint_id == checkpoint3.checkpoint_id
+
+ # Test get_latest for non-existent workflow
+ latest_none = await storage.get_latest(workflow_name="nonexistent-workflow")
+ assert latest_none is None
+
+
+async def test_file_checkpoint_storage_list_ids_corrupted_file():
+ with tempfile.TemporaryDirectory() as temp_dir:
+ storage = FileCheckpointStorage(temp_dir)
+
+ # Create a valid checkpoint first
+ checkpoint = WorkflowCheckpoint(workflow_name="test-workflow", graph_signature_hash="test-hash")
+ await storage.save(checkpoint)
+
+ # Create a corrupted JSON file
+ corrupted_file = Path(temp_dir) / "corrupted.json"
+ with open(corrupted_file, "w") as f: # noqa: ASYNC230
+ f.write("{ invalid json }")
+
+ # list_ids should handle the corrupted file gracefully
+ checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="test-workflow")
+ assert len(checkpoint_ids) == 1
+ assert checkpoint.checkpoint_id in checkpoint_ids
+
+
+async def test_file_checkpoint_storage_list_ids_empty():
+ with tempfile.TemporaryDirectory() as temp_dir:
+ storage = FileCheckpointStorage(temp_dir)
+
+ # Test list_ids on empty storage
+ checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="any-workflow")
+ assert checkpoint_ids == []
+
+
+async def test_file_checkpoint_storage_roundtrip_json_native_types():
+ """Test that JSON-native types (str, int, float, bool, None) roundtrip correctly."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ storage = FileCheckpointStorage(temp_dir)
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ state={
+ "string": "hello world",
+ "integer": 42,
+ "negative_int": -100,
+ "float": 3.14159,
+ "negative_float": -2.71828,
+ "bool_true": True,
+ "bool_false": False,
+ "null_value": None,
+ "zero": 0,
+ "empty_string": "",
+ },
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ assert loaded.state == checkpoint.state
+
+
+async def test_file_checkpoint_storage_roundtrip_datetime():
+ """Test that datetime objects roundtrip correctly via pickle encoding."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ storage = FileCheckpointStorage(temp_dir)
+
+ now = datetime.now(timezone.utc)
+ specific_datetime = datetime(2025, 6, 15, 10, 30, 45, 123456, tzinfo=timezone.utc)
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ state={
+ "current_time": now,
+ "specific_time": specific_datetime,
+ "nested": {"created_at": now, "updated_at": specific_datetime},
+ },
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ assert loaded.state["current_time"] == now
+ assert loaded.state["specific_time"] == specific_datetime
+ assert loaded.state["nested"]["created_at"] == now
+ assert loaded.state["nested"]["updated_at"] == specific_datetime
+
+
+async def test_file_checkpoint_storage_roundtrip_dataclass():
+ """Test that dataclass objects roundtrip correctly via pickle encoding."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ storage = FileCheckpointStorage(temp_dir)
+
+ custom_obj = _TestCustomData(name="test", value=42, tags=["a", "b", "c"])
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ state={
+ "custom_data": custom_obj,
+ "nested": {"inner_data": custom_obj},
+ },
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ assert loaded.state["custom_data"] == custom_obj
+ assert loaded.state["custom_data"].name == "test"
+ assert loaded.state["custom_data"].value == 42
+ assert loaded.state["custom_data"].tags == ["a", "b", "c"]
+ assert loaded.state["nested"]["inner_data"] == custom_obj
+ assert isinstance(loaded.state["custom_data"], _TestCustomData)
+
+
+async def test_file_checkpoint_storage_roundtrip_tuple_and_set():
+ """Test tuple/frozenset encoding behavior.
+
+ Tuples, sets, and frozensets are pickled to preserve their type through
+ the encode/decode roundtrip.
+ """
+ with tempfile.TemporaryDirectory() as temp_dir:
+ storage = FileCheckpointStorage(temp_dir)
+
+ original_tuple = (1, "two", 3.0, None)
+ original_frozenset = frozenset({1, 2, 3})
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ state={
+ "my_tuple": original_tuple,
+ "my_frozenset": original_frozenset,
+ "nested_tuple": {"inner": (10, 20, 30)},
+ },
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ # Tuples preserve their type through roundtrip
+ assert loaded.state["my_tuple"] == original_tuple
+ assert isinstance(loaded.state["my_tuple"], tuple)
+
+ # Frozensets are pickled and preserve their type
+ assert loaded.state["my_frozenset"] == original_frozenset
+ assert isinstance(loaded.state["my_frozenset"], frozenset)
+
+ # Nested tuples also preserve their type
+ assert loaded.state["nested_tuple"]["inner"] == (10, 20, 30)
+ assert isinstance(loaded.state["nested_tuple"]["inner"], tuple)
+
+
+async def test_file_checkpoint_storage_roundtrip_complex_nested_structures():
+ """Test complex nested structures with mixed types roundtrip correctly."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ storage = FileCheckpointStorage(temp_dir)
+
+ # Create complex nested structure mixing JSON-native and non-native types
+ complex_state = {
+ "level1": {
+ "level2": {
+ "level3": {
+ "deep_string": "hello",
+ "deep_int": 123,
+ "deep_datetime": datetime(2025, 1, 1, tzinfo=timezone.utc),
+ "deep_tuple": (1, 2, 3),
+ }
+ },
+ "list_of_dicts": [
+ {"a": 1, "b": datetime(2025, 2, 1, tzinfo=timezone.utc)},
+ {"c": 2, "d": (4, 5, 6)},
+ ],
+ },
+ "mixed_list": [
+ "string",
+ 42,
+ 3.14,
+ True,
+ None,
+ datetime(2025, 3, 1, tzinfo=timezone.utc),
+ (7, 8, 9),
+ ],
+ }
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ state=complex_state,
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ # Verify deep nested values
+ assert loaded.state["level1"]["level2"]["level3"]["deep_string"] == "hello"
+ assert loaded.state["level1"]["level2"]["level3"]["deep_int"] == 123
+ assert loaded.state["level1"]["level2"]["level3"]["deep_datetime"] == datetime(2025, 1, 1, tzinfo=timezone.utc)
+ # Tuples preserve their type through roundtrip
+ assert loaded.state["level1"]["level2"]["level3"]["deep_tuple"] == (1, 2, 3)
+
+ # Verify list of dicts
+ assert loaded.state["level1"]["list_of_dicts"][0]["a"] == 1
+ assert loaded.state["level1"]["list_of_dicts"][0]["b"] == datetime(2025, 2, 1, tzinfo=timezone.utc)
+ # Tuples preserve their type through roundtrip
+ assert loaded.state["level1"]["list_of_dicts"][1]["d"] == (4, 5, 6)
+
+ # Verify mixed list with correct types
+ assert loaded.state["mixed_list"][0] == "string"
+ assert loaded.state["mixed_list"][1] == 42
+ assert loaded.state["mixed_list"][5] == datetime(2025, 3, 1, tzinfo=timezone.utc)
+ # Tuples preserve their type through roundtrip
+ assert loaded.state["mixed_list"][6] == (7, 8, 9)
+ assert isinstance(loaded.state["mixed_list"][6], tuple)
+
+
+async def test_file_checkpoint_storage_roundtrip_messages_with_complex_data():
+ """Test that messages dict with Message objects roundtrips correctly."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ storage = FileCheckpointStorage(temp_dir)
+
+ msg1 = WorkflowMessage(
+ data={"text": "hello", "timestamp": datetime(2025, 1, 1, tzinfo=timezone.utc)},
+ source_id="source",
+ target_id="target",
+ )
+ msg2 = WorkflowMessage(
+ data=(1, 2, 3),
+ source_id="s2",
+ target_id=None,
+ )
+ msg3 = WorkflowMessage(
+ data="simple string",
+ source_id="s3",
+ target_id="t3",
+ )
+
+ messages = {
+ "executor1": [msg1, msg2],
+ "executor2": [msg3],
+ }
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ messages=messages,
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ # Verify messages structure and types
+ assert len(loaded.messages["executor1"]) == 2
+ loaded_msg1 = loaded.messages["executor1"][0]
+ loaded_msg2 = loaded.messages["executor1"][1]
+ loaded_msg3 = loaded.messages["executor2"][0]
+
+ # Verify WorkflowMessage type is preserved
+ assert isinstance(loaded_msg1, WorkflowMessage)
+ assert isinstance(loaded_msg2, WorkflowMessage)
+ assert isinstance(loaded_msg3, WorkflowMessage)
+
+ # Verify WorkflowMessage fields
+ assert loaded_msg1.data["text"] == "hello"
+ assert loaded_msg1.data["timestamp"] == datetime(2025, 1, 1, tzinfo=timezone.utc)
+ assert loaded_msg1.source_id == "source"
+ assert loaded_msg1.target_id == "target"
+
+ assert loaded_msg2.data == (1, 2, 3)
+ assert isinstance(loaded_msg2.data, tuple)
+ assert loaded_msg2.source_id == "s2"
+ assert loaded_msg2.target_id is None
+
+ assert loaded_msg3.data == "simple string"
+ assert loaded_msg3.source_id == "s3"
+ assert loaded_msg3.target_id == "t3"
+
+
+async def test_file_checkpoint_storage_roundtrip_pending_request_info_events():
+ """Test that pending_request_info_events with WorkflowEvent objects roundtrip correctly."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ storage = FileCheckpointStorage(temp_dir)
+
+ # Create request_info events using the proper WorkflowEvent factory
+ event1 = WorkflowEvent.request_info(
+ request_id="req123",
+ source_executor_id="executor1",
+ request_data="What is your name?",
+ response_type=str,
+ )
+ event2 = WorkflowEvent.request_info(
+ request_id="req456",
+ source_executor_id="executor2",
+ request_data=_TestToolApprovalRequest(
+ tool_name="search",
+ arguments={"query": "test"},
+ timestamp=datetime(2025, 1, 1, tzinfo=timezone.utc),
+ ),
+ response_type=bool,
+ )
+
+ pending_events = {
+ "req123": event1,
+ "req456": event2,
+ }
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ pending_request_info_events=pending_events,
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ # Verify WorkflowEvent type is preserved
+ loaded_event1 = loaded.pending_request_info_events["req123"]
+ loaded_event2 = loaded.pending_request_info_events["req456"]
+
+ assert isinstance(loaded_event1, WorkflowEvent)
+ assert isinstance(loaded_event2, WorkflowEvent)
+
+ # Verify event1 fields
+ assert loaded_event1.type == "request_info"
+ assert loaded_event1.request_id == "req123"
+ assert loaded_event1.source_executor_id == "executor1"
+ assert loaded_event1.data == "What is your name?"
+ assert loaded_event1.response_type is str
+
+ # Verify event2 fields with complex data
+ assert loaded_event2.type == "request_info"
+ assert loaded_event2.request_id == "req456"
+ assert loaded_event2.source_executor_id == "executor2"
+ assert isinstance(loaded_event2.data, _TestToolApprovalRequest)
+ assert loaded_event2.data.tool_name == "search"
+ assert loaded_event2.data.arguments == {"query": "test"}
+ assert loaded_event2.data.timestamp == datetime(2025, 1, 1, tzinfo=timezone.utc)
+ assert loaded_event2.response_type is bool
+
+
+async def test_file_checkpoint_storage_roundtrip_full_checkpoint():
+ """Test complete WorkflowCheckpoint roundtrip with all fields populated using proper types."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ storage = FileCheckpointStorage(temp_dir)
+
+ # Create proper WorkflowMessage objects
+ msg1 = WorkflowMessage(data="msg1", source_id="s", target_id="t")
+ msg2 = WorkflowMessage(data=datetime(2025, 1, 1, tzinfo=timezone.utc), source_id="a", target_id="b")
+
+ # Create proper WorkflowEvent for pending request
+ pending_event = WorkflowEvent.request_info(
+ request_id="req1",
+ source_executor_id="exec1",
+ request_data=_TestApprovalRequest(action="approve", params=(1, 2, 3)),
+ response_type=bool,
+ )
+
+ checkpoint = WorkflowCheckpoint(
+ checkpoint_id="full-test-checkpoint",
+ workflow_name="comprehensive-test",
+ graph_signature_hash="hash-abc123",
+ previous_checkpoint_id="previous-checkpoint-id",
+ timestamp=datetime(2025, 6, 15, 12, 0, 0, tzinfo=timezone.utc).isoformat(),
+ messages={
+ "exec1": [msg1],
+ "exec2": [msg2],
+ },
+ state={
+ "user_data": {"name": "test", "created": datetime(2025, 1, 1, tzinfo=timezone.utc)},
+ "_executor_state": {
+ "exec1": _TestExecutorState(counter=5, history=["a", "b", "c"]),
+ },
+ },
+ pending_request_info_events={
+ "req1": pending_event,
+ },
+ iteration_count=10,
+ metadata={
+ "superstep": 5,
+ "started_at": datetime(2025, 6, 15, 11, 0, 0, tzinfo=timezone.utc),
+ },
+ version="1.0",
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ # Verify all scalar fields
+ assert loaded.checkpoint_id == checkpoint.checkpoint_id
+ assert loaded.workflow_name == checkpoint.workflow_name
+ assert loaded.graph_signature_hash == checkpoint.graph_signature_hash
+ assert loaded.previous_checkpoint_id == checkpoint.previous_checkpoint_id
+ assert loaded.timestamp == checkpoint.timestamp
+ assert loaded.iteration_count == checkpoint.iteration_count
+ assert loaded.version == checkpoint.version
+
+ # Verify complex nested state data
+ assert loaded.state["user_data"]["created"] == datetime(2025, 1, 1, tzinfo=timezone.utc)
+ assert loaded.state["_executor_state"]["exec1"].counter == 5
+ assert loaded.state["_executor_state"]["exec1"].history == ["a", "b", "c"]
+ assert isinstance(loaded.state["_executor_state"]["exec1"], _TestExecutorState)
+
+ # Verify messages are proper Message objects
+ loaded_msg1 = loaded.messages["exec1"][0]
+ loaded_msg2 = loaded.messages["exec2"][0]
+ assert isinstance(loaded_msg1, WorkflowMessage)
+ assert isinstance(loaded_msg2, WorkflowMessage)
+ assert loaded_msg1.data == "msg1"
+ assert loaded_msg1.source_id == "s"
+ assert loaded_msg2.data == datetime(2025, 1, 1, tzinfo=timezone.utc)
+
+ # Verify pending events are proper WorkflowEvent objects
+ loaded_event = loaded.pending_request_info_events["req1"]
+ assert isinstance(loaded_event, WorkflowEvent)
+ assert loaded_event.type == "request_info"
+ assert loaded_event.request_id == "req1"
+ assert isinstance(loaded_event.data, _TestApprovalRequest)
+ assert loaded_event.data.params == (1, 2, 3)
+
+ # Verify metadata
+ assert loaded.metadata["superstep"] == 5
+ assert loaded.metadata["started_at"] == datetime(2025, 6, 15, 11, 0, 0, tzinfo=timezone.utc)
+
+
+async def test_file_checkpoint_storage_roundtrip_bytes():
+ """Test that bytes objects roundtrip correctly via pickle encoding."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ storage = FileCheckpointStorage(temp_dir)
+
+ binary_data = b"\x00\x01\x02\xff\xfe\xfd"
+ unicode_bytes = "Hello 世界".encode()
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ state={
+ "binary_data": binary_data,
+ "unicode_bytes": unicode_bytes,
+ "nested": {"inner_bytes": binary_data},
+ },
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ assert loaded.state["binary_data"] == binary_data
+ assert loaded.state["unicode_bytes"] == unicode_bytes
+ assert loaded.state["nested"]["inner_bytes"] == binary_data
+ assert isinstance(loaded.state["binary_data"], bytes)
+
+
+async def test_file_checkpoint_storage_roundtrip_empty_collections():
+ """Test that empty collections roundtrip correctly."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ storage = FileCheckpointStorage(temp_dir)
+
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test-workflow",
+ graph_signature_hash="test-hash",
+ state={
+ "empty_dict": {},
+ "empty_list": [],
+ "empty_tuple": (),
+ "nested_empty": {"inner_dict": {}, "inner_list": []},
+ },
+ messages={},
+ pending_request_info_events={},
+ )
+
+ await storage.save(checkpoint)
+ loaded = await storage.load(checkpoint.checkpoint_id)
+
+ assert loaded.state["empty_dict"] == {}
+ assert loaded.state["empty_list"] == []
+ # Empty tuples preserve their type through roundtrip
+ assert loaded.state["empty_tuple"] == ()
+ assert isinstance(loaded.state["empty_tuple"], tuple)
+ assert loaded.state["nested_empty"]["inner_dict"] == {}
+ assert loaded.messages == {}
+ assert loaded.pending_request_info_events == {}
+
+
+# endregion
diff --git a/python/packages/core/tests/workflow/test_checkpoint_decode.py b/python/packages/core/tests/workflow/test_checkpoint_decode.py
index 431c70cc3c..5ef9bc480f 100644
--- a/python/packages/core/tests/workflow/test_checkpoint_decode.py
+++ b/python/packages/core/tests/workflow/test_checkpoint_decode.py
@@ -1,16 +1,17 @@
# Copyright (c) Microsoft. All rights reserved.
-from dataclasses import dataclass # noqa: I001
+from dataclasses import dataclass
+from datetime import datetime, timezone
from typing import Any, cast
+import pytest
from agent_framework._workflows._checkpoint_encoding import (
- DATACLASS_MARKER,
- MODEL_MARKER,
+ _TYPE_MARKER, # type: ignore
+ CheckpointDecodingError,
decode_checkpoint_value,
encode_checkpoint_value,
)
-from agent_framework._workflows._typing_utils import is_instance_of
@dataclass
@@ -30,7 +31,22 @@ class SampleResponse:
request_id: str
-def test_decode_dataclass_with_nested_request() -> None:
+# --- Tests for round-trip encode/decode ---
+
+
+def test_roundtrip_simple_dataclass() -> None:
+ """Test encoding and decoding of a simple dataclass."""
+ original = SampleRequest(request_id="test-123", prompt="test prompt")
+
+ encoded = encode_checkpoint_value(original)
+ decoded = cast(SampleRequest, decode_checkpoint_value(encoded))
+
+ assert isinstance(decoded, SampleRequest)
+ assert decoded.request_id == "test-123"
+ assert decoded.prompt == "test prompt"
+
+
+def test_roundtrip_dataclass_with_nested_request() -> None:
"""Test that dataclass with nested dataclass fields can be encoded and decoded correctly."""
original = SampleResponse(
data="approve",
@@ -49,45 +65,7 @@ def test_decode_dataclass_with_nested_request() -> None:
assert decoded.original_request.request_id == "abc"
-def test_is_instance_of_coerces_nested_dataclass_dict() -> None:
- """Test that is_instance_of can handle nested structures with dict conversion."""
- response = SampleResponse(
- data="approve",
- original_request=SampleRequest(request_id="req-1", prompt="prompt"),
- request_id="req-1",
- )
-
- # Simulate checkpoint decode fallback leaving a dict
- response.original_request = cast(
- Any,
- {
- "request_id": "req-1",
- "prompt": "prompt",
- },
- )
-
- assert is_instance_of(response, SampleResponse)
- assert isinstance(response.original_request, dict)
-
- # Verify the dict contains expected values
- dict_request = cast(dict[str, Any], response.original_request)
- assert dict_request["request_id"] == "req-1"
- assert dict_request["prompt"] == "prompt"
-
-
-def test_encode_decode_simple_dataclass() -> None:
- """Test encoding and decoding of a simple dataclass."""
- original = SampleRequest(request_id="test-123", prompt="test prompt")
-
- encoded = encode_checkpoint_value(original)
- decoded = cast(SampleRequest, decode_checkpoint_value(encoded))
-
- assert isinstance(decoded, SampleRequest)
- assert decoded.request_id == "test-123"
- assert decoded.prompt == "test prompt"
-
-
-def test_encode_decode_nested_structures() -> None:
+def test_roundtrip_nested_structures() -> None:
"""Test encoding and decoding of complex nested structures."""
nested_data = {
"requests": [
@@ -110,7 +88,6 @@ def test_encode_decode_nested_structures() -> None:
assert "requests" in decoded
assert "responses" in decoded
- # Check the requests list
requests = cast(list[Any], decoded["requests"])
assert isinstance(requests, list)
assert len(requests) == 2
@@ -120,7 +97,6 @@ def test_encode_decode_nested_structures() -> None:
assert first_request.request_id == "req-1"
assert second_request.request_id == "req-2"
- # Check the responses dict
responses = cast(dict[str, Any], decoded["responses"])
assert isinstance(responses, dict)
assert "req-1" in responses
@@ -131,108 +107,145 @@ def test_encode_decode_nested_structures() -> None:
assert response.original_request.request_id == "req-1"
-def test_encode_allows_marker_key_without_value_key() -> None:
- """Test that encoding a dict with only the marker key (no 'value') is allowed."""
- dict_with_marker_only = {
- MODEL_MARKER: "some.module:FakeClass",
- "other_key": "test",
+def test_roundtrip_datetime() -> None:
+ """Test round-trip encoding/decoding of datetime objects."""
+ original = datetime(2024, 5, 4, 12, 30, 45, tzinfo=timezone.utc)
+
+ encoded = encode_checkpoint_value(original)
+ decoded = decode_checkpoint_value(encoded)
+
+ assert isinstance(decoded, datetime)
+ assert decoded == original
+
+
+def test_roundtrip_primitives() -> None:
+ """Test that primitive types round-trip unchanged."""
+ for value in ["hello", 42, 3.14, True, False, None]:
+ assert decode_checkpoint_value(encode_checkpoint_value(value)) == value
+
+
+def test_roundtrip_dict_with_mixed_values() -> None:
+ """Test round-trip of a dict containing both primitives and complex types."""
+ original = {
+ "name": "test",
+ "request": SampleRequest(request_id="r1", prompt="p1"),
+ "count": 5,
}
- encoded = encode_checkpoint_value(dict_with_marker_only)
- assert MODEL_MARKER in encoded
- assert "other_key" in encoded
+
+ encoded = encode_checkpoint_value(original)
+ decoded = decode_checkpoint_value(encoded)
+
+ assert decoded["name"] == "test"
+ assert decoded["count"] == 5
+ assert isinstance(decoded["request"], SampleRequest)
+ assert decoded["request"].request_id == "r1"
-def test_encode_allows_value_key_without_marker_key() -> None:
- """Test that encoding a dict with only 'value' key (no marker) is allowed."""
- dict_with_value_only = {
- "value": {"data": "test"},
- "other_key": "test",
- }
- encoded = encode_checkpoint_value(dict_with_value_only)
- assert "value" in encoded
- assert "other_key" in encoded
+# --- Tests for decode primitives ---
-def test_encode_allows_marker_with_value_key() -> None:
- """Test that encoding a dict with marker and 'value' keys is allowed.
-
- This is allowed because legitimate encoded data may contain these keys,
- and security is enforced at deserialization time by validating class types.
- """
- dict_with_both = {
- MODEL_MARKER: "some.module:SomeClass",
- "value": {"data": "test"},
- "strategy": "to_dict",
- }
- encoded = encode_checkpoint_value(dict_with_both)
- assert MODEL_MARKER in encoded
- assert "value" in encoded
+def test_decode_string() -> None:
+ """Test decoding a string passes through unchanged."""
+ assert decode_checkpoint_value("hello") == "hello"
-class NotADataclass:
+def test_decode_integer() -> None:
+ """Test decoding an integer passes through unchanged."""
+ assert decode_checkpoint_value(42) == 42
+
+
+def test_decode_none() -> None:
+ """Test decoding None passes through unchanged."""
+ assert decode_checkpoint_value(None) is None
+
+
+# --- Tests for decode collections ---
+
+
+def test_decode_plain_dict() -> None:
+ """Test decoding a plain dictionary with primitive values."""
+ data = {"a": 1, "b": "two"}
+ assert decode_checkpoint_value(data) == {"a": 1, "b": "two"}
+
+
+def test_decode_plain_list() -> None:
+ """Test decoding a plain list with primitive values."""
+ data = [1, "two", 3.0]
+ assert decode_checkpoint_value(data) == [1, "two", 3.0]
+
+
+# --- Tests for type verification ---
+
+
+def test_decode_raises_on_type_mismatch() -> None:
+ """Test that decoding raises CheckpointDecodingError when type doesn't match."""
+ # Encode a SampleRequest but tamper with the type marker
+ encoded = encode_checkpoint_value(SampleRequest(request_id="r1", prompt="p1"))
+ assert isinstance(encoded, dict)
+ encoded[_TYPE_MARKER] = "nonexistent.module:FakeClass"
+
+ with pytest.raises(CheckpointDecodingError, match="Type mismatch"):
+ decode_checkpoint_value(encoded)
+
+
+class NotADataclass: # noqa: B903
"""A regular class that is not a dataclass."""
def __init__(self, value: str) -> None:
self.value = value
- def get_value(self) -> str:
- return self.value
+
+def test_roundtrip_regular_class() -> None:
+ """Test that regular (non-dataclass) objects can be round-tripped via pickle."""
+ original = NotADataclass(value="test_value")
+
+ encoded = encode_checkpoint_value(original)
+ decoded = cast(NotADataclass, decode_checkpoint_value(encoded))
+
+ assert isinstance(decoded, NotADataclass)
+ assert decoded.value == "test_value"
-class NotAModel:
- """A regular class that does not support the model protocol."""
+def test_roundtrip_tuple() -> None:
+ """Test that tuples preserve their type through encode/decode roundtrip."""
+ original = (1, "two", 3.0)
- def __init__(self, value: str) -> None:
- self.value = value
+ encoded = encode_checkpoint_value(original)
+ decoded = decode_checkpoint_value(encoded)
- def get_value(self) -> str:
- return self.value
+ assert isinstance(decoded, tuple)
+ assert decoded == original
-def test_decode_rejects_non_dataclass_with_dataclass_marker() -> None:
- """Test that decode returns raw value when marked class is not a dataclass."""
- # Manually construct a payload that claims NotADataclass is a dataclass
- fake_payload = {
- DATACLASS_MARKER: f"{NotADataclass.__module__}:{NotADataclass.__name__}",
- "value": {"value": "test_value"},
- }
+def test_roundtrip_set() -> None:
+ """Test that sets preserve their type through encode/decode roundtrip."""
+ original = {1, 2, 3}
- decoded = decode_checkpoint_value(fake_payload)
+ encoded = encode_checkpoint_value(original)
+ decoded = decode_checkpoint_value(encoded)
- # Should return the raw decoded value, not an instance of NotADataclass
- assert isinstance(decoded, dict)
- assert decoded["value"] == "test_value"
+ assert isinstance(decoded, set)
+ assert decoded == original
-def test_decode_rejects_non_model_with_model_marker() -> None:
- """Test that decode returns raw value when marked class doesn't support model protocol."""
- # Manually construct a payload that claims NotAModel supports the model protocol
- fake_payload = {
- MODEL_MARKER: f"{NotAModel.__module__}:{NotAModel.__name__}",
- "strategy": "to_dict",
- "value": {"value": "test_value"},
- }
+def test_roundtrip_nested_tuple_in_dict() -> None:
+ """Test that tuples nested inside dicts preserve their type."""
+ original = {"items": (1, 2, 3), "name": "test"}
- decoded = decode_checkpoint_value(fake_payload)
+ encoded = encode_checkpoint_value(original)
+ decoded = decode_checkpoint_value(encoded)
- # Should return the raw decoded value, not an instance of NotAModel
- assert isinstance(decoded, dict)
- assert decoded["value"] == "test_value"
+ assert isinstance(decoded["items"], tuple)
+ assert decoded["items"] == (1, 2, 3)
+ assert decoded["name"] == "test"
-def test_encode_allows_nested_dict_with_marker_keys() -> None:
- """Test that encoding allows nested dicts containing marker patterns.
+def test_roundtrip_set_in_list() -> None:
+ """Test that sets nested inside lists preserve their type."""
+ original = [{"tags": {1, 2, 3}}]
- Security is enforced at deserialization time, not serialization time,
- so legitimate encoded data can contain markers at any nesting level.
- """
- nested_data = {
- "outer": {
- MODEL_MARKER: "some.module:SomeClass",
- "value": {"data": "test"},
- }
- }
+ encoded = encode_checkpoint_value(original)
+ decoded = decode_checkpoint_value(encoded)
- encoded = encode_checkpoint_value(nested_data)
- assert "outer" in encoded
- assert MODEL_MARKER in encoded["outer"]
+ assert isinstance(decoded[0]["tags"], set)
+ assert decoded[0]["tags"] == {1, 2, 3}
diff --git a/python/packages/core/tests/workflow/test_checkpoint_encode.py b/python/packages/core/tests/workflow/test_checkpoint_encode.py
index 3f4db1f864..68ec1ac4e3 100644
--- a/python/packages/core/tests/workflow/test_checkpoint_encode.py
+++ b/python/packages/core/tests/workflow/test_checkpoint_encode.py
@@ -1,12 +1,13 @@
# Copyright (c) Microsoft. All rights reserved.
+import json
from dataclasses import dataclass
+from datetime import datetime, timezone
from typing import Any
from agent_framework._workflows._checkpoint_encoding import (
- _CYCLE_SENTINEL,
- DATACLASS_MARKER,
- MODEL_MARKER,
+ _PICKLE_MARKER,
+ _TYPE_MARKER,
encode_checkpoint_value,
)
@@ -41,23 +42,6 @@ class ModelWithToDict:
return cls(data=d["data"])
-class ModelWithToJson:
- """A class that implements to_json/from_json protocol."""
-
- def __init__(self, data: str) -> None:
- self.data = data
-
- def to_json(self) -> str:
- return f'{{"data": "{self.data}"}}'
-
- @classmethod
- def from_json(cls, json_str: str) -> "ModelWithToJson":
- import json
-
- d = json.loads(json_str)
- return cls(data=d["data"])
-
-
class UnknownObject:
"""A class that doesn't support any serialization protocol."""
@@ -68,43 +52,37 @@ class UnknownObject:
return f"UnknownObject({self.value})"
-# --- Tests for primitive encoding ---
+# --- Tests for primitive encoding (pass-through) ---
def test_encode_string() -> None:
"""Test encoding a string value."""
- result = encode_checkpoint_value("hello")
- assert result == "hello"
+ assert encode_checkpoint_value("hello") == "hello"
def test_encode_integer() -> None:
"""Test encoding an integer value."""
- result = encode_checkpoint_value(42)
- assert result == 42
+ assert encode_checkpoint_value(42) == 42
def test_encode_float() -> None:
"""Test encoding a float value."""
- result = encode_checkpoint_value(3.14)
- assert result == 3.14
+ assert encode_checkpoint_value(3.14) == 3.14
def test_encode_boolean_true() -> None:
"""Test encoding a True boolean value."""
- result = encode_checkpoint_value(True)
- assert result is True
+ assert encode_checkpoint_value(True) is True
def test_encode_boolean_false() -> None:
"""Test encoding a False boolean value."""
- result = encode_checkpoint_value(False)
- assert result is False
+ assert encode_checkpoint_value(False) is False
def test_encode_none() -> None:
"""Test encoding a None value."""
- result = encode_checkpoint_value(None)
- assert result is None
+ assert encode_checkpoint_value(None) is None
# --- Tests for collection encoding ---
@@ -112,8 +90,7 @@ def test_encode_none() -> None:
def test_encode_empty_dict() -> None:
"""Test encoding an empty dictionary."""
- result = encode_checkpoint_value({})
- assert result == {}
+ assert encode_checkpoint_value({}) == {}
def test_encode_simple_dict() -> None:
@@ -132,8 +109,7 @@ def test_encode_dict_with_non_string_keys() -> None:
def test_encode_empty_list() -> None:
"""Test encoding an empty list."""
- result = encode_checkpoint_value([])
- assert result == []
+ assert encode_checkpoint_value([]) == []
def test_encode_simple_list() -> None:
@@ -144,29 +120,26 @@ def test_encode_simple_list() -> None:
def test_encode_tuple() -> None:
- """Test encoding a tuple (converted to list)."""
+ """Test encoding a tuple (pickled to preserve type)."""
data = (1, 2, 3)
result = encode_checkpoint_value(data)
- assert result == [1, 2, 3]
+ assert isinstance(result, dict)
+ assert _PICKLE_MARKER in result
+ assert _TYPE_MARKER in result
def test_encode_set() -> None:
- """Test encoding a set (converted to list)."""
+ """Test encoding a set (pickled to preserve type)."""
data = {1, 2, 3}
result = encode_checkpoint_value(data)
- assert isinstance(result, list)
- assert sorted(result) == [1, 2, 3]
+ assert isinstance(result, dict)
+ assert _PICKLE_MARKER in result
+ assert _TYPE_MARKER in result
def test_encode_nested_dict() -> None:
"""Test encoding a nested dictionary structure."""
- data = {
- "outer": {
- "inner": {
- "value": 42,
- }
- }
- }
+ data = {"outer": {"inner": {"value": 42}}}
result = encode_checkpoint_value(data)
assert result == {"outer": {"inner": {"value": 42}}}
@@ -178,18 +151,18 @@ def test_encode_list_of_dicts() -> None:
assert result == [{"a": 1}, {"b": 2}]
-# --- Tests for dataclass encoding ---
+# --- Tests for non-JSON-native types (pickled) ---
def test_encode_simple_dataclass() -> None:
- """Test encoding a simple dataclass."""
+ """Test encoding a simple dataclass produces a pickled entry."""
obj = SimpleDataclass(name="test", value=42)
result = encode_checkpoint_value(obj)
assert isinstance(result, dict)
- assert DATACLASS_MARKER in result
- assert "value" in result
- assert result["value"] == {"name": "test", "value": 42}
+ assert _PICKLE_MARKER in result
+ assert _TYPE_MARKER in result
+ assert isinstance(result[_PICKLE_MARKER], str) # base64 string
def test_encode_nested_dataclass() -> None:
@@ -199,12 +172,8 @@ def test_encode_nested_dataclass() -> None:
result = encode_checkpoint_value(outer)
assert isinstance(result, dict)
- assert DATACLASS_MARKER in result
- assert "value" in result
-
- outer_value = result["value"]
- assert outer_value["outer_name"] == "outer"
- assert DATACLASS_MARKER in outer_value["inner"]
+ assert _PICKLE_MARKER in result
+ assert _TYPE_MARKER in result
def test_encode_list_of_dataclasses() -> None:
@@ -218,7 +187,7 @@ def test_encode_list_of_dataclasses() -> None:
assert isinstance(result, list)
assert len(result) == 2
for item in result:
- assert DATACLASS_MARKER in item
+ assert _PICKLE_MARKER in item
def test_encode_dict_with_dataclass_values() -> None:
@@ -230,169 +199,77 @@ def test_encode_dict_with_dataclass_values() -> None:
result = encode_checkpoint_value(data)
assert isinstance(result, dict)
- assert DATACLASS_MARKER in result["item1"]
- assert DATACLASS_MARKER in result["item2"]
-
-
-# --- Tests for model protocol encoding ---
+ assert _PICKLE_MARKER in result["item1"]
+ assert _PICKLE_MARKER in result["item2"]
def test_encode_model_with_to_dict() -> None:
- """Test encoding an object implementing to_dict/from_dict protocol."""
+ """Test encoding an object with to_dict is pickled (not using to_dict)."""
obj = ModelWithToDict(data="test_data")
result = encode_checkpoint_value(obj)
assert isinstance(result, dict)
- assert MODEL_MARKER in result
- assert result["strategy"] == "to_dict"
- assert result["value"] == {"data": "test_data"}
+ assert _PICKLE_MARKER in result
-def test_encode_model_with_to_json() -> None:
- """Test encoding an object implementing to_json/from_json protocol."""
- obj = ModelWithToJson(data="test_data")
- result = encode_checkpoint_value(obj)
-
- assert isinstance(result, dict)
- assert MODEL_MARKER in result
- assert result["strategy"] == "to_json"
- assert '"data": "test_data"' in result["value"]
-
-
-# --- Tests for unknown object encoding ---
-
-
-def test_encode_unknown_object_fallback_to_string() -> None:
- """Test that unknown objects are encoded as strings."""
+def test_encode_unknown_object() -> None:
+ """Test that arbitrary objects are pickled."""
obj = UnknownObject(value="test")
result = encode_checkpoint_value(obj)
- assert isinstance(result, str)
- assert "UnknownObject" in result
+ assert isinstance(result, dict)
+ assert _PICKLE_MARKER in result
-# --- Tests for cycle detection ---
+def test_encode_datetime() -> None:
+ """Test that datetime objects are pickled."""
+ dt = datetime(2024, 5, 4, 12, 30, 45, tzinfo=timezone.utc)
+ result = encode_checkpoint_value(dt)
+
+ assert isinstance(result, dict)
+ assert _PICKLE_MARKER in result
-def test_encode_dict_with_self_reference() -> None:
- """Test that dict self-references are detected and handled."""
- data: dict[str, Any] = {"name": "test"}
- data["self"] = data # Create circular reference
-
- result = encode_checkpoint_value(data)
- assert result["name"] == "test"
- assert result["self"] == _CYCLE_SENTINEL
+# --- Tests for type marker ---
-def test_encode_list_with_self_reference() -> None:
- """Test that list self-references are detected and handled."""
- data: list[Any] = [1, 2]
- data.append(data) # Create circular reference
+def test_encode_type_marker_records_type_info() -> None:
+ """Test that encoded objects include correct type information."""
+ obj = SimpleDataclass(name="test", value=42)
+ result = encode_checkpoint_value(obj)
- result = encode_checkpoint_value(data)
- assert result[0] == 1
- assert result[1] == 2
- assert result[2] == _CYCLE_SENTINEL
+ type_key = result[_TYPE_MARKER]
+ assert "SimpleDataclass" in type_key
-# --- Tests for reserved keyword handling ---
-# Note: Security is enforced at deserialization time by validating class types,
-# not at serialization time. This allows legitimate encoded data to be re-encoded.
+def test_encode_type_marker_uses_module_qualname_format() -> None:
+ """Test that type marker uses module:qualname format."""
+ obj = SimpleDataclass(name="test", value=42)
+ result = encode_checkpoint_value(obj)
+
+ type_key = result[_TYPE_MARKER]
+ assert ":" in type_key
+ module, qualname = type_key.split(":")
+ assert module # non-empty module
+ assert qualname == "SimpleDataclass"
-def test_encode_allows_dict_with_model_marker_and_value() -> None:
- """Test that encoding a dict with MODEL_MARKER and 'value' is allowed.
+# --- Tests for JSON serializability ---
- Security is enforced at deserialization time, not serialization time.
- """
+
+def test_encode_result_is_json_serializable() -> None:
+ """Test that encoded output is fully JSON-serializable."""
data = {
- MODEL_MARKER: "some.module:SomeClass",
- "value": {"data": "test"},
+ "dc": SimpleDataclass(name="test", value=42),
+ "model": ModelWithToDict(data="test"),
+ "dt": datetime.now(timezone.utc),
+ "nested": [SimpleDataclass(name="n", value=1)],
}
- result = encode_checkpoint_value(data)
- assert MODEL_MARKER in result
- assert "value" in result
-
-
-def test_encode_allows_dict_with_dataclass_marker_and_value() -> None:
- """Test that encoding a dict with DATACLASS_MARKER and 'value' is allowed.
-
- Security is enforced at deserialization time, not serialization time.
- """
- data = {
- DATACLASS_MARKER: "some.module:SomeClass",
- "value": {"field": "test"},
- }
- result = encode_checkpoint_value(data)
- assert DATACLASS_MARKER in result
- assert "value" in result
-
-
-def test_encode_allows_nested_dict_with_marker_keys() -> None:
- """Test that encoding nested dict with marker keys is allowed.
-
- Security is enforced at deserialization time, not serialization time.
- """
- nested_data = {
- "outer": {
- MODEL_MARKER: "some.module:SomeClass",
- "value": {"data": "test"},
- }
- }
- result = encode_checkpoint_value(nested_data)
- assert "outer" in result
- assert MODEL_MARKER in result["outer"]
-
-
-def test_encode_allows_marker_without_value() -> None:
- """Test that a dict with marker key but without 'value' key is allowed."""
- data = {
- MODEL_MARKER: "some.module:SomeClass",
- "other_key": "allowed",
- }
- result = encode_checkpoint_value(data)
- assert MODEL_MARKER in result
- assert result["other_key"] == "allowed"
-
-
-def test_encode_allows_value_without_marker() -> None:
- """Test that a dict with 'value' key but without marker is allowed."""
- data = {
- "value": {"nested": "data"},
- "other_key": "allowed",
- }
- result = encode_checkpoint_value(data)
- assert "value" in result
- assert result["other_key"] == "allowed"
-
-
-# --- Tests for max depth protection ---
-
-
-def test_encode_deep_nesting_triggers_max_depth() -> None:
- """Test that very deep nesting triggers max depth protection."""
- # Create a deeply nested structure (over 100 levels)
- data: dict[str, Any] = {"level": 0}
- current = data
- for i in range(105):
- current["nested"] = {"level": i + 1}
- current = current["nested"]
result = encode_checkpoint_value(data)
-
- # Navigate to find the max_depth sentinel
- current_result = result
- found_max_depth = False
- for _ in range(110):
- if isinstance(current_result, dict) and "nested" in current_result:
- current_result = current_result["nested"]
- if current_result == "":
- found_max_depth = True
- break
- else:
- break
-
- assert found_max_depth, "Expected sentinel to be found in deeply nested structure"
+ # Should not raise
+ json_str = json.dumps(result)
+ assert isinstance(json_str, str)
# --- Tests for mixed complex structures ---
@@ -413,6 +290,7 @@ def test_encode_complex_mixed_structure() -> None:
result = encode_checkpoint_value(data)
+ # Primitives and collections pass through
assert result["string_value"] == "hello"
assert result["int_value"] == 42
assert result["float_value"] == 3.14
@@ -420,4 +298,17 @@ def test_encode_complex_mixed_structure() -> None:
assert result["none_value"] is None
assert result["list_value"] == [1, 2, 3]
assert result["nested_dict"] == {"a": 1, "b": 2}
- assert DATACLASS_MARKER in result["dataclass_value"]
+ # Dataclass is pickled
+ assert _PICKLE_MARKER in result["dataclass_value"]
+
+
+def test_encode_preserves_dict_with_pickle_marker_key() -> None:
+ """Test that regular dicts containing _PICKLE_MARKER key are recursively encoded."""
+ data = {
+ _PICKLE_MARKER: "some_value",
+ "other_key": "test",
+ }
+ result = encode_checkpoint_value(data)
+ assert _PICKLE_MARKER in result
+ assert result[_PICKLE_MARKER] == "some_value"
+ assert result["other_key"] == "test"
diff --git a/python/packages/core/tests/workflow/test_checkpoint_validation.py b/python/packages/core/tests/workflow/test_checkpoint_validation.py
index 17175451ce..a9c748a324 100644
--- a/python/packages/core/tests/workflow/test_checkpoint_validation.py
+++ b/python/packages/core/tests/workflow/test_checkpoint_validation.py
@@ -44,7 +44,7 @@ async def test_resume_fails_when_graph_mismatch() -> None:
# Run once to create checkpoints
_ = [event async for event in workflow.run("hello", stream=True)] # noqa: F841
- checkpoints = await storage.list_checkpoints()
+ checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
assert checkpoints, "expected at least one checkpoint to be created"
target_checkpoint = checkpoints[-1]
@@ -67,7 +67,7 @@ async def test_resume_succeeds_when_graph_matches() -> None:
workflow = build_workflow(storage, finish_id="finish")
_ = [event async for event in workflow.run("hello", stream=True)] # noqa: F841
- checkpoints = sorted(await storage.list_checkpoints(), key=lambda c: c.timestamp)
+ checkpoints = sorted(await storage.list_checkpoints(workflow_name=workflow.name), key=lambda c: c.timestamp)
target_checkpoint = checkpoints[0]
resumed_workflow = build_workflow(storage, finish_id="finish")
@@ -126,7 +126,7 @@ async def test_resume_succeeds_when_sub_workflow_matches() -> None:
_ = [event async for event in workflow.run("hello", stream=True)]
- checkpoints = await storage.list_checkpoints()
+ checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
assert checkpoints, "expected at least one checkpoint to be created"
target_checkpoint = checkpoints[-1]
@@ -150,7 +150,7 @@ async def test_resume_fails_when_sub_workflow_changes() -> None:
_ = [event async for event in workflow.run("hello", stream=True)]
- checkpoints = await storage.list_checkpoints()
+ checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
assert checkpoints, "expected at least one checkpoint to be created"
target_checkpoint = checkpoints[-1]
diff --git a/python/packages/core/tests/workflow/test_request_info_and_response.py b/python/packages/core/tests/workflow/test_request_info_and_response.py
index b62bfafb7c..05a7ed1ec5 100644
--- a/python/packages/core/tests/workflow/test_request_info_and_response.py
+++ b/python/packages/core/tests/workflow/test_request_info_and_response.py
@@ -3,7 +3,6 @@
from dataclasses import dataclass
from agent_framework import (
- FileCheckpointStorage,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
@@ -323,90 +322,3 @@ class TestRequestInfoAndResponse:
assert completed
# Should not have any calculations performed due to invalid input
assert len(executor.calculations_performed) == 0
-
- async def test_checkpoint_with_pending_request_info_events(self):
- """Test that request info events are properly serialized in checkpoints and can be restored."""
- import tempfile
-
- with tempfile.TemporaryDirectory() as temp_dir:
- # Use file-based storage to test full serialization
- storage = FileCheckpointStorage(temp_dir)
-
- # Create workflow with checkpointing enabled
- executor = ApprovalRequiredExecutor(id="approval_executor")
- workflow = WorkflowBuilder(start_executor=executor, checkpoint_storage=storage).build()
-
- # Step 1: Run workflow to completion to ensure checkpoints are created
- request_info_event: WorkflowEvent | None = None
- async for event in workflow.run("checkpoint test operation", stream=True):
- if event.type == "request_info":
- request_info_event = event
-
- # Verify request was emitted
- assert request_info_event is not None
- assert isinstance(request_info_event.data, UserApprovalRequest)
- assert request_info_event.data.prompt == "Please approve the operation: checkpoint test operation"
- assert request_info_event.source_executor_id == "approval_executor"
-
- # Step 2: List checkpoints to find the one with our pending request
- checkpoints = await storage.list_checkpoints()
- assert len(checkpoints) > 0, "No checkpoints were created during workflow execution"
-
- # Find the checkpoint with our pending request
- checkpoint_with_request = None
- for checkpoint in checkpoints:
- if request_info_event.request_id in checkpoint.pending_request_info_events:
- checkpoint_with_request = checkpoint
- break
-
- assert checkpoint_with_request is not None, "No checkpoint found with pending request info event"
-
- # Step 3: Verify the pending request info event was properly serialized
- serialized_event = checkpoint_with_request.pending_request_info_events[request_info_event.request_id]
- assert "data" in serialized_event
- assert "request_id" in serialized_event
- assert "source_executor_id" in serialized_event
- assert "request_type" in serialized_event
- assert serialized_event["request_id"] == request_info_event.request_id
- assert serialized_event["source_executor_id"] == "approval_executor"
-
- # Step 4: Create a fresh workflow and restore from checkpoint
- new_executor = ApprovalRequiredExecutor(id="approval_executor")
- restored_workflow = WorkflowBuilder(start_executor=new_executor, checkpoint_storage=storage).build()
-
- # Step 5: Resume from checkpoint and verify the request can be continued
- completed = False
- restored_request_event: WorkflowEvent | None = None
- async for event in restored_workflow.run(checkpoint_id=checkpoint_with_request.checkpoint_id, stream=True):
- # Should re-emit the pending request info event
- if event.type == "request_info" and event.request_id == request_info_event.request_id:
- restored_request_event = event
- elif event.type == "status" and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
- completed = True
-
- assert completed, "Workflow should reach idle with pending requests state after restoration"
- assert restored_request_event is not None, "Restored request info event should be emitted"
-
- # Verify the restored event matches the original
- assert restored_request_event.source_executor_id == request_info_event.source_executor_id
- assert isinstance(restored_request_event.data, UserApprovalRequest)
- assert restored_request_event.data.prompt == request_info_event.data.prompt
- assert restored_request_event.data.context == request_info_event.data.context
-
- # Step 6: Provide response to the restored request and complete the workflow
- final_completed = False
- async for event in restored_workflow.run(
- stream=True,
- responses={
- request_info_event.request_id: True # Approve the request
- },
- ):
- if event.type == "status" and event.state == WorkflowRunState.IDLE:
- final_completed = True
-
- assert final_completed, "Workflow should complete after providing response to restored request"
-
- # Step 7: Verify the executor state was properly restored and response was processed
- assert new_executor.approval_received is True
- expected_result = "Operation approved: Please approve the operation: checkpoint test operation"
- assert new_executor.final_result == expected_result
diff --git a/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py b/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py
index 73b4b938c1..dbb01d6e66 100644
--- a/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py
+++ b/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py
@@ -4,14 +4,27 @@ import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
-import pytest
-
-from agent_framework import InMemoryCheckpointStorage, InProcRunnerContext
-from agent_framework._workflows._checkpoint_encoding import DATACLASS_MARKER, encode_checkpoint_value
-from agent_framework._workflows._checkpoint_summary import get_checkpoint_summary
+from agent_framework import (
+ FileCheckpointStorage,
+ InMemoryCheckpointStorage,
+ InProcRunnerContext,
+ WorkflowBuilder,
+ WorkflowRunState,
+)
+from agent_framework._workflows._checkpoint_encoding import (
+ _PICKLE_MARKER,
+ encode_checkpoint_value,
+)
from agent_framework._workflows._events import WorkflowEvent
from agent_framework._workflows._state import State
+from .test_request_info_and_response import (
+ ApprovalRequiredExecutor,
+ CalculationRequest,
+ MultiRequestExecutor,
+ UserApprovalRequest,
+)
+
@dataclass
class MockRequest: ...
@@ -46,13 +59,13 @@ async def test_rehydrate_request_info_event() -> None:
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
await runner_context.add_request_info_event(request_info_event)
- checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
+ checkpoint_id = await runner_context.create_checkpoint("test_name", "test_hash", State(), None, iteration_count=1)
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
assert checkpoint is not None
assert checkpoint.pending_request_info_events
assert "request-123" in checkpoint.pending_request_info_events
- assert "request_type" in checkpoint.pending_request_info_events["request-123"]
+ assert checkpoint.pending_request_info_events["request-123"].request_type is MockRequest
# Rehydrate the context
await runner_context.apply_checkpoint(checkpoint)
@@ -67,97 +80,6 @@ async def test_rehydrate_request_info_event() -> None:
assert isinstance(rehydrated_event.data, MockRequest)
-async def test_rehydrate_fails_when_request_type_missing() -> None:
- """Rehydration should fail is the request type is missing or fails to import."""
- request_info_event = WorkflowEvent.request_info(
- request_id="request-123",
- source_executor_id="review_gateway",
- request_data=MockRequest(),
- response_type=bool,
- )
-
- runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
- await runner_context.add_request_info_event(request_info_event)
-
- checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
- checkpoint = await runner_context.load_checkpoint(checkpoint_id)
-
- assert checkpoint is not None
- assert checkpoint.pending_request_info_events
- assert "request-123" in checkpoint.pending_request_info_events
- assert "request_type" in checkpoint.pending_request_info_events["request-123"]
-
- # Modify the checkpoint to simulate missing request type
- checkpoint.pending_request_info_events["request-123"]["request_type"] = "nonexistent.module:MissingRequest"
-
- # Rehydrate the context
- with pytest.raises(ImportError):
- await runner_context.apply_checkpoint(checkpoint)
-
-
-async def test_rehydrate_fails_when_request_type_mismatch() -> None:
- """Rehydration should fail if the request type is mismatched."""
- request_info_event = WorkflowEvent.request_info(
- request_id="request-123",
- source_executor_id="review_gateway",
- request_data=MockRequest(),
- response_type=bool,
- )
-
- runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
- await runner_context.add_request_info_event(request_info_event)
-
- checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
- checkpoint = await runner_context.load_checkpoint(checkpoint_id)
-
- assert checkpoint is not None
- assert checkpoint.pending_request_info_events
- assert "request-123" in checkpoint.pending_request_info_events
- assert "request_type" in checkpoint.pending_request_info_events["request-123"]
-
- # Modify the checkpoint to simulate mismatched request type in the serialized data
- checkpoint.pending_request_info_events["request-123"]["data"][DATACLASS_MARKER] = (
- "nonexistent.module:MissingRequest"
- )
-
- # Rehydrate the context
- with pytest.raises(TypeError):
- await runner_context.apply_checkpoint(checkpoint)
-
-
-async def test_pending_requests_in_summary() -> None:
- """Test that pending requests are correctly summarized in the checkpoint summary."""
- request_info_event = WorkflowEvent.request_info(
- request_id="request-123",
- source_executor_id="review_gateway",
- request_data=MockRequest(),
- response_type=bool,
- )
-
- runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
- await runner_context.add_request_info_event(request_info_event)
-
- checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
- checkpoint = await runner_context.load_checkpoint(checkpoint_id)
-
- assert checkpoint is not None
- summary = get_checkpoint_summary(checkpoint)
-
- assert summary.checkpoint_id == checkpoint_id
- assert summary.status == "awaiting request response"
-
- assert len(summary.pending_request_info_events) == 1
- pending_event = summary.pending_request_info_events[0]
- assert isinstance(pending_event, WorkflowEvent)
- assert pending_event.type == "request_info"
- assert pending_event.request_id == "request-123"
-
- assert pending_event.source_executor_id == "review_gateway"
- assert pending_event.request_type is MockRequest
- assert pending_event.response_type is bool
- assert isinstance(pending_event.data, MockRequest)
-
-
async def test_request_info_event_serializes_non_json_payloads() -> None:
req_1 = WorkflowEvent.request_info(
request_id="req-1",
@@ -176,20 +98,260 @@ async def test_request_info_event_serializes_non_json_payloads() -> None:
await runner_context.add_request_info_event(req_1)
await runner_context.add_request_info_event(req_2)
- checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
+ checkpoint_id = await runner_context.create_checkpoint("test_name", "test_hash", State(), None, iteration_count=1)
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
# Should be JSON serializable despite datetime/slots
serialized = json.dumps(encode_checkpoint_value(checkpoint))
+ assert isinstance(serialized, str)
+
+ # Verify the structure contains pickled data for the request data fields
deserialized = json.loads(serialized)
+ assert _PICKLE_MARKER in deserialized # checkpoint itself is pickled
- assert "value" in deserialized
- deserialized = deserialized["value"]
+ # Verify we can rehydrate the checkpoint correctly
+ await runner_context.apply_checkpoint(checkpoint)
+ pending = await runner_context.get_pending_request_info_events()
- assert "pending_request_info_events" in deserialized
- pending_request_info_events = deserialized["pending_request_info_events"]
- assert "req-1" in pending_request_info_events
- assert isinstance(pending_request_info_events["req-1"]["data"]["value"]["issued_at"], str)
+ assert "req-1" in pending
+ rehydrated_1 = pending["req-1"]
+ assert isinstance(rehydrated_1.data, TimedApproval)
+ assert rehydrated_1.data.issued_at == datetime(2024, 5, 4, 12, 30, 45)
- assert "req-2" in pending_request_info_events
- assert pending_request_info_events["req-2"]["data"]["value"]["note"] == "slot-based"
+ assert "req-2" in pending
+ rehydrated_2 = pending["req-2"]
+ assert isinstance(rehydrated_2.data, SlottedApproval)
+ assert rehydrated_2.data.note == "slot-based"
+
+
+async def test_checkpoint_with_pending_request_info_events():
+ """Test that request info events are properly serialized in checkpoints and can be restored."""
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ # Use file-based storage to test full serialization
+ storage = FileCheckpointStorage(temp_dir)
+
+ # Create workflow with checkpointing enabled
+ executor = ApprovalRequiredExecutor(id="approval_executor")
+ workflow = WorkflowBuilder(start_executor=executor, checkpoint_storage=storage).build()
+
+ # Step 1: Run workflow to completion to ensure checkpoints are created
+ request_info_event: WorkflowEvent | None = None
+ async for event in workflow.run("checkpoint test operation", stream=True):
+ if event.type == "request_info":
+ request_info_event = event
+
+ # Verify request was emitted
+ assert request_info_event is not None
+ assert isinstance(request_info_event.data, UserApprovalRequest)
+ assert request_info_event.data.prompt == "Please approve the operation: checkpoint test operation"
+ assert request_info_event.source_executor_id == "approval_executor"
+
+ # Step 2: List checkpoints to find the one with our pending request
+ checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
+ assert len(checkpoints) > 0, "No checkpoints were created during workflow execution"
+
+ # Find the checkpoint with our pending request
+ checkpoint_with_request = None
+ for checkpoint in checkpoints:
+ if request_info_event.request_id in checkpoint.pending_request_info_events:
+ checkpoint_with_request = checkpoint
+ break
+
+ assert checkpoint_with_request is not None, "No checkpoint found with pending request info event"
+
+ # Step 3: Verify the pending request info event was properly serialized
+ serialized_event = checkpoint_with_request.pending_request_info_events[request_info_event.request_id]
+ assert serialized_event.data
+ assert serialized_event.request_type is UserApprovalRequest
+ assert serialized_event.request_id == request_info_event.request_id
+ assert serialized_event.source_executor_id == "approval_executor"
+
+ # Step 4: Create a fresh workflow and restore from checkpoint
+ new_executor = ApprovalRequiredExecutor(id="approval_executor")
+ restored_workflow = WorkflowBuilder(start_executor=new_executor, checkpoint_storage=storage).build()
+
+ # Step 5: Resume from checkpoint and verify the request can be continued
+ completed = False
+ restored_request_event: WorkflowEvent | None = None
+ async for event in restored_workflow.run(checkpoint_id=checkpoint_with_request.checkpoint_id, stream=True):
+ # Should re-emit the pending request info event
+ if event.type == "request_info" and event.request_id == request_info_event.request_id:
+ restored_request_event = event
+ elif event.type == "status" and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
+ completed = True
+
+ assert completed, "Workflow should reach idle with pending requests state after restoration"
+ assert restored_request_event is not None, "Restored request info event should be emitted"
+
+ # Verify the restored event matches the original
+ assert restored_request_event.source_executor_id == request_info_event.source_executor_id
+ assert isinstance(restored_request_event.data, UserApprovalRequest)
+ assert restored_request_event.data.prompt == request_info_event.data.prompt
+ assert restored_request_event.data.context == request_info_event.data.context
+
+ # Step 6: Provide response to the restored request and complete the workflow
+ final_completed = False
+ async for event in restored_workflow.run(
+ stream=True,
+ responses={
+ request_info_event.request_id: True # Approve the request
+ },
+ ):
+ if event.type == "status" and event.state == WorkflowRunState.IDLE:
+ final_completed = True
+
+ assert final_completed, "Workflow should complete after providing response to restored request"
+
+ # Step 7: Verify the executor state was properly restored and response was processed
+ assert new_executor.approval_received is True
+ expected_result = "Operation approved: Please approve the operation: checkpoint test operation"
+ assert new_executor.final_result == expected_result
+
+
+async def test_checkpoint_restore_with_responses_does_not_reemit_handled_requests():
+ """Test that request_info events are not re-emitted when responses are provided with checkpoint restore.
+
+ When calling run(checkpoint_id=..., responses=...), the workflow restores from a checkpoint
+ that contains pending request_info events. Because responses are provided for those events,
+ they should NOT be re-emitted in the event stream - they are considered "handled".
+
+ Note: The workflow's internal state tracking still sees the request_info events (before filtering),
+ so the final status may be IDLE_WITH_PENDING_REQUESTS even though the requests were handled.
+ The key behavior we're testing is that the CALLER doesn't see the request_info events.
+ """
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ # Use file-based storage to test full serialization
+ storage = FileCheckpointStorage(temp_dir)
+
+ # Create workflow with checkpointing enabled
+ executor = ApprovalRequiredExecutor(id="approval_executor")
+ workflow = WorkflowBuilder(start_executor=executor, checkpoint_storage=storage).build()
+
+ # Step 1: Run workflow until it emits a request_info event
+ request_info_event: WorkflowEvent | None = None
+ async for event in workflow.run("test pending request suppression", stream=True):
+ if event.type == "request_info":
+ request_info_event = event
+
+ assert request_info_event is not None
+ request_id = request_info_event.request_id
+
+ # Step 2: Find the checkpoint with the pending request
+ checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
+ checkpoint_with_request = None
+ for checkpoint in checkpoints:
+ if request_id in checkpoint.pending_request_info_events:
+ checkpoint_with_request = checkpoint
+ break
+
+ assert checkpoint_with_request is not None
+
+ # Step 3: Create a fresh workflow and restore from checkpoint WITH responses in one call
+ new_executor = ApprovalRequiredExecutor(id="approval_executor")
+ restored_workflow = WorkflowBuilder(start_executor=new_executor, checkpoint_storage=storage).build()
+
+ # Track all emitted events
+ emitted_events: list[WorkflowEvent] = []
+ async for event in restored_workflow.run(
+ checkpoint_id=checkpoint_with_request.checkpoint_id,
+ responses={request_id: True}, # Provide response for the pending request
+ stream=True,
+ ):
+ emitted_events.append(event)
+
+ # Step 4: Verify the request_info event was NOT re-emitted to the caller
+ reemitted_request_info_events = [
+ e for e in emitted_events if e.type == "request_info" and e.request_id == request_id
+ ]
+ assert len(reemitted_request_info_events) == 0, (
+ f"request_info event should NOT be re-emitted when response is provided. "
+ f"Found {len(reemitted_request_info_events)} request_info events with request_id={request_id}"
+ )
+
+ # Step 5: Verify the response was processed by checking executor state
+ assert new_executor.approval_received is True, "Response should have been processed by the executor"
+ assert new_executor.final_result == (
+ "Operation approved: Please approve the operation: test pending request suppression"
+ )
+
+
+async def test_checkpoint_restore_with_partial_responses_reemits_unhandled_requests():
+ """Test that only unhandled request_info events are re-emitted when partial responses are provided.
+
+ When calling run(checkpoint_id=..., responses=...) with responses for only some of the
+ pending requests, only the unhandled request_info events should be re-emitted.
+ """
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ storage = FileCheckpointStorage(temp_dir)
+
+ # Create workflow with multiple requests
+ executor = MultiRequestExecutor(id="multi_executor")
+ workflow = WorkflowBuilder(start_executor=executor, checkpoint_storage=storage).build()
+
+ # Step 1: Run workflow until it emits multiple request_info events
+ request_events: list[WorkflowEvent] = []
+ async for event in workflow.run("start batch", stream=True):
+ if event.type == "request_info":
+ request_events.append(event)
+
+ assert len(request_events) == 2
+
+ # Find the approval and calculation requests
+ approval_event = next((e for e in request_events if isinstance(e.data, UserApprovalRequest)), None)
+ calc_event = next((e for e in request_events if isinstance(e.data, CalculationRequest)), None)
+ assert approval_event is not None
+ assert calc_event is not None
+
+ # Step 2: Find the checkpoint with pending requests
+ checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
+ checkpoint_with_requests = None
+ for checkpoint in checkpoints:
+ has_approval = approval_event.request_id in checkpoint.pending_request_info_events
+ has_calc = calc_event.request_id in checkpoint.pending_request_info_events
+ if has_approval and has_calc:
+ checkpoint_with_requests = checkpoint
+ break
+
+ assert checkpoint_with_requests is not None
+
+ # Step 3: Restore from checkpoint with ONLY the approval response (not the calculation)
+ new_executor = MultiRequestExecutor(id="multi_executor")
+ restored_workflow = WorkflowBuilder(start_executor=new_executor, checkpoint_storage=storage).build()
+
+ emitted_events: list[WorkflowEvent] = []
+ async for event in restored_workflow.run(
+ checkpoint_id=checkpoint_with_requests.checkpoint_id,
+ responses={approval_event.request_id: True}, # Only respond to approval
+ stream=True,
+ ):
+ emitted_events.append(event)
+
+ # Step 4: Verify the approval request_info was NOT re-emitted
+ reemitted_approval_events = [
+ e for e in emitted_events if e.type == "request_info" and e.request_id == approval_event.request_id
+ ]
+ assert len(reemitted_approval_events) == 0, (
+ "Approval request_info should NOT be re-emitted since response was provided"
+ )
+
+ # Step 5: Verify the calculation request_info WAS re-emitted (no response provided)
+ reemitted_calc_events = [
+ e for e in emitted_events if e.type == "request_info" and e.request_id == calc_event.request_id
+ ]
+ assert len(reemitted_calc_events) == 1, (
+ "Calculation request_info SHOULD be re-emitted since no response was provided"
+ )
+
+ # Step 6: Verify workflow is in IDLE_WITH_PENDING_REQUESTS state (calc still pending)
+ status_events = [e for e in emitted_events if e.type == "status"]
+ final_status = status_events[-1] if status_events else None
+ assert final_status is not None
+ assert final_status.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, (
+ f"Workflow should be IDLE_WITH_PENDING_REQUESTS, got {final_status.state}"
+ )
diff --git a/python/packages/core/tests/workflow/test_runner.py b/python/packages/core/tests/workflow/test_runner.py
index e527ba13fa..039c61b07d 100644
--- a/python/packages/core/tests/workflow/test_runner.py
+++ b/python/packages/core/tests/workflow/test_runner.py
@@ -2,6 +2,7 @@
import asyncio
from dataclasses import dataclass
+from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -9,6 +10,9 @@ from agent_framework import (
AgentExecutorResponse,
AgentResponse,
Executor,
+ InMemoryCheckpointStorage,
+ WorkflowCheckpoint,
+ WorkflowCheckpointException,
WorkflowContext,
WorkflowConvergenceException,
WorkflowEvent,
@@ -16,6 +20,7 @@ from agent_framework import (
WorkflowRunState,
handler,
)
+from agent_framework._workflows._const import EXECUTOR_STATE_KEY
from agent_framework._workflows._edge import SingleEdgeGroup
from agent_framework._workflows._runner import Runner
from agent_framework._workflows._runner_context import (
@@ -61,7 +66,14 @@ def test_create_runner():
executor_b.id: executor_b,
}
- runner = Runner(edge_groups, executors, state=State(), ctx=InProcRunnerContext())
+ runner = Runner(
+ edge_groups,
+ executors,
+ state=State(),
+ ctx=InProcRunnerContext(),
+ workflow_name="test_name",
+ graph_signature_hash="test_hash",
+ )
assert runner.context is not None and isinstance(runner.context, RunnerContext)
@@ -84,7 +96,7 @@ async def test_runner_run_until_convergence():
state = State()
ctx = InProcRunnerContext()
- runner = Runner(edges, executors, state, ctx)
+ runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
result: int | None = None
await executor_a.execute(
@@ -122,7 +134,7 @@ async def test_runner_run_until_convergence_not_completed():
state = State()
ctx = InProcRunnerContext()
- runner = Runner(edges, executors, state, ctx, max_iterations=5)
+ runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash", max_iterations=5)
await executor_a.execute(
MockMessage(data=0),
@@ -156,7 +168,7 @@ async def test_runner_already_running():
state = State()
ctx = InProcRunnerContext()
- runner = Runner(edges, executors, state, ctx)
+ runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
await executor_a.execute(
MockMessage(data=0),
@@ -176,7 +188,7 @@ async def test_runner_already_running():
async def test_runner_emits_runner_completion_for_agent_response_without_targets():
ctx = InProcRunnerContext()
- runner = Runner([], {}, State(), ctx)
+ runner = Runner([], {}, State(), ctx, "test_name", graph_signature_hash="test_hash")
await ctx.send_message(
WorkflowMessage(
@@ -228,7 +240,7 @@ async def test_runner_cancellation_stops_active_executor():
shared_state = State()
ctx = InProcRunnerContext()
- runner = Runner(edges, executors, shared_state, ctx)
+ runner = Runner(edges, executors, shared_state, ctx, "test_name", graph_signature_hash="test_hash")
await executor_a.execute(
MockMessage(data=0),
@@ -259,3 +271,579 @@ async def test_runner_cancellation_stops_active_executor():
assert executor_a.completed_count == 1
assert executor_b.started_count == 1
assert executor_b.completed_count == 0 # Should NOT have completed due to cancellation
+
+
+class FailingExecutor(Executor):
+ """An executor that fails during execution."""
+
+ def __init__(self, id: str, fail_on_data: int = 5):
+ super().__init__(id=id)
+ self.fail_on_data = fail_on_data
+
+ @handler
+ async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
+ if message.data == self.fail_on_data:
+ raise RuntimeError("Simulated executor failure")
+ await ctx.send_message(MockMessage(data=message.data + 1))
+
+
+async def test_runner_iteration_exception_drains_events():
+ """Test that when an executor raises an exception, events are drained before propagating."""
+ executor_a = FailingExecutor(id="executor_a", fail_on_data=2)
+ executor_b = MockExecutor(id="executor_b")
+
+ edges = [
+ SingleEdgeGroup(executor_a.id, executor_b.id),
+ SingleEdgeGroup(executor_b.id, executor_a.id),
+ ]
+
+ executors: dict[str, Executor] = {
+ executor_a.id: executor_a,
+ executor_b.id: executor_b,
+ }
+ state = State()
+ ctx = InProcRunnerContext()
+
+ runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
+
+ await executor_a.execute(
+ MockMessage(data=0),
+ ["START"],
+ state,
+ ctx,
+ )
+
+ events: list[WorkflowEvent] = []
+ with pytest.raises(RuntimeError, match="Simulated executor failure"):
+ async for event in runner.run_until_convergence():
+ events.append(event)
+
+ # There should be some events emitted before the failure
+ assert len(events) > 0
+
+
+async def test_runner_reset_iteration_count():
+ """Test that reset_iteration_count works correctly."""
+ executor_a = MockExecutor(id="executor_a")
+ state = State()
+ ctx = InProcRunnerContext()
+
+ runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
+ runner._iteration = 10
+
+ runner.reset_iteration_count()
+
+ assert runner._iteration == 0
+
+
+class CheckpointingContext(InProcRunnerContext):
+ """A context that supports checkpointing for testing."""
+
+ def __init__(self, storage: InMemoryCheckpointStorage | None = None):
+ super().__init__()
+ self._storage = storage or InMemoryCheckpointStorage()
+ self._checkpointing_enabled = True
+
+ def has_checkpointing(self) -> bool:
+ return self._checkpointing_enabled
+
+ async def create_checkpoint(
+ self,
+ workflow_name: str,
+ graph_signature_hash: str,
+ state: State,
+ previous_checkpoint_id: str | None,
+ iteration: int,
+ ) -> str:
+ checkpoint = WorkflowCheckpoint(
+ workflow_name=workflow_name,
+ graph_signature_hash=graph_signature_hash,
+ state=state.export(),
+ previous_checkpoint_id=previous_checkpoint_id,
+ iteration_count=iteration,
+ )
+ return await self._storage.save(checkpoint)
+
+ async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
+ try:
+ return await self._storage.load(checkpoint_id)
+ except WorkflowCheckpointException:
+ return None
+
+ async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None:
+ # Restore messages from checkpoint
+ for source_id, messages in checkpoint.messages.items():
+ for msg_data in messages:
+ await self.send_message(WorkflowMessage(data=msg_data, source_id=source_id))
+
+
+class FailingCheckpointContext(InProcRunnerContext):
+ """A context that fails during checkpoint creation."""
+
+ def has_checkpointing(self) -> bool:
+ return True
+
+ async def create_checkpoint(
+ self,
+ workflow_name: str,
+ graph_signature_hash: str,
+ state: State,
+ previous_checkpoint_id: str | None,
+ iteration: int,
+ ) -> str:
+ raise RuntimeError("Simulated checkpoint failure")
+
+
+async def test_runner_checkpoint_creation_failure():
+ """Test that checkpoint creation failure is handled gracefully."""
+ executor_a = MockExecutor(id="executor_a")
+ executor_b = MockExecutor(id="executor_b")
+
+ edges = [
+ SingleEdgeGroup(executor_a.id, executor_b.id),
+ SingleEdgeGroup(executor_b.id, executor_a.id),
+ ]
+
+ executors: dict[str, Executor] = {
+ executor_a.id: executor_a,
+ executor_b.id: executor_b,
+ }
+ state = State()
+ ctx = FailingCheckpointContext()
+
+ runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
+
+ await executor_a.execute(
+ MockMessage(data=0),
+ ["START"],
+ state,
+ ctx,
+ )
+
+ # Should complete without raising, even though checkpointing fails
+ result: int | None = None
+ async for event in runner.run_until_convergence():
+ if event.type == "output":
+ result = event.data
+
+ assert result == 10
+
+
+async def test_runner_restore_from_checkpoint_with_external_storage():
+ """Test restoring from checkpoint using external storage when context has no checkpointing."""
+ executor_a = MockExecutor(id="executor_a")
+ executor_b = MockExecutor(id="executor_b")
+
+ edges = [
+ SingleEdgeGroup(executor_a.id, executor_b.id),
+ SingleEdgeGroup(executor_b.id, executor_a.id),
+ ]
+
+ executors: dict[str, Executor] = {
+ executor_a.id: executor_a,
+ executor_b.id: executor_b,
+ }
+ state = State()
+ ctx = InProcRunnerContext() # No checkpointing enabled
+
+ runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
+
+ # Create a checkpoint manually
+ storage = InMemoryCheckpointStorage()
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test_name",
+ graph_signature_hash="test_hash",
+ state={"test_key": "test_value"},
+ iteration_count=5,
+ )
+ checkpoint_id = await storage.save(checkpoint)
+
+ # Restore using external storage
+ await runner.restore_from_checkpoint(checkpoint_id, checkpoint_storage=storage)
+
+ assert runner._resumed_from_checkpoint is True
+ assert runner._iteration == 5
+ assert state.get("test_key") == "test_value"
+
+
+async def test_runner_restore_from_checkpoint_no_storage():
+ """Test that restore fails when no checkpointing and no external storage."""
+ state = State()
+ ctx = InProcRunnerContext()
+
+ runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash")
+
+ with pytest.raises(WorkflowCheckpointException, match="Cannot load checkpoint"):
+ await runner.restore_from_checkpoint("nonexistent-id")
+
+
+async def test_runner_restore_from_checkpoint_not_found():
+ """Test that restore fails when checkpoint is not found."""
+ storage = InMemoryCheckpointStorage()
+ ctx = CheckpointingContext(storage)
+ state = State()
+
+ runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash")
+
+ with pytest.raises(WorkflowCheckpointException, match="not found"):
+ await runner.restore_from_checkpoint("nonexistent-id")
+
+
+async def test_runner_restore_from_checkpoint_graph_hash_mismatch():
+ """Test that restore fails when graph hash doesn't match."""
+ storage = InMemoryCheckpointStorage()
+ ctx = CheckpointingContext(storage)
+ state = State()
+
+ runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="current_hash")
+
+ # Create a checkpoint with a different graph hash
+ checkpoint = WorkflowCheckpoint(
+ workflow_name="test_name",
+ graph_signature_hash="different_hash",
+ state={},
+ iteration_count=5,
+ )
+ checkpoint_id = await storage.save(checkpoint)
+
+ with pytest.raises(WorkflowCheckpointException, match="Workflow graph has changed"):
+ await runner.restore_from_checkpoint(checkpoint_id)
+
+
+async def test_runner_restore_from_checkpoint_generic_exception():
+ """Test that generic exceptions during restore are wrapped in WorkflowCheckpointException."""
+ state = State()
+
+ # Create a mock context that raises a generic exception
+ mock_ctx = MagicMock(spec=InProcRunnerContext)
+ mock_ctx.has_checkpointing.return_value = True
+ mock_ctx.load_checkpoint = AsyncMock(side_effect=ValueError("Unexpected error"))
+
+ runner = Runner([], {}, state, mock_ctx, "test_name", graph_signature_hash="test_hash")
+
+ with pytest.raises(WorkflowCheckpointException, match="Failed to restore from checkpoint"):
+ await runner.restore_from_checkpoint("some-id")
+
+
+async def test_runner_restore_executor_states_invalid_states_type():
+ """Test that restore fails when executor states is not a dict."""
+ executor_a = MockExecutor(id="executor_a")
+ state = State()
+ state.set(EXECUTOR_STATE_KEY, "not_a_dict")
+ state.commit()
+
+ ctx = InProcRunnerContext()
+ runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
+
+ with pytest.raises(WorkflowCheckpointException, match="not a dictionary"):
+ await runner._restore_executor_states()
+
+
+async def test_runner_restore_executor_states_invalid_executor_id_type():
+ """Test that restore fails when executor ID is not a string."""
+ executor_a = MockExecutor(id="executor_a")
+ state = State()
+ state.set(EXECUTOR_STATE_KEY, {123: {"key": "value"}}) # Non-string key
+ state.commit()
+
+ ctx = InProcRunnerContext()
+ runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
+
+ with pytest.raises(WorkflowCheckpointException, match="not a string"):
+ await runner._restore_executor_states()
+
+
+async def test_runner_restore_executor_states_invalid_state_type():
+ """Test that restore fails when executor state is not a dict[str, Any]."""
+ executor_a = MockExecutor(id="executor_a")
+ state = State()
+ state.set(EXECUTOR_STATE_KEY, {"executor_a": "not_a_dict"})
+ state.commit()
+
+ ctx = InProcRunnerContext()
+ runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
+
+ with pytest.raises(WorkflowCheckpointException, match="not a dict"):
+ await runner._restore_executor_states()
+
+
+async def test_runner_restore_executor_states_invalid_state_keys():
+ """Test that restore fails when executor state dict has non-string keys."""
+ executor_a = MockExecutor(id="executor_a")
+ state = State()
+ state.set(EXECUTOR_STATE_KEY, {"executor_a": {123: "value"}}) # Non-string key in state
+ state.commit()
+
+ ctx = InProcRunnerContext()
+ runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
+
+ with pytest.raises(WorkflowCheckpointException, match="not a dict"):
+ await runner._restore_executor_states()
+
+
+async def test_runner_restore_executor_states_missing_executor():
+ """Test that restore fails when executor is not found."""
+ state = State()
+ state.set(EXECUTOR_STATE_KEY, {"missing_executor": {"key": "value"}})
+ state.commit()
+
+ ctx = InProcRunnerContext()
+ runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash")
+
+ with pytest.raises(WorkflowCheckpointException, match="not found during state restoration"):
+ await runner._restore_executor_states()
+
+
+async def test_runner_set_executor_state_invalid_existing_states():
+ """Test that _set_executor_state fails when existing states is not a dict."""
+ executor_a = MockExecutor(id="executor_a")
+ state = State()
+ state.set(EXECUTOR_STATE_KEY, "not_a_dict")
+
+ ctx = InProcRunnerContext()
+ runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
+
+ with pytest.raises(WorkflowCheckpointException, match="not a dictionary"):
+ await runner._set_executor_state("executor_a", {"key": "value"})
+
+
+async def test_runner_with_pre_loop_events():
+ """Test that pre-loop events are yielded correctly."""
+ ctx = InProcRunnerContext()
+ state = State()
+
+ runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash")
+
+ # Add an event before running
+ await ctx.add_event(WorkflowEvent.output(executor_id="test_executor", data="pre-loop-output"))
+
+ events: list[WorkflowEvent] = []
+ async for event in runner.run_until_convergence():
+ events.append(event)
+
+ # Should have the pre-loop output event
+ output_events = [e for e in events if e.type == "output"]
+ assert len(output_events) == 1
+ assert output_events[0].data == "pre-loop-output"
+
+
+class EventEmittingExecutor(Executor):
+ """An executor that emits events during execution."""
+
+ @handler
+ async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
+ # Emit event during processing
+ await ctx.yield_output(f"processed-{message.data}")
+ if message.data < 3:
+ await ctx.send_message(MockMessage(data=message.data + 1))
+
+
+async def test_runner_drains_straggler_events():
+ """Test that events emitted at the end of iteration are drained."""
+ executor_a = EventEmittingExecutor(id="executor_a")
+ executor_b = EventEmittingExecutor(id="executor_b")
+
+ edges = [
+ SingleEdgeGroup(executor_a.id, executor_b.id),
+ SingleEdgeGroup(executor_b.id, executor_a.id),
+ ]
+
+ executors: dict[str, Executor] = {
+ executor_a.id: executor_a,
+ executor_b.id: executor_b,
+ }
+ state = State()
+ ctx = InProcRunnerContext()
+
+ runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
+
+ await executor_a.execute(
+ MockMessage(data=0),
+ ["START"],
+ state,
+ ctx,
+ )
+
+ events: list[WorkflowEvent] = []
+ async for event in runner.run_until_convergence():
+ events.append(event)
+
+ # Should have output events from both executors
+ output_events = [e for e in events if e.type == "output"]
+ assert len(output_events) > 0
+
+
+async def test_runner_restore_executor_states_no_states():
+ """Test that restore does nothing when there are no executor states."""
+ executor_a = MockExecutor(id="executor_a")
+ state = State() # No executor states set
+ state.commit()
+
+ ctx = InProcRunnerContext()
+ runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
+
+ # Should complete without error when no executor states exist
+ await runner._restore_executor_states()
+
+
+async def test_runner_checkpoint_with_resumed_flag():
+ """Test that resumed flag prevents initial checkpoint creation."""
+ storage = InMemoryCheckpointStorage()
+ ctx = CheckpointingContext(storage)
+ executor_a = MockExecutor(id="executor_a")
+ executor_b = MockExecutor(id="executor_b")
+
+ edges = [
+ SingleEdgeGroup(executor_a.id, executor_b.id),
+ SingleEdgeGroup(executor_b.id, executor_a.id),
+ ]
+
+ executors: dict[str, Executor] = {
+ executor_a.id: executor_a,
+ executor_b.id: executor_b,
+ }
+ state = State()
+
+ runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
+ runner._mark_resumed(5)
+
+ # Add a message to trigger the checkpoint creation path
+ await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id="START"))
+
+ await executor_a.execute(
+ MockMessage(data=8),
+ ["START"],
+ state,
+ ctx,
+ )
+
+ # Run until convergence
+ async for _ in runner.run_until_convergence():
+ pass
+
+ # After completing, resumed flag should be reset
+ assert runner._resumed_from_checkpoint is False
+
+
+class ExecutorThatFailsWithEvents(Executor):
+ """An executor that emits events and then raises an exception after receiving messages."""
+
+ def __init__(self, id: str, runner_ctx: RunnerContext, fail_on_iteration: int = 1):
+ super().__init__(id=id)
+ self._runner_ctx = runner_ctx
+ self._fail_on_iteration = fail_on_iteration
+ self._iteration_count = 0
+
+ @handler
+ async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
+ self._iteration_count += 1
+ # First emit an output event to the workflow context
+ await ctx.yield_output(f"output-before-failure-{message.data}")
+ # Add some events directly to the runner context
+ await self._runner_ctx.add_event(WorkflowEvent.output(executor_id=self.id, data="pending-event"))
+ # Fail on the specified iteration
+ if self._iteration_count >= self._fail_on_iteration:
+ raise RuntimeError("Executor failed with pending events")
+ # Otherwise, send to next
+ await ctx.send_message(MockMessage(data=message.data + 1))
+
+
+class PassthroughExecutor(Executor):
+ """An executor that passes messages through to the failing executor."""
+
+ @handler
+ async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
+ await ctx.send_message(MockMessage(data=message.data))
+
+
+async def test_runner_drains_events_on_iteration_exception():
+ """Test that events are drained when iteration task raises an exception (lines 128-129)."""
+ ctx = InProcRunnerContext()
+ # executor_b will fail with pending events after receiving a message
+ executor_a = PassthroughExecutor(id="executor_a")
+ executor_b = ExecutorThatFailsWithEvents(id="executor_b", runner_ctx=ctx, fail_on_iteration=1)
+
+ edges = [
+ SingleEdgeGroup(executor_a.id, executor_b.id),
+ ]
+
+ executors: dict[str, Executor] = {
+ executor_a.id: executor_a,
+ executor_b.id: executor_b,
+ }
+ state = State()
+
+ runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
+
+ # Execute through executor_a which will pass to executor_b during the runner iteration
+ await executor_a.execute(
+ MockMessage(data=0),
+ ["START"],
+ state,
+ ctx,
+ )
+
+ events: list[WorkflowEvent] = []
+ with pytest.raises(RuntimeError, match="Executor failed with pending events"):
+ async for event in runner.run_until_convergence():
+ events.append(event)
+
+ # Events should include the ones emitted before the exception
+ output_events = [e for e in events if e.type == "output"]
+ # Should have drained the pending events before propagating the exception
+ assert len(output_events) >= 1
+
+
+class SlowEventEmittingExecutor(Executor):
+ """An executor that emits events with delays to test straggler event draining."""
+
+ def __init__(self, id: str, iterations_to_emit: int = 2):
+ super().__init__(id=id)
+ self.iterations_to_emit = iterations_to_emit
+ self.current_iteration = 0
+
+ @handler
+ async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
+ self.current_iteration += 1
+ # Emit output event
+ await ctx.yield_output(f"iteration-{self.current_iteration}")
+ # Continue sending messages until we reach the target iterations
+ if self.current_iteration < self.iterations_to_emit:
+ await ctx.send_message(MockMessage(data=message.data + 1))
+
+
+async def test_runner_drains_straggler_events_at_iteration_end():
+ """Test that events emitted at the very end of iteration are drained (lines 135-136)."""
+ # Create executors that ping-pong messages and emit events
+ executor_a = SlowEventEmittingExecutor(id="executor_a", iterations_to_emit=3)
+ executor_b = SlowEventEmittingExecutor(id="executor_b", iterations_to_emit=3)
+
+ edges = [
+ SingleEdgeGroup(executor_a.id, executor_b.id),
+ SingleEdgeGroup(executor_b.id, executor_a.id),
+ ]
+
+ executors: dict[str, Executor] = {
+ executor_a.id: executor_a,
+ executor_b.id: executor_b,
+ }
+ state = State()
+ ctx = InProcRunnerContext()
+
+ runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
+
+ await executor_a.execute(
+ MockMessage(data=0),
+ ["START"],
+ state,
+ ctx,
+ )
+
+ events: list[WorkflowEvent] = []
+ async for event in runner.run_until_convergence():
+ events.append(event)
+
+ # Check that output events were collected (including straggler events)
+ output_events = [e for e in events if e.type == "output"]
+ # We should have output events from both executors
+ assert len(output_events) >= 2
diff --git a/python/packages/core/tests/workflow/test_serialization.py b/python/packages/core/tests/workflow/test_serialization.py
index f579c1be76..55284db407 100644
--- a/python/packages/core/tests/workflow/test_serialization.py
+++ b/python/packages/core/tests/workflow/test_serialization.py
@@ -647,12 +647,11 @@ class TestSerializationWorkflowClasses:
# Test 2: Without name and description (defaults)
workflow2 = WorkflowBuilder(start_executor=SampleExecutor(id="e2")).build()
- assert workflow2.name is None
+ assert workflow2.name is not None
assert workflow2.description is None
data2 = workflow2.to_dict()
- assert "name" not in data2 # Should not include None values
- assert "description" not in data2
+ assert "description" not in data2 # Should not include None values
# Test 3: With only name (no description)
workflow3 = WorkflowBuilder(name="Named Only", start_executor=SampleExecutor(id="e3")).build()
diff --git a/python/packages/core/tests/workflow/test_sub_workflow.py b/python/packages/core/tests/workflow/test_sub_workflow.py
index 55afad880f..666e82f4d7 100644
--- a/python/packages/core/tests/workflow/test_sub_workflow.py
+++ b/python/packages/core/tests/workflow/test_sub_workflow.py
@@ -595,7 +595,7 @@ async def test_sub_workflow_checkpoint_restore_no_duplicate_requests() -> None:
assert first_request_id is not None
# Get checkpoint
- checkpoints = await storage.list_checkpoints(workflow1.id)
+ checkpoints = await storage.list_checkpoints(workflow_name=workflow1.name)
checkpoint_id = max(checkpoints, key=lambda cp: cp.iteration_count).checkpoint_id
# Step 2: Resume workflow from checkpoint
diff --git a/python/packages/core/tests/workflow/test_workflow.py b/python/packages/core/tests/workflow/test_workflow.py
index 6728bcfcb1..744ad827ea 100644
--- a/python/packages/core/tests/workflow/test_workflow.py
+++ b/python/packages/core/tests/workflow/test_workflow.py
@@ -335,12 +335,9 @@ async def test_workflow_run_stream_from_checkpoint_invalid_checkpoint(
)
# Attempt to run from non-existent checkpoint should fail
- try:
+ with pytest.raises(WorkflowCheckpointException, match="No checkpoint found with ID nonexistent_checkpoint_id"):
async for _ in workflow.run(checkpoint_id="nonexistent_checkpoint_id", stream=True):
pass
- raise AssertionError("Expected WorkflowCheckpointException to be raised")
- except WorkflowCheckpointException as e:
- assert str(e) == "Checkpoint nonexistent_checkpoint_id not found"
async def test_workflow_run_stream_from_checkpoint_with_external_storage(
@@ -354,12 +351,14 @@ async def test_workflow_run_stream_from_checkpoint_with_external_storage(
from agent_framework import WorkflowCheckpoint
test_checkpoint = WorkflowCheckpoint(
- workflow_id="test-workflow",
+ workflow_name="test-workflow",
+ graph_signature_hash="test-graph-signature",
+ previous_checkpoint_id=None,
messages={},
state={},
iteration_count=0,
)
- checkpoint_id = await storage.save_checkpoint(test_checkpoint)
+ checkpoint_id = await storage.save(test_checkpoint)
# Create a workflow WITHOUT checkpointing
workflow_without_checkpointing = (
@@ -385,17 +384,6 @@ async def test_workflow_run_from_checkpoint_non_streaming(simple_executor: Execu
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
- # Create a test checkpoint manually in storage
- from agent_framework import WorkflowCheckpoint
-
- test_checkpoint = WorkflowCheckpoint(
- workflow_id="test-workflow",
- messages={},
- state={},
- iteration_count=0,
- )
- checkpoint_id = await storage.save_checkpoint(test_checkpoint)
-
# Build workflow with checkpointing
workflow = (
WorkflowBuilder(start_executor=simple_executor, checkpoint_storage=storage)
@@ -403,6 +391,19 @@ async def test_workflow_run_from_checkpoint_non_streaming(simple_executor: Execu
.build()
)
+ # Create a test checkpoint manually in storage
+ from agent_framework import WorkflowCheckpoint
+
+ test_checkpoint = WorkflowCheckpoint(
+ workflow_name=workflow.name,
+ graph_signature_hash=workflow.graph_signature_hash,
+ previous_checkpoint_id=None,
+ messages={},
+ state={},
+ iteration_count=0,
+ )
+ checkpoint_id = await storage.save(test_checkpoint)
+
# Test non-streaming run method with checkpoint_id
result = await workflow.run(checkpoint_id=checkpoint_id)
assert isinstance(result, list) # Should return WorkflowRunResult which extends list
@@ -416,11 +417,19 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
+ # Build workflow with checkpointing
+ workflow = (
+ WorkflowBuilder(start_executor=simple_executor, checkpoint_storage=storage)
+ .add_edge(simple_executor, simple_executor)
+ .build()
+ )
+
# Create a test checkpoint manually in storage
from agent_framework import WorkflowCheckpoint
test_checkpoint = WorkflowCheckpoint(
- workflow_id="test-workflow",
+ workflow_name=workflow.name,
+ graph_signature_hash=workflow.graph_signature_hash,
messages={},
state={},
pending_request_info_events={
@@ -429,18 +438,11 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(
source_executor_id=simple_executor.id,
request_data="Mock",
response_type=str,
- ).to_dict(),
+ ),
},
iteration_count=0,
)
- checkpoint_id = await storage.save_checkpoint(test_checkpoint)
-
- # Build workflow with checkpointing
- workflow = (
- WorkflowBuilder(start_executor=simple_executor, checkpoint_storage=storage)
- .add_edge(simple_executor, simple_executor)
- .build()
- )
+ checkpoint_id = await storage.save(test_checkpoint)
# Resume from checkpoint - pending request events should be emitted
events: list[WorkflowEvent] = []
@@ -542,7 +544,7 @@ async def test_workflow_checkpoint_runtime_only_configuration(
assert result.get_final_state() == WorkflowRunState.IDLE
# Verify checkpoints were created
- checkpoints = await storage.list_checkpoints()
+ checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
assert len(checkpoints) > 0
# Find a superstep checkpoint to resume from
@@ -592,8 +594,8 @@ async def test_workflow_checkpoint_runtime_overrides_buildtime(
assert result is not None
# Verify checkpoints were created in runtime storage, not build-time storage
- buildtime_checkpoints = await buildtime_storage.list_checkpoints()
- runtime_checkpoints = await runtime_storage.list_checkpoints()
+ buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=workflow.name)
+ runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=workflow.name)
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py
index 1ccc400f92..2a1532502b 100644
--- a/python/packages/core/tests/workflow/test_workflow_agent.py
+++ b/python/packages/core/tests/workflow/test_workflow_agent.py
@@ -607,7 +607,7 @@ class TestWorkflowAgent:
# Drain workflow events to get checkpoint
# The workflow should have created checkpoints
- checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
+ checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
assert len(checkpoints) > 0, "Checkpoints should have been created when checkpoint_storage is provided"
async def test_agent_executor_output_response_false_filters_streaming_events(self):
diff --git a/python/packages/core/tests/workflow/test_workflow_observability.py b/python/packages/core/tests/workflow/test_workflow_observability.py
index d5c20ad429..b2260abe63 100644
--- a/python/packages/core/tests/workflow/test_workflow_observability.py
+++ b/python/packages/core/tests/workflow/test_workflow_observability.py
@@ -306,8 +306,8 @@ async def test_end_to_end_workflow_tracing(span_exporter: InMemorySpanExporter)
assert len(build_spans_with_metadata) == 1
metadata_build_span = build_spans_with_metadata[0]
assert metadata_build_span.attributes is not None
- assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_NAME) == "Test Pipeline"
- assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_DESCRIPTION) == "Test workflow description"
+ assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_BUILDER_NAME) == "Test Pipeline"
+ assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_BUILDER_DESCRIPTION) == "Test workflow description"
# Clear spans to separate build from run tracing
span_exporter.clear()
@@ -451,14 +451,14 @@ async def test_message_trace_context_serialization(span_exporter: InMemorySpanEx
await ctx.send_message(message)
# Create a checkpoint that includes the message
- checkpoint_id = await ctx.create_checkpoint(State(), 0)
+ checkpoint_id = await ctx.create_checkpoint("test_name", "test_hash", State(), None, 0)
checkpoint = await ctx.load_checkpoint(checkpoint_id)
assert checkpoint is not None
# Check serialized message includes trace context
serialized_msg = checkpoint.messages["source"][0]
- assert serialized_msg["trace_contexts"] == [{"traceparent": "00-trace-span-01"}]
- assert serialized_msg["source_span_ids"] == ["span123"]
+ assert serialized_msg.trace_contexts == [{"traceparent": "00-trace-span-01"}]
+ assert serialized_msg.source_span_ids == ["span123"]
# Test deserialization
await ctx.apply_checkpoint(checkpoint)
diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py
index f3bbb6d87a..001f6f511e 100644
--- a/python/packages/declarative/agent_framework_declarative/_loader.py
+++ b/python/packages/declarative/agent_framework_declarative/_loader.py
@@ -605,10 +605,11 @@ class AgentFactory:
# Parse tools
tools = self._parse_tools(prompt_agent.tools) if prompt_agent.tools else None
- # Parse response format
- response_format = None
+ # Parse response format into default_options
+ default_options: dict[str, Any] | None = None
if prompt_agent.outputSchema:
response_format = _create_model_from_json_schema("agent", prompt_agent.outputSchema.to_json_schema())
+ default_options = {"response_format": response_format}
# Create the agent using the provider
# The provider's create_agent returns a Agent directly
@@ -620,7 +621,7 @@ class AgentFactory:
instructions=prompt_agent.instructions,
description=prompt_agent.description,
tools=tools,
- response_format=response_format,
+ default_options=default_options,
),
)
diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_actions_agents.py b/python/packages/declarative/agent_framework_declarative/_workflows/_actions_agents.py
index b7c05b8607..e34f6e06f8 100644
--- a/python/packages/declarative/agent_framework_declarative/_workflows/_actions_agents.py
+++ b/python/packages/declarative/agent_framework_declarative/_workflows/_actions_agents.py
@@ -327,6 +327,16 @@ async def handle_invoke_azure_agent(ctx: ActionContext) -> AsyncGenerator[Workfl
max_iterations = 100 # Safety limit
# Start external loop if configured
+ # Build options for kwargs propagation to agent tools
+ run_kwargs = ctx.run_kwargs
+ options: dict[str, Any] | None = None
+ if run_kwargs:
+ # Merge caller-provided options to avoid duplicate keyword argument
+ options = dict(run_kwargs.get("options") or {})
+ options["additional_function_arguments"] = run_kwargs
+ # Exclude 'options' from splat to avoid TypeError on duplicate keyword
+ run_kwargs = {k: v for k, v in run_kwargs.items() if k != "options"}
+
while True:
# Invoke the agent
try:
@@ -337,7 +347,7 @@ async def handle_invoke_azure_agent(ctx: ActionContext) -> AsyncGenerator[Workfl
updates: list[Any] = []
tool_calls: list[Any] = []
- async for chunk in agent.run(messages, stream=True):
+ async for chunk in agent.run(messages, stream=True, options=options, **run_kwargs):
updates.append(chunk)
# Yield streaming events for text chunks
@@ -403,7 +413,7 @@ async def handle_invoke_azure_agent(ctx: ActionContext) -> AsyncGenerator[Workfl
except TypeError:
# Agent doesn't support streaming, fall back to non-streaming
- response = await agent.run(messages)
+ response = await agent.run(messages, options=options, **run_kwargs)
text = response.text
response_messages = response.messages
@@ -570,6 +580,16 @@ async def handle_invoke_prompt_agent(ctx: ActionContext) -> AsyncGenerator[Workf
logger.debug(f"InvokePromptAgent: calling '{agent_name}' with {len(messages)} messages")
+ # Build options for kwargs propagation to agent tools
+ prompt_run_kwargs = ctx.run_kwargs
+ prompt_options: dict[str, Any] | None = None
+ if prompt_run_kwargs:
+ # Merge caller-provided options to avoid duplicate keyword argument
+ prompt_options = dict(prompt_run_kwargs.get("options") or {})
+ prompt_options["additional_function_arguments"] = prompt_run_kwargs
+ # Exclude 'options' from splat to avoid TypeError on duplicate keyword
+ prompt_run_kwargs = {k: v for k, v in prompt_run_kwargs.items() if k != "options"}
+
# Invoke the agent
try:
if hasattr(agent, "run"):
@@ -577,7 +597,7 @@ async def handle_invoke_prompt_agent(ctx: ActionContext) -> AsyncGenerator[Workf
try:
updates: list[Any] = []
- async for chunk in agent.run(messages, stream=True):
+ async for chunk in agent.run(messages, stream=True, options=prompt_options, **prompt_run_kwargs):
updates.append(chunk)
if hasattr(chunk, "text") and chunk.text:
@@ -607,7 +627,7 @@ async def handle_invoke_prompt_agent(ctx: ActionContext) -> AsyncGenerator[Workf
except TypeError:
# Agent doesn't support streaming, fall back to non-streaming
- response = await agent.run(messages)
+ response = await agent.run(messages, options=prompt_options, **prompt_run_kwargs)
text = response.text
response_messages = response.messages
diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py
index 9bb868135b..229f6ea3b0 100644
--- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py
+++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py
@@ -37,7 +37,13 @@ from agent_framework._workflows import (
WorkflowContext,
)
from agent_framework._workflows._state import State
-from powerfx import Engine
+
+try:
+ from powerfx import Engine
+except (ImportError, RuntimeError):
+ # ImportError: powerfx package not installed
+ # RuntimeError: .NET runtime not available or misconfigured
+ Engine = None # type: ignore[assignment, misc]
if sys.version_info >= (3, 11):
from typing import TypedDict # type: ignore # pragma: no cover
@@ -339,7 +345,8 @@ class DeclarativeWorkflowState:
undefined variables (matching legacy fallback parser behavior).
Raises:
- ImportError: If the powerfx package is not installed.
+ RuntimeError: If the powerfx package is not installed and the
+ expression requires PowerFx evaluation.
"""
if not expression:
return expression
@@ -363,6 +370,13 @@ class DeclarativeWorkflowState:
# Replace them with their evaluated results before sending to PowerFx
formula = self._preprocess_custom_functions(formula)
+ if Engine is None:
+ raise RuntimeError(
+ f"PowerFx is not available (dotnet runtime not installed). "
+ f"Expression '={formula[:80]}' cannot be evaluated. "
+ f"Install dotnet and the powerfx package for full PowerFx support."
+ )
+
engine = Engine()
symbols = self._to_powerfx_symbols()
try:
diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py
index f28d283e60..44c9e958c2 100644
--- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py
+++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py
@@ -656,10 +656,22 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
if isinstance(messages_for_agent, list) and messages_for_agent:
_validate_conversation_history(messages_for_agent, agent_name)
+ # Retrieve kwargs passed to workflow.run() so they propagate to agent tools
+ from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY
+
+ run_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})
+ options: dict[str, Any] | None = None
+ if run_kwargs:
+ # Merge caller-provided options to avoid duplicate keyword argument
+ options = dict(run_kwargs.get("options") or {})
+ options["additional_function_arguments"] = run_kwargs
+ # Exclude 'options' from splat to avoid TypeError on duplicate keyword
+ run_kwargs = {k: v for k, v in run_kwargs.items() if k != "options"}
+
# Use run() method to get properly structured messages (including tool calls and results)
# This is critical for multi-turn conversations where tool calls must be followed
# by their results in the message history
- result: Any = await agent.run(messages_for_agent)
+ result: Any = await agent.run(messages_for_agent, options=options, **run_kwargs)
if hasattr(result, "text") and result.text:
accumulated_response = str(result.text)
if auto_send:
diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_handlers.py b/python/packages/declarative/agent_framework_declarative/_workflows/_handlers.py
index cc529a2c8a..c8a2039073 100644
--- a/python/packages/declarative/agent_framework_declarative/_workflows/_handlers.py
+++ b/python/packages/declarative/agent_framework_declarative/_workflows/_handlers.py
@@ -10,7 +10,7 @@ has a corresponding handler registered via the @action_handler decorator.
from __future__ import annotations
from collections.abc import AsyncGenerator, Callable
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
from agent_framework import get_logger
@@ -44,6 +44,9 @@ class ActionContext:
bindings: dict[str, Any]
"""Function bindings for tool calls."""
+ run_kwargs: dict[str, Any] = field(default_factory=dict)
+ """Kwargs from workflow.run() to forward to agent invocations."""
+
@property
def action_id(self) -> str | None:
"""Get the action's unique identifier."""
diff --git a/python/packages/declarative/tests/test_declarative_loader.py b/python/packages/declarative/tests/test_declarative_loader.py
index 8b70634aeb..a200a69310 100644
--- a/python/packages/declarative/tests/test_declarative_loader.py
+++ b/python/packages/declarative/tests/test_declarative_loader.py
@@ -1,8 +1,10 @@
# Copyright (c) Microsoft. All rights reserved.
+import builtins
import sys
from pathlib import Path
from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import yaml
@@ -905,3 +907,105 @@ tools:
# Verify project_connection_id is set from connection name
assert mcp_tool.get("project_connection_id") == "my-oauth-connection"
+
+
+class TestProviderResponseFormat:
+ """response_format from outputSchema must be passed inside default_options."""
+
+ @staticmethod
+ def _make_mock_prompt_agent(*, with_output_schema: bool = False) -> MagicMock:
+ """Create a mock PromptAgent to avoid serialization complexity."""
+ mock_model = MagicMock()
+ mock_model.id = "gpt-4"
+ mock_model.connection = None
+
+ agent = MagicMock()
+ agent.name = "test-agent"
+ agent.description = "test"
+ agent.instructions = "be helpful"
+ agent.model = mock_model
+ agent.tools = None
+
+ if with_output_schema:
+ mock_schema = MagicMock()
+ mock_schema.to_json_schema.return_value = {
+ "type": "object",
+ "properties": {"answer": {"type": "string"}},
+ }
+ agent.outputSchema = mock_schema
+ else:
+ agent.outputSchema = None
+
+ return agent
+
+ @staticmethod
+ def _make_mock_provider() -> tuple[MagicMock, AsyncMock]:
+ """Create a mock provider class and its instance."""
+ mock_agent = MagicMock()
+ mock_provider_instance = AsyncMock()
+ mock_provider_instance.create_agent = AsyncMock(return_value=mock_agent)
+ mock_provider_class = MagicMock(return_value=mock_provider_instance)
+ return mock_provider_class, mock_provider_instance
+
+ @pytest.mark.asyncio
+ async def test_response_format_in_default_options(self):
+ """Provider.create_agent() should receive response_format inside default_options."""
+ from agent_framework_declarative._loader import AgentFactory
+
+ prompt_agent = self._make_mock_prompt_agent(with_output_schema=True)
+ mock_provider_class, mock_provider_instance = self._make_mock_provider()
+
+ mapping = {"package": "some_module", "name": "SomeProvider"}
+ factory = AgentFactory()
+
+ original_import = builtins.__import__
+
+ def mock_import(name, *args, **kwargs):
+ if name == "some_module":
+ mod = MagicMock()
+ mod.SomeProvider = mock_provider_class
+ return mod
+ return original_import(name, *args, **kwargs)
+
+ with (
+ patch.object(builtins, "__import__", side_effect=mock_import),
+ patch.object(factory, "_parse_tools", return_value=None),
+ ):
+ await factory._create_agent_with_provider(prompt_agent, mapping)
+
+ mock_provider_instance.create_agent.assert_called_once()
+ call_kwargs = mock_provider_instance.create_agent.call_args.kwargs
+
+ assert "response_format" not in call_kwargs
+ default_options = call_kwargs.get("default_options")
+ assert default_options is not None
+ assert "response_format" in default_options
+
+ @pytest.mark.asyncio
+ async def test_no_default_options_without_output_schema(self):
+ """When there's no outputSchema, default_options should be None."""
+ from agent_framework_declarative._loader import AgentFactory
+
+ prompt_agent = self._make_mock_prompt_agent(with_output_schema=False)
+ mock_provider_class, mock_provider_instance = self._make_mock_provider()
+
+ mapping = {"package": "some_module", "name": "SomeProvider"}
+ factory = AgentFactory()
+
+ original_import = builtins.__import__
+
+ def mock_import(name, *args, **kwargs):
+ if name == "some_module":
+ mod = MagicMock()
+ mod.SomeProvider = mock_provider_class
+ return mod
+ return original_import(name, *args, **kwargs)
+
+ with (
+ patch.object(builtins, "__import__", side_effect=mock_import),
+ patch.object(factory, "_parse_tools", return_value=None),
+ ):
+ await factory._create_agent_with_provider(prompt_agent, mapping)
+
+ call_kwargs = mock_provider_instance.create_agent.call_args.kwargs
+ assert call_kwargs.get("default_options") is None
diff --git a/python/packages/declarative/tests/test_graph_executors.py b/python/packages/declarative/tests/test_graph_executors.py
index 0a4433b095..754274db59 100644
--- a/python/packages/declarative/tests/test_graph_executors.py
+++ b/python/packages/declarative/tests/test_graph_executors.py
@@ -2,6 +2,7 @@
"""Tests for the graph-based declarative workflow executors."""
+from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -1295,3 +1296,203 @@ class TestExtractJsonFromResponse:
text = 'First: {"status": "pending"} then later: {"status": "complete", "id": 42}'
result = _extract_json_from_response(text)
assert result == {"status": "complete", "id": 42}
+
+
+class TestPowerFxConditionalImport:
+ """The _declarative_base module should be importable without dotnet/powerfx."""
+
+ def test_import_guard_exists(self):
+ """The powerfx import must be wrapped in try/except."""
+ import agent_framework_declarative._workflows._declarative_base as base_mod
+
+ assert hasattr(base_mod, "DeclarativeWorkflowState")
+ assert hasattr(base_mod, "Engine")
+
+ # Engine should either be the real class or None — never an ImportError
+ engine = base_mod.Engine
+ assert engine is None or callable(engine)
+
+ def test_eval_raises_when_engine_unavailable(self):
+ """eval() should raise RuntimeError when Engine is None."""
+ import agent_framework_declarative._workflows._declarative_base as base_mod
+
+ mock_state = MagicMock()
+ mock_state._data: dict[str, Any] = {}
+ mock_state.get = MagicMock(side_effect=lambda k, d=None: mock_state._data.get(k, d))
+ mock_state.set = MagicMock(side_effect=lambda k, v: mock_state._data.__setitem__(k, v))
+
+ state = DeclarativeWorkflowState(mock_state)
+ state.initialize({"name": "test"})
+
+ original_engine = base_mod.Engine
+ try:
+ base_mod.Engine = None
+ with pytest.raises(RuntimeError, match="PowerFx is not available"):
+ state.eval("=Local.counter + 1")
+ finally:
+ base_mod.Engine = original_engine
+
+ def test_eval_passes_through_plain_strings_without_engine(self):
+ """Non-PowerFx strings (no leading '=') should work without Engine."""
+ import agent_framework_declarative._workflows._declarative_base as base_mod
+
+ mock_state = MagicMock()
+ mock_state._data: dict[str, Any] = {}
+ mock_state.get = MagicMock(side_effect=lambda k, d=None: mock_state._data.get(k, d))
+ mock_state.set = MagicMock(side_effect=lambda k, v: mock_state._data.__setitem__(k, v))
+
+ state = DeclarativeWorkflowState(mock_state)
+ state.initialize()
+
+ original_engine = base_mod.Engine
+ try:
+ base_mod.Engine = None
+ assert state.eval("hello world") == "hello world"
+ assert state.eval("") == ""
+ assert state.eval(42) == 42
+ finally:
+ base_mod.Engine = original_engine
+
+
+class TestExecutorKwargsForwarding:
+ """Workflow run kwargs should be forwarded through executor agent invocations."""
+
+ @pytest.mark.asyncio
+ async def test_invoke_agent_forwards_kwargs(self):
+ """InvokeAzureAgentExecutor should forward run_kwargs to agent.run()."""
+ from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY
+ from agent_framework._workflows._state import State
+
+ from agent_framework_declarative._workflows._executors_agents import (
+ InvokeAzureAgentExecutor,
+ )
+
+ # Create a mock State with kwargs stored
+ mock_state = MagicMock(spec=State)
+ state_data: dict[str, Any] = {}
+
+ def mock_get(key, default=None):
+ return state_data.get(key, default)
+
+ def mock_set(key, value):
+ state_data[key] = value
+
+ mock_state.get = MagicMock(side_effect=mock_get)
+ mock_state.set = MagicMock(side_effect=mock_set)
+
+ # Store kwargs in state like Workflow.run() does
+ test_kwargs = {"user_token": "abc123", "service_config": {"endpoint": "http://test"}}
+ state_data[WORKFLOW_RUN_KWARGS_KEY] = test_kwargs
+
+ # Initialize declarative state
+ dws = DeclarativeWorkflowState(mock_state)
+ dws.initialize({"input": "hello"})
+
+ # Create a mock agent
+ mock_response = MagicMock()
+ mock_response.text = "response text"
+ mock_response.messages = []
+ mock_response.tool_calls = []
+ mock_agent = AsyncMock()
+ mock_agent.run = AsyncMock(return_value=mock_response)
+
+ # Create a mock workflow context
+ mock_ctx = MagicMock()
+ mock_ctx.get_state = MagicMock(side_effect=mock_get)
+ mock_ctx.yield_output = AsyncMock()
+
+ executor = InvokeAzureAgentExecutor.__new__(InvokeAzureAgentExecutor)
+ executor._agents = {"test_agent": mock_agent}
+
+ await executor._invoke_agent_and_store_results(
+ agent=mock_agent,
+ agent_name="test_agent",
+ input_text="hello",
+ state=dws,
+ ctx=mock_ctx,
+ messages_var=None,
+ response_obj_var=None,
+ result_property=None,
+ auto_send=True,
+ )
+
+ # Verify agent.run was called with kwargs
+ mock_agent.run.assert_called_once()
+ call_kwargs = mock_agent.run.call_args
+
+ # Check options contains additional_function_arguments
+ assert "options" in call_kwargs.kwargs
+ assert call_kwargs.kwargs["options"]["additional_function_arguments"] == test_kwargs
+
+ # Check direct kwargs were passed
+ assert call_kwargs.kwargs.get("user_token") == "abc123"
+ assert call_kwargs.kwargs.get("service_config") == {"endpoint": "http://test"}
+
+ @pytest.mark.asyncio
+ async def test_invoke_agent_merges_caller_options(self):
+ """Caller-provided options in run_kwargs should be merged, not cause TypeError."""
+ from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY
+ from agent_framework._workflows._state import State
+
+ from agent_framework_declarative._workflows._executors_agents import (
+ InvokeAzureAgentExecutor,
+ )
+
+ mock_state = MagicMock(spec=State)
+ state_data: dict[str, Any] = {}
+
+ def mock_get(key, default=None):
+ return state_data.get(key, default)
+
+ def mock_set(key, value):
+ state_data[key] = value
+
+ mock_state.get = MagicMock(side_effect=mock_get)
+ mock_state.set = MagicMock(side_effect=mock_set)
+
+ # Include 'options' in run_kwargs to test merge behavior
+ test_kwargs = {
+ "user_token": "abc123",
+ "options": {"temperature": 0.5},
+ }
+ state_data[WORKFLOW_RUN_KWARGS_KEY] = test_kwargs
+
+ dws = DeclarativeWorkflowState(mock_state)
+ dws.initialize({"input": "hello"})
+
+ mock_response = MagicMock()
+ mock_response.text = "response text"
+ mock_response.messages = []
+ mock_response.tool_calls = []
+ mock_agent = AsyncMock()
+ mock_agent.run = AsyncMock(return_value=mock_response)
+
+ mock_ctx = MagicMock()
+ mock_ctx.get_state = MagicMock(side_effect=mock_get)
+ mock_ctx.yield_output = AsyncMock()
+
+ executor = InvokeAzureAgentExecutor.__new__(InvokeAzureAgentExecutor)
+ executor._agents = {"test_agent": mock_agent}
+
+ await executor._invoke_agent_and_store_results(
+ agent=mock_agent,
+ agent_name="test_agent",
+ input_text="hello",
+ state=dws,
+ ctx=mock_ctx,
+ messages_var=None,
+ response_obj_var=None,
+ result_property=None,
+ auto_send=True,
+ )
+
+ mock_agent.run.assert_called_once()
+ call_kwargs = mock_agent.run.call_args
+
+ # Caller options should be merged with additional_function_arguments
+ merged_options = call_kwargs.kwargs["options"]
+ assert merged_options["temperature"] == 0.5
+ assert "additional_function_arguments" in merged_options
+
+ # Direct kwargs should be passed without 'options' (no duplicate keyword)
+ assert call_kwargs.kwargs.get("user_token") == "abc123"
diff --git a/python/packages/declarative/tests/test_workflow_handlers.py b/python/packages/declarative/tests/test_workflow_handlers.py
index 88aa565c9b..23c37db295 100644
--- a/python/packages/declarative/tests/test_workflow_handlers.py
+++ b/python/packages/declarative/tests/test_workflow_handlers.py
@@ -4,6 +4,7 @@
from collections.abc import AsyncGenerator
from typing import Any
+from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -29,6 +30,7 @@ def create_action_context(
inputs: dict[str, Any] | None = None,
agents: dict[str, Any] | None = None,
bindings: dict[str, Any] | None = None,
+ run_kwargs: dict[str, Any] | None = None,
) -> ActionContext:
"""Helper to create an ActionContext for testing."""
state = WorkflowState(inputs=inputs or {})
@@ -47,6 +49,7 @@ def create_action_context(
execute_actions=execute_actions,
agents=agents or {},
bindings=bindings or {},
+ run_kwargs=run_kwargs or {},
)
async for event in handler(ctx):
yield event
@@ -57,6 +60,7 @@ def create_action_context(
execute_actions=execute_actions,
agents=agents or {},
bindings=bindings or {},
+ run_kwargs=run_kwargs or {},
)
@@ -422,3 +426,128 @@ class TestTryCatchHandler:
assert ctx.state.get("Local.try") == "ran"
assert ctx.state.get("Local.finally") == "ran"
+
+
+class TestActionContextKwargs:
+ """ActionContext should carry and forward run_kwargs to agent invocations."""
+
+ @pytest.mark.asyncio
+ async def test_action_context_carries_run_kwargs(self):
+ """ActionContext should store and expose run_kwargs."""
+ ctx = create_action_context(
+ {"kind": "SetValue", "path": "Local.x", "value": "1"},
+ run_kwargs={"user_token": "test123"},
+ )
+ assert ctx.run_kwargs == {"user_token": "test123"}
+
+ @pytest.mark.asyncio
+ async def test_action_context_defaults_to_empty_kwargs(self):
+ """ActionContext.run_kwargs should default to empty dict."""
+ ctx = create_action_context(
+ {"kind": "SetValue", "path": "Local.x", "value": "1"},
+ )
+ assert ctx.run_kwargs == {}
+
+ @pytest.mark.asyncio
+ async def test_invoke_agent_handler_forwards_kwargs(self):
+ """handle_invoke_azure_agent should forward ctx.run_kwargs to agent.run()."""
+ import agent_framework_declarative._workflows._actions_agents # noqa: F401
+
+ mock_response = MagicMock()
+ mock_response.text = "response"
+ mock_response.messages = []
+ mock_response.tool_calls = []
+
+ async def non_streaming_run(*args, **kwargs):
+ if kwargs.get("stream"):
+ raise TypeError("no streaming")
+ return mock_response
+
+ mock_agent = AsyncMock()
+ mock_agent.run = AsyncMock(side_effect=non_streaming_run)
+
+ test_kwargs = {"user_token": "secret", "api_key": "key123"}
+
+ state = WorkflowState()
+ state.add_conversation_message(MagicMock(role="user", text="hello"))
+
+ ctx = create_action_context(
+ action={
+ "kind": "InvokeAzureAgent",
+ "agent": "my_agent",
+ },
+ agents={"my_agent": mock_agent},
+ run_kwargs=test_kwargs,
+ )
+
+ handler = get_action_handler("InvokeAzureAgent")
+ _ = [e async for e in handler(ctx)]
+
+ assert mock_agent.run.call_count >= 1
+
+ # Find the non-streaming fallback call
+ for call in mock_agent.run.call_args_list:
+ call_kw = call.kwargs
+ if not call_kw.get("stream"):
+ assert call_kw.get("user_token") == "secret"
+ assert call_kw.get("api_key") == "key123"
+ assert call_kw.get("options") == {"additional_function_arguments": test_kwargs}
+ break
+ else:
+ # All calls were streaming — check the streaming call
+ call_kw = mock_agent.run.call_args_list[0].kwargs
+ assert call_kw.get("user_token") == "secret"
+ assert call_kw.get("api_key") == "key123"
+
+ @pytest.mark.asyncio
+ async def test_invoke_agent_handler_merges_caller_options(self):
+ """Caller-provided options in run_kwargs should be merged, not cause TypeError."""
+ import agent_framework_declarative._workflows._actions_agents # noqa: F401
+
+ mock_response = MagicMock()
+ mock_response.text = "response"
+ mock_response.messages = []
+ mock_response.tool_calls = []
+
+ async def non_streaming_run(*args, **kwargs):
+ if kwargs.get("stream"):
+ raise TypeError("no streaming")
+ return mock_response
+
+ mock_agent = AsyncMock()
+ mock_agent.run = AsyncMock(side_effect=non_streaming_run)
+
+ # Include 'options' in run_kwargs to test merge behavior
+ test_kwargs = {"user_token": "secret", "options": {"temperature": 0.7}}
+
+ state = WorkflowState()
+ state.add_conversation_message(MagicMock(role="user", text="hello"))
+
+ ctx = create_action_context(
+ action={
+ "kind": "InvokeAzureAgent",
+ "agent": "my_agent",
+ },
+ agents={"my_agent": mock_agent},
+ run_kwargs=test_kwargs,
+ )
+
+ handler = get_action_handler("InvokeAzureAgent")
+ _ = [e async for e in handler(ctx)]
+
+ assert mock_agent.run.call_count >= 1
+
+ # Find the non-streaming fallback call
+ for call in mock_agent.run.call_args_list:
+ call_kw = call.kwargs
+ if not call_kw.get("stream"):
+ # Caller options should be merged with additional_function_arguments
+ assert call_kw["options"]["temperature"] == 0.7
+ assert "additional_function_arguments" in call_kw["options"]
+ # Direct kwargs should not include 'options' (no duplicate keyword)
+ assert call_kw.get("user_token") == "secret"
+ break
+ else:
+ call_kw = mock_agent.run.call_args_list[0].kwargs
+ assert call_kw["options"]["temperature"] == 0.7
+ assert "additional_function_arguments" in call_kw["options"]
diff --git a/python/packages/devui/agent_framework_devui/_executor.py b/python/packages/devui/agent_framework_devui/_executor.py
index b55a57cf44..92e6301b66 100644
--- a/python/packages/devui/agent_framework_devui/_executor.py
+++ b/python/packages/devui/agent_framework_devui/_executor.py
@@ -430,7 +430,7 @@ class AgentFrameworkExecutor:
elif hil_responses:
# Only auto-resume from latest checkpoint when we have HIL responses
# Regular "Run" clicks should start fresh, not resume from checkpoints
- checkpoints = await checkpoint_storage.list_checkpoints() # No workflow_id filter needed!
+ checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
if checkpoints:
latest = max(checkpoints, key=lambda cp: cp.timestamp)
checkpoint_id = latest.checkpoint_id
diff --git a/python/packages/devui/agent_framework_devui/_server.py b/python/packages/devui/agent_framework_devui/_server.py
index 374bab9962..e7994d3d3b 100644
--- a/python/packages/devui/agent_framework_devui/_server.py
+++ b/python/packages/devui/agent_framework_devui/_server.py
@@ -1059,7 +1059,7 @@ class DevServer:
# Extract checkpoint_id from item_id (format: "checkpoint_{checkpoint_id}")
checkpoint_id = item_id[len("checkpoint_") :]
storage = executor.checkpoint_manager.get_checkpoint_storage(conversation_id)
- deleted = await storage.delete_checkpoint(checkpoint_id)
+ deleted = await storage.delete(checkpoint_id)
if not deleted:
raise HTTPException(status_code=404, detail="Checkpoint not found")
diff --git a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py
index 5cf9e8c85d..ece84bc483 100644
--- a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py
+++ b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import sys
from collections.abc import Sequence
-from typing import Any, ClassVar, Generic
+from typing import Any, Generic
from agent_framework import (
ChatAndFunctionMiddlewareTypes,
@@ -13,7 +13,7 @@ from agent_framework import (
FunctionInvocationConfiguration,
FunctionInvocationLayer,
)
-from agent_framework._pydantic import AFBaseSettings
+from agent_framework._settings import load_settings
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.observability import ChatTelemetryLayer
from agent_framework.openai._chat_client import RawOpenAIChatClient
@@ -115,25 +115,19 @@ FoundryLocalChatOptionsT = TypeVar(
# endregion
-class FoundryLocalSettings(AFBaseSettings):
+class FoundryLocalSettings(TypedDict, total=False):
"""Foundry local model settings.
The settings are first loaded from environment variables with the prefix 'FOUNDRY_LOCAL_'.
If the environment variables are not found, the settings can be loaded from a .env file
- with the encoding 'utf-8'. If the settings are not found in the .env file, the settings
- are ignored; however, validation will fail alerting that the settings are missing.
+ with the encoding 'utf-8'.
- Attributes:
+ Keys:
model_id: The name of the model deployment to use.
(Env var FOUNDRY_LOCAL_MODEL_ID)
- Parameters:
- env_file_path: If provided, the .env settings are read from this file path location.
- env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
"""
- env_prefix: ClassVar[str] = "FOUNDRY_LOCAL_"
-
- model_id: str
+ model_id: str | None
class FoundryLocalClient(
@@ -247,21 +241,27 @@ class FoundryLocalClient(
type that is not supported by the model, it will not be found.
"""
- settings = FoundryLocalSettings(
- model_id=model_id, # type: ignore
+ settings = load_settings(
+ FoundryLocalSettings,
+ env_prefix="FOUNDRY_LOCAL_",
+ required_fields=["model_id"],
+ model_id=model_id,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
manager = FoundryLocalManager(bootstrap=bootstrap, timeout=timeout)
model_info = manager.get_model_info(
- alias_or_model_id=settings.model_id,
+ alias_or_model_id=settings["model_id"],
device=device,
)
if model_info is None:
message = (
- f"Model with ID or alias '{settings.model_id}:{device.value}' not found in Foundry Local."
+ f"Model with ID or alias '{settings['model_id']}:{device.value}' not found in Foundry Local."
if device
- else f"Model with ID or alias '{settings.model_id}' for your current device not found in Foundry Local."
+ else (
+ f"Model with ID or alias '{settings['model_id']}' for your current device "
+ "not found in Foundry Local."
+ )
)
raise ServiceInitializationError(message)
if prepare_model:
diff --git a/python/packages/foundry_local/tests/test_foundry_local_client.py b/python/packages/foundry_local/tests/test_foundry_local_client.py
index 031461d926..88b9fb4260 100644
--- a/python/packages/foundry_local/tests/test_foundry_local_client.py
+++ b/python/packages/foundry_local/tests/test_foundry_local_client.py
@@ -4,8 +4,8 @@ from unittest.mock import MagicMock, patch
import pytest
from agent_framework import SupportsChatGetResponse
-from agent_framework.exceptions import ServiceInitializationError
-from pydantic import ValidationError
+from agent_framework._settings import load_settings
+from agent_framework.exceptions import ServiceInitializationError, SettingNotFoundError
from agent_framework_foundry_local import FoundryLocalClient
from agent_framework_foundry_local._foundry_local_client import FoundryLocalSettings
@@ -15,31 +15,43 @@ from agent_framework_foundry_local._foundry_local_client import FoundryLocalSett
def test_foundry_local_settings_init_from_env(foundry_local_unit_test_env: dict[str, str]) -> None:
"""Test FoundryLocalSettings initialization from environment variables."""
- settings = FoundryLocalSettings(env_file_path="test.env")
+ settings = load_settings(FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_", env_file_path="test.env")
- assert settings.model_id == foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL_ID"]
+ assert settings["model_id"] == foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL_ID"]
def test_foundry_local_settings_init_with_explicit_values() -> None:
"""Test FoundryLocalSettings initialization with explicit values."""
- settings = FoundryLocalSettings(model_id="custom-model-id", env_file_path="test.env")
+ settings = load_settings(
+ FoundryLocalSettings,
+ env_prefix="FOUNDRY_LOCAL_",
+ model_id="custom-model-id",
+ env_file_path="test.env",
+ )
- assert settings.model_id == "custom-model-id"
+ assert settings["model_id"] == "custom-model-id"
@pytest.mark.parametrize("exclude_list", [["FOUNDRY_LOCAL_MODEL_ID"]], indirect=True)
def test_foundry_local_settings_missing_model_id(foundry_local_unit_test_env: dict[str, str]) -> None:
- """Test FoundryLocalSettings when model_id is missing raises ValidationError."""
- with pytest.raises(ValidationError):
- FoundryLocalSettings(env_file_path="test.env")
+ """Test FoundryLocalSettings when model_id is missing raises error."""
+ with pytest.raises(SettingNotFoundError, match="Required setting 'model_id'"):
+ load_settings(
+ FoundryLocalSettings,
+ env_prefix="FOUNDRY_LOCAL_",
+ required_fields=["model_id"],
+ env_file_path="test.env",
+ )
def test_foundry_local_settings_explicit_overrides_env(foundry_local_unit_test_env: dict[str, str]) -> None:
"""Test that explicit values override environment variables."""
- settings = FoundryLocalSettings(model_id="override-model-id", env_file_path="test.env")
+ settings = load_settings(
+ FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_", model_id="override-model-id", env_file_path="test.env"
+ )
- assert settings.model_id == "override-model-id"
- assert settings.model_id != foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL_ID"]
+ assert settings["model_id"] == "override-model-id"
+ assert settings["model_id"] != foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL_ID"]
# Client Initialization Tests
diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py
index 38b31f4e2e..d1475f04a3 100644
--- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py
+++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py
@@ -21,9 +21,10 @@ from agent_framework import (
ResponseStream,
normalize_messages,
)
+from agent_framework._settings import load_settings
from agent_framework._tools import FunctionTool
from agent_framework._types import normalize_tools
-from agent_framework.exceptions import ServiceException, ServiceInitializationError
+from agent_framework.exceptions import ServiceException
from copilot import CopilotClient, CopilotSession
from copilot.generated.session_events import SessionEvent, SessionEventType
from copilot.types import (
@@ -38,7 +39,6 @@ from copilot.types import (
ToolResult,
)
from copilot.types import Tool as CopilotTool
-from pydantic import ValidationError
from ._settings import GitHubCopilotSettings
@@ -207,17 +207,16 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
on_permission_request: PermissionHandlerType | None = opts.pop("on_permission_request", None)
mcp_servers: dict[str, MCPServerConfig] | None = opts.pop("mcp_servers", None)
- try:
- self._settings = GitHubCopilotSettings(
- cli_path=cli_path,
- model=model,
- timeout=timeout,
- log_level=log_level,
- env_file_path=env_file_path,
- env_file_encoding=env_file_encoding,
- )
- except ValidationError as ex:
- raise ServiceInitializationError("Failed to create GitHub Copilot settings.", ex) from ex
+ self._settings = load_settings(
+ GitHubCopilotSettings,
+ env_prefix="GITHUB_COPILOT_",
+ cli_path=cli_path,
+ model=model,
+ timeout=timeout,
+ log_level=log_level,
+ env_file_path=env_file_path,
+ env_file_encoding=env_file_encoding,
+ )
self._tools = normalize_tools(tools)
self._permission_handler = on_permission_request
@@ -249,10 +248,10 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
if self._client is None:
client_options: CopilotClientOptions = {}
- if self._settings.cli_path:
- client_options["cli_path"] = self._settings.cli_path
- if self._settings.log_level:
- client_options["log_level"] = self._settings.log_level # type: ignore[typeddict-item]
+ if self._settings["cli_path"]:
+ client_options["cli_path"] = self._settings["cli_path"]
+ if self._settings["log_level"]:
+ client_options["log_level"] = self._settings["log_level"] # type: ignore[typeddict-item]
self._client = CopilotClient(client_options if client_options else None)
@@ -355,7 +354,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
thread = self.get_new_thread()
opts: dict[str, Any] = dict(options) if options else {}
- timeout = opts.pop("timeout", None) or self._settings.timeout or DEFAULT_TIMEOUT_SECONDS
+ timeout = opts.pop("timeout", None) or self._settings["timeout"] or DEFAULT_TIMEOUT_SECONDS
session = await self._get_or_create_session(thread, streaming=False, runtime_options=opts)
input_messages = normalize_messages(messages)
@@ -578,7 +577,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
opts = runtime_options or {}
config: SessionConfig = {"streaming": streaming}
- model = opts.get("model") or self._settings.model
+ model = opts.get("model") or self._settings["model"]
if model:
config["model"] = model # type: ignore[typeddict-item]
diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_settings.py b/python/packages/github_copilot/agent_framework_github_copilot/_settings.py
index 67315539d5..884d23e9a8 100644
--- a/python/packages/github_copilot/agent_framework_github_copilot/_settings.py
+++ b/python/packages/github_copilot/agent_framework_github_copilot/_settings.py
@@ -1,19 +1,16 @@
# Copyright (c) Microsoft. All rights reserved.
-from typing import ClassVar
-
-from agent_framework._pydantic import AFBaseSettings
+from typing import TypedDict
-class GitHubCopilotSettings(AFBaseSettings):
+class GitHubCopilotSettings(TypedDict, total=False):
"""GitHub Copilot model settings.
The settings are first loaded from environment variables with the prefix 'GITHUB_COPILOT_'.
If the environment variables are not found, the settings can be loaded from a .env file
- with the encoding 'utf-8'. If the settings are not found in the .env file, the settings
- are ignored; however, validation will fail alerting that the settings are missing.
+ with the encoding 'utf-8'.
- Keyword Args:
+ Keys:
cli_path: Path to the Copilot CLI executable.
Can be set via environment variable GITHUB_COPILOT_CLI_PATH.
model: Model to use (e.g., "gpt-5", "claude-sonnet-4").
@@ -22,28 +19,9 @@ class GitHubCopilotSettings(AFBaseSettings):
Can be set via environment variable GITHUB_COPILOT_TIMEOUT.
log_level: CLI log level.
Can be set via environment variable GITHUB_COPILOT_LOG_LEVEL.
- env_file_path: If provided, the .env settings are read from this file path location.
- env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
-
- Examples:
- .. code-block:: python
-
- from agent_framework_github_copilot import GitHubCopilotSettings
-
- # Using environment variables
- # Set GITHUB_COPILOT_MODEL=gpt-5
- settings = GitHubCopilotSettings()
-
- # Or passing parameters directly
- settings = GitHubCopilotSettings(model="claude-sonnet-4", timeout=120)
-
- # Or loading from a .env file
- settings = GitHubCopilotSettings(env_file_path="path/to/.env")
"""
- env_prefix: ClassVar[str] = "GITHUB_COPILOT_"
-
- cli_path: str | None = None
- model: str | None = None
- timeout: float | None = None
- log_level: str | None = None
+ cli_path: str | None
+ model: str | None
+ timeout: float | None
+ log_level: str | None
diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py
index b2b7b2ebee..0ce0ca0307 100644
--- a/python/packages/github_copilot/tests/test_github_copilot_agent.py
+++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py
@@ -122,8 +122,8 @@ class TestGitHubCopilotAgentInit:
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
default_options={"model": "claude-sonnet-4", "timeout": 120}
)
- assert agent._settings.model == "claude-sonnet-4" # type: ignore
- assert agent._settings.timeout == 120 # type: ignore
+ assert agent._settings["model"] == "claude-sonnet-4" # type: ignore
+ assert agent._settings["timeout"] == 120 # type: ignore
def test_init_with_tools(self) -> None:
"""Test initialization with function tools."""
diff --git a/python/packages/ollama/agent_framework_ollama/_chat_client.py b/python/packages/ollama/agent_framework_ollama/_chat_client.py
index 6ead403c5b..a074562a83 100644
--- a/python/packages/ollama/agent_framework_ollama/_chat_client.py
+++ b/python/packages/ollama/agent_framework_ollama/_chat_client.py
@@ -30,9 +30,8 @@ from agent_framework import (
UsageDetails,
get_logger,
)
-from agent_framework._pydantic import AFBaseSettings
+from agent_framework._settings import load_settings
from agent_framework.exceptions import (
- ServiceInitializationError,
ServiceInvalidRequestError,
ServiceResponseException,
)
@@ -42,7 +41,7 @@ from ollama import AsyncClient
# Rename imported types to avoid naming conflicts with Agent Framework types
from ollama._types import ChatResponse as OllamaChatResponse
from ollama._types import Message as OllamaMessage
-from pydantic import BaseModel, ValidationError
+from pydantic import BaseModel
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
@@ -275,13 +274,11 @@ OllamaChatOptionsT = TypeVar("OllamaChatOptionsT", bound=TypedDict, default="Oll
# endregion
-class OllamaSettings(AFBaseSettings):
+class OllamaSettings(TypedDict, total=False):
"""Ollama settings."""
- env_prefix: ClassVar[str] = "OLLAMA_"
-
- host: str | None = None
- model_id: str | None = None
+ host: str | None
+ model_id: str | None
logger = get_logger("agent_framework.ollama")
@@ -322,23 +319,19 @@ class OllamaChatClient(
env_file_encoding: The encoding to use when reading the dotenv (.env) file. Defaults to 'utf-8'.
**kwargs: Additional keyword arguments passed to BaseChatClient.
"""
- try:
- ollama_settings = OllamaSettings(
- host=host,
- model_id=model_id,
- env_file_encoding=env_file_encoding,
- env_file_path=env_file_path,
- )
- except ValidationError as ex:
- raise ServiceInitializationError("Failed to create Ollama settings.", ex) from ex
+ ollama_settings = load_settings(
+ OllamaSettings,
+ env_prefix="OLLAMA_",
+ required_fields=["model_id"],
+ host=host,
+ model_id=model_id,
+ env_file_encoding=env_file_encoding,
+ env_file_path=env_file_path,
+ )
- if ollama_settings.model_id is None:
- raise ServiceInitializationError(
- "Ollama chat model ID must be provided via model_id or OLLAMA_MODEL_ID environment variable."
- )
-
- self.model_id = ollama_settings.model_id
- self.client = client or AsyncClient(host=ollama_settings.host)
+ self.model_id = ollama_settings["model_id"]
+ # we can just pass in None for the host, the default is set by the Ollama package.
+ self.client = client or AsyncClient(host=ollama_settings.get("host"))
# Save Host URL for serialization with to_dict()
self.host = str(self.client._client.base_url) # pyright: ignore[reportUnknownMemberType,reportPrivateUsage,reportUnknownArgumentType]
diff --git a/python/packages/ollama/tests/test_ollama_chat_client.py b/python/packages/ollama/tests/test_ollama_chat_client.py
index 8d179b982d..14f21332d9 100644
--- a/python/packages/ollama/tests/test_ollama_chat_client.py
+++ b/python/packages/ollama/tests/test_ollama_chat_client.py
@@ -15,9 +15,9 @@ from agent_framework import (
tool,
)
from agent_framework.exceptions import (
- ServiceInitializationError,
ServiceInvalidRequestError,
ServiceResponseException,
+ SettingNotFoundError,
)
from ollama import AsyncClient
from ollama._types import ChatResponse as OllamaChatResponse
@@ -182,7 +182,7 @@ def test_init_client(ollama_unit_test_env: dict[str, str]) -> None:
@pytest.mark.parametrize("exclude_list", [["OLLAMA_MODEL_ID"]], indirect=True)
def test_with_invalid_settings(ollama_unit_test_env: dict[str, str]) -> None:
- with pytest.raises(ServiceInitializationError):
+ with pytest.raises(SettingNotFoundError, match="Required setting 'model_id'"):
OllamaChatClient(
host="http://localhost:12345",
model_id=None,
diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py
index d5ead8d9e7..d7ac1576c7 100644
--- a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py
+++ b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py
@@ -32,7 +32,6 @@ from agent_framework import Agent, AgentThread, Message, SupportsAgentRun
from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse
from agent_framework._workflows._agent_utils import resolve_agent_id
from agent_framework._workflows._checkpoint import CheckpointStorage
-from agent_framework._workflows._conversation_state import decode_chat_messages, encode_chat_messages
from agent_framework._workflows._executor import Executor
from agent_framework._workflows._workflow import Workflow
from agent_framework._workflows._workflow_builder import WorkflowBuilder
@@ -476,7 +475,7 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator):
async def on_checkpoint_save(self) -> dict[str, Any]:
"""Capture current orchestrator state for checkpointing."""
state = await super().on_checkpoint_save()
- state["cache"] = encode_chat_messages(self._cache)
+ state["cache"] = self._cache
serialized_thread = await self._thread.serialize()
state["thread"] = serialized_thread
@@ -486,7 +485,7 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator):
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
"""Restore executor state from checkpoint."""
await super().on_checkpoint_restore(state)
- self._cache = decode_chat_messages(state.get("cache", []))
+ self._cache = state.get("cache", [])
serialized_thread = state.get("thread")
if serialized_thread:
self._thread = await self._agent.deserialize_thread(serialized_thread)
diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_state.py b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_state.py
index 0f23f96dc0..e8f8a81080 100644
--- a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_state.py
+++ b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_state.py
@@ -59,15 +59,14 @@ class OrchestrationState:
Returns:
Dict with encoded conversation and metadata for persistence
"""
- from agent_framework._workflows._conversation_state import encode_chat_messages
-
result: dict[str, Any] = {
- "conversation": encode_chat_messages(self.conversation),
+ "conversation": self.conversation,
"round_index": self.round_index,
+ "orchestrator_name": self.orchestrator_name,
"metadata": dict(self.metadata),
}
if self.task is not None:
- result["task"] = encode_chat_messages([self.task])[0]
+ result["task"] = self.task
return result
@classmethod
@@ -80,16 +79,15 @@ class OrchestrationState:
Returns:
Restored OrchestrationState instance
"""
- from agent_framework._workflows._conversation_state import decode_chat_messages
-
task = None
if "task" in data:
- decoded_tasks = decode_chat_messages([data["task"]])
+ decoded_tasks = [data["task"]]
task = decoded_tasks[0] if decoded_tasks else None
return cls(
- conversation=decode_chat_messages(data.get("conversation", [])),
+ conversation=data.get("conversation", []),
round_index=data.get("round_index", 0),
+ orchestrator_name=data.get("orchestrator_name", ""),
metadata=dict(data.get("metadata", {})),
task=task,
)
diff --git a/python/packages/orchestrations/tests/test_concurrent.py b/python/packages/orchestrations/tests/test_concurrent.py
index 55100af4c3..8712aae3fd 100644
--- a/python/packages/orchestrations/tests/test_concurrent.py
+++ b/python/packages/orchestrations/tests/test_concurrent.py
@@ -224,13 +224,10 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None:
assert baseline_output is not None
- checkpoints = await storage.list_checkpoints()
+ checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
- resume_checkpoint = next(
- (cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"),
- checkpoints[-1],
- )
+ resume_checkpoint = checkpoints[1]
resumed_participants = (
_FakeAgentExec("agentA", "Alpha"),
@@ -270,14 +267,13 @@ async def test_concurrent_checkpoint_runtime_only() -> None:
assert baseline_output is not None
- checkpoints = await storage.list_checkpoints()
- assert checkpoints
- checkpoints.sort(key=lambda cp: cp.timestamp)
-
- resume_checkpoint = next(
- (cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"),
- checkpoints[-1],
+ checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
+ assert len(checkpoints) >= 2, (
+ "Expected at least 2 checkpoints. The first one is after the start executor, "
+ "and the second one is after the first round of agent executions."
)
+ checkpoints.sort(key=lambda cp: cp.timestamp)
+ resume_checkpoint = checkpoints[1]
resumed_agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")]
wf_resume = ConcurrentBuilder(participants=resumed_agents).build()
@@ -320,8 +316,8 @@ async def test_concurrent_checkpoint_runtime_overrides_buildtime() -> None:
assert baseline_output is not None
- buildtime_checkpoints = await buildtime_storage.list_checkpoints()
- runtime_checkpoints = await runtime_storage.list_checkpoints()
+ buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name)
+ runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name)
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
diff --git a/python/packages/orchestrations/tests/test_group_chat.py b/python/packages/orchestrations/tests/test_group_chat.py
index 9eb94b19d4..6544b681a0 100644
--- a/python/packages/orchestrations/tests/test_group_chat.py
+++ b/python/packages/orchestrations/tests/test_group_chat.py
@@ -620,7 +620,7 @@ async def test_group_chat_checkpoint_runtime_only() -> None:
assert baseline_output is not None
- checkpoints = await storage.list_checkpoints()
+ checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert len(checkpoints) > 0, "Runtime-only checkpointing should have created checkpoints"
@@ -656,8 +656,8 @@ async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None:
assert baseline_output is not None
- buildtime_checkpoints = await buildtime_storage.list_checkpoints()
- runtime_checkpoints = await runtime_storage.list_checkpoints()
+ buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name)
+ runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name)
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
diff --git a/python/packages/orchestrations/tests/test_magentic.py b/python/packages/orchestrations/tests/test_magentic.py
index b24284f9c3..17b6957205 100644
--- a/python/packages/orchestrations/tests/test_magentic.py
+++ b/python/packages/orchestrations/tests/test_magentic.py
@@ -362,7 +362,7 @@ async def test_magentic_checkpoint_resume_round_trip():
assert req_event is not None
assert isinstance(req_event.data, MagenticPlanReviewRequest)
- checkpoints = await storage.list_checkpoints()
+ checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = checkpoints[-1]
@@ -605,8 +605,9 @@ async def test_agent_executor_invoke_with_assistants_client_messages():
async def _collect_checkpoints(
storage: InMemoryCheckpointStorage,
+ workflow_name: str,
) -> list[WorkflowCheckpoint]:
- checkpoints = await storage.list_checkpoints()
+ checkpoints = await storage.list_checkpoints(workflow_name=workflow_name)
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
return checkpoints
@@ -619,12 +620,13 @@ async def test_magentic_checkpoint_resume_inner_loop_superstep():
participants=[StubThreadAgent()], checkpoint_storage=storage, manager=InvokeOnceManager()
).build()
- async for event in workflow.run("inner-loop task", stream=True):
- if event.type == "output":
- break
+ async for _ in workflow.run("inner-loop task", stream=True):
+ continue
- checkpoints = await _collect_checkpoints(storage)
- inner_loop_checkpoint = next(cp for cp in checkpoints if cp.metadata.get("superstep") == 1) # type: ignore[reportUnknownMemberType]
+ checkpoints = await _collect_checkpoints(storage, workflow.name)
+ # The first checkpoint is after the manager has run.
+ # The second checkpoint is after the participant has run.
+ inner_loop_checkpoint = checkpoints[1]
resumed = MagenticBuilder(
participants=[StubThreadAgent()], checkpoint_storage=storage, manager=InvokeOnceManager()
@@ -651,7 +653,7 @@ async def test_magentic_checkpoint_resume_from_saved_state():
if event.type == "output":
break
- checkpoints = await _collect_checkpoints(storage)
+ checkpoints = await _collect_checkpoints(storage, workflow.name)
# Verify we can resume from the last saved checkpoint
resumed_state = checkpoints[-1] # Use the last checkpoint
@@ -688,7 +690,7 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames():
assert req_event is not None
assert isinstance(req_event.data, MagenticPlanReviewRequest)
- checkpoints = await _collect_checkpoints(storage)
+ checkpoints = await _collect_checkpoints(storage, workflow.name)
target_checkpoint = checkpoints[-1]
renamed_workflow = MagenticBuilder(
@@ -772,7 +774,7 @@ async def test_magentic_checkpoint_runtime_only() -> None:
assert baseline_output is not None
- checkpoints = await storage.list_checkpoints()
+ checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert len(checkpoints) > 0, "Runtime-only checkpointing should have created checkpoints"
@@ -806,8 +808,8 @@ async def test_magentic_checkpoint_runtime_overrides_buildtime() -> None:
assert baseline_output is not None
- buildtime_checkpoints = await buildtime_storage.list_checkpoints()
- runtime_checkpoints = await runtime_storage.list_checkpoints()
+ buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name)
+ runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name)
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
@@ -856,13 +858,13 @@ async def test_magentic_checkpoint_restore_no_duplicate_history():
break
# Get checkpoint
- checkpoints = await storage.list_checkpoints()
+ checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert len(checkpoints) > 0, "Should have created checkpoints"
latest_checkpoint = checkpoints[-1]
# Load checkpoint and verify no duplicates in state
- checkpoint_data = await storage.load_checkpoint(latest_checkpoint.checkpoint_id)
+ checkpoint_data = await storage.load(latest_checkpoint.checkpoint_id)
assert checkpoint_data is not None
# Check the magentic_context in the checkpoint
diff --git a/python/packages/orchestrations/tests/test_sequential.py b/python/packages/orchestrations/tests/test_sequential.py
index 880e33761d..04a4ae4141 100644
--- a/python/packages/orchestrations/tests/test_sequential.py
+++ b/python/packages/orchestrations/tests/test_sequential.py
@@ -146,14 +146,10 @@ async def test_sequential_checkpoint_resume_round_trip() -> None:
assert baseline_output is not None
- checkpoints = await storage.list_checkpoints()
+ checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
-
- resume_checkpoint = next(
- (cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"),
- checkpoints[-1],
- )
+ resume_checkpoint = checkpoints[0]
resumed_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2"))
wf_resume = SequentialBuilder(participants=list(resumed_agents), checkpoint_storage=storage).build()
@@ -189,14 +185,10 @@ async def test_sequential_checkpoint_runtime_only() -> None:
assert baseline_output is not None
- checkpoints = await storage.list_checkpoints()
+ checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
-
- resume_checkpoint = next(
- (cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"),
- checkpoints[-1],
- )
+ resume_checkpoint = checkpoints[0]
resumed_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2"))
wf_resume = SequentialBuilder(participants=list(resumed_agents)).build()
@@ -240,8 +232,8 @@ async def test_sequential_checkpoint_runtime_overrides_buildtime() -> None:
assert baseline_output is not None
- buildtime_checkpoints = await buildtime_storage.list_checkpoints()
- runtime_checkpoints = await runtime_storage.list_checkpoints()
+ buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name)
+ runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name)
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
diff --git a/python/packages/purview/agent_framework_purview/__init__.py b/python/packages/purview/agent_framework_purview/__init__.py
index 79722f1b50..44b5312d1c 100644
--- a/python/packages/purview/agent_framework_purview/__init__.py
+++ b/python/packages/purview/agent_framework_purview/__init__.py
@@ -9,7 +9,7 @@ from ._exceptions import (
PurviewServiceError,
)
from ._middleware import PurviewChatPolicyMiddleware, PurviewPolicyMiddleware
-from ._settings import PurviewAppLocation, PurviewLocationType, PurviewSettings
+from ._settings import PurviewAppLocation, PurviewLocationType, PurviewSettings, get_purview_scopes
__all__ = [
"CacheProvider",
@@ -23,4 +23,5 @@ __all__ = [
"PurviewRequestError",
"PurviewServiceError",
"PurviewSettings",
+ "get_purview_scopes",
]
diff --git a/python/packages/purview/agent_framework_purview/_client.py b/python/packages/purview/agent_framework_purview/_client.py
index 3eda072ca3..b2b07b2637 100644
--- a/python/packages/purview/agent_framework_purview/_client.py
+++ b/python/packages/purview/agent_framework_purview/_client.py
@@ -31,7 +31,7 @@ from ._models import (
ProtectionScopesRequest,
ProtectionScopesResponse,
)
-from ._settings import PurviewSettings
+from ._settings import PurviewSettings, get_purview_scopes
logger = get_logger("agent_framework.purview")
@@ -52,7 +52,7 @@ class PurviewClient:
):
self._credential: TokenCredential | AsyncTokenCredential = credential
self._settings = settings
- self._graph_uri = settings.graph_base_uri.rstrip("/")
+ self._graph_uri = (settings.get("graph_base_uri") or "https://graph.microsoft.com/v1.0/").rstrip("/")
self._timeout = timeout
self._client = httpx.AsyncClient(timeout=timeout)
@@ -61,7 +61,7 @@ class PurviewClient:
async def _get_token(self, *, tenant_id: str | None = None) -> str:
"""Acquire an access token using either async or sync credential."""
- scopes = self._settings.get_scopes()
+ scopes = get_purview_scopes(self._settings)
cred = self._credential
token = cred.get_token(*scopes, tenant_id=tenant_id)
token = await token if inspect.isawaitable(token) else token
@@ -167,7 +167,7 @@ class PurviewClient:
if resp.status_code in (401, 403):
raise PurviewAuthenticationError(f"Auth failure {resp.status_code}: {resp.text}")
if resp.status_code == 402:
- if self._settings.ignore_payment_required:
+ if self._settings.get("ignore_payment_required", False):
return response_type() # type: ignore[call-arg, no-any-return]
raise PurviewPaymentRequiredError(f"Payment required {resp.status_code}: {resp.text}")
if resp.status_code == 429:
diff --git a/python/packages/purview/agent_framework_purview/_middleware.py b/python/packages/purview/agent_framework_purview/_middleware.py
index 2da8de84ee..70f3b2892f 100644
--- a/python/packages/purview/agent_framework_purview/_middleware.py
+++ b/python/packages/purview/agent_framework_purview/_middleware.py
@@ -78,18 +78,22 @@ class PurviewPolicyMiddleware(AgentMiddleware):
from agent_framework import AgentResponse, Message
context.result = AgentResponse(
- messages=[Message(role="system", text=self._settings.blocked_prompt_message)]
+ messages=[
+ Message(
+ role="system", text=self._settings.get("blocked_prompt_message", "Prompt blocked by policy")
+ )
+ ]
)
raise MiddlewareTermination
except MiddlewareTermination:
raise
except PurviewPaymentRequiredError as ex:
logger.error(f"Purview payment required error in policy pre-check: {ex}")
- if not self._settings.ignore_payment_required:
+ if not self._settings.get("ignore_payment_required", False):
raise
except Exception as ex:
logger.error(f"Error in Purview policy pre-check: {ex}")
- if not self._settings.ignore_exceptions:
+ if not self._settings.get("ignore_exceptions", False):
raise
await call_next()
@@ -111,18 +115,23 @@ class PurviewPolicyMiddleware(AgentMiddleware):
from agent_framework import AgentResponse, Message
context.result = AgentResponse(
- messages=[Message(role="system", text=self._settings.blocked_response_message)]
+ messages=[
+ Message(
+ role="system",
+ text=self._settings.get("blocked_response_message", "Response blocked by policy"),
+ )
+ ]
)
else:
# Streaming responses are not supported for post-checks
logger.debug("Streaming responses are not supported for Purview policy post-checks")
except PurviewPaymentRequiredError as ex:
logger.error(f"Purview payment required error in policy post-check: {ex}")
- if not self._settings.ignore_payment_required:
+ if not self._settings.get("ignore_payment_required", False):
raise
except Exception as ex:
logger.error(f"Error in Purview policy post-check: {ex}")
- if not self._settings.ignore_exceptions:
+ if not self._settings.get("ignore_exceptions", False):
raise
@@ -173,18 +182,20 @@ class PurviewChatPolicyMiddleware(ChatMiddleware):
if should_block_prompt:
from agent_framework import ChatResponse, Message
- blocked_message = Message(role="system", text=self._settings.blocked_prompt_message)
+ blocked_message = Message(
+ role="system", text=self._settings.get("blocked_prompt_message", "Prompt blocked by policy")
+ )
context.result = ChatResponse(messages=[blocked_message])
raise MiddlewareTermination
except MiddlewareTermination:
raise
except PurviewPaymentRequiredError as ex:
logger.error(f"Purview payment required error in policy pre-check: {ex}")
- if not self._settings.ignore_payment_required:
+ if not self._settings.get("ignore_payment_required", False):
raise
except Exception as ex:
logger.error(f"Error in Purview policy pre-check: {ex}")
- if not self._settings.ignore_exceptions:
+ if not self._settings.get("ignore_exceptions", False):
raise
await call_next()
@@ -205,15 +216,18 @@ class PurviewChatPolicyMiddleware(ChatMiddleware):
if should_block_response:
from agent_framework import ChatResponse, Message
- blocked_message = Message(role="system", text=self._settings.blocked_response_message)
+ blocked_message = Message(
+ role="system",
+ text=self._settings.get("blocked_response_message", "Response blocked by policy"),
+ )
context.result = ChatResponse(messages=[blocked_message])
else:
logger.debug("Streaming responses are not supported for Purview policy post-checks")
except PurviewPaymentRequiredError as ex:
logger.error(f"Purview payment required error in policy post-check: {ex}")
- if not self._settings.ignore_payment_required:
+ if not self._settings.get("ignore_payment_required", False):
raise
except Exception as ex:
logger.error(f"Error in Purview policy post-check: {ex}")
- if not self._settings.ignore_exceptions:
+ if not self._settings.get("ignore_exceptions", False):
raise
diff --git a/python/packages/purview/agent_framework_purview/_processor.py b/python/packages/purview/agent_framework_purview/_processor.py
index 5525897a9e..bc8cd045c0 100644
--- a/python/packages/purview/agent_framework_purview/_processor.py
+++ b/python/packages/purview/agent_framework_purview/_processor.py
@@ -57,8 +57,11 @@ class ScopedContentProcessor:
def __init__(self, client: PurviewClient, settings: PurviewSettings, cache_provider: CacheProvider | None = None):
self._client = client
self._settings = settings
+ cache_ttl = settings.get("cache_ttl_seconds")
+ max_cache = settings.get("max_cache_size_bytes")
self._cache: CacheProvider = cache_provider or InMemoryCacheProvider(
- default_ttl_seconds=settings.cache_ttl_seconds, max_size_bytes=settings.max_cache_size_bytes
+ default_ttl_seconds=cache_ttl if cache_ttl is not None else 14400,
+ max_size_bytes=max_cache if max_cache is not None else 200 * 1024 * 1024,
)
self._background_tasks: set[asyncio.Task[Any]] = set()
@@ -116,10 +119,10 @@ class ScopedContentProcessor:
results: list[ProcessContentRequest] = []
token_info = None
- if not (self._settings.tenant_id and self._settings.purview_app_location):
- token_info = await self._client.get_user_info_from_token(tenant_id=self._settings.tenant_id)
+ if not (self._settings.get("tenant_id") and self._settings.get("purview_app_location")):
+ token_info = await self._client.get_user_info_from_token(tenant_id=self._settings.get("tenant_id"))
- tenant_id = (token_info or {}).get("tenant_id") or self._settings.tenant_id
+ tenant_id = (token_info or {}).get("tenant_id") or self._settings.get("tenant_id")
if not tenant_id or not _is_valid_guid(tenant_id):
raise ValueError("Tenant id required or must be inferable from credential")
@@ -159,10 +162,11 @@ class ScopedContentProcessor:
)
activity_meta = ActivityMetadata(activity=activity)
- if self._settings.purview_app_location:
+ purview_app_location = self._settings.get("purview_app_location")
+ if purview_app_location:
policy_location = PolicyLocation(
- data_type=self._settings.purview_app_location.get_policy_location()["@odata.type"],
- value=self._settings.purview_app_location.location_value,
+ data_type=purview_app_location.get_policy_location()["@odata.type"],
+ value=purview_app_location.location_value,
)
elif token_info and token_info.get("client_id"):
policy_location = PolicyLocation(
@@ -172,13 +176,14 @@ class ScopedContentProcessor:
else:
raise ValueError("App location not provided or inferable")
- app_version = self._settings.app_version or "Unknown"
protected_app = ProtectedAppMetadata(
- name=self._settings.app_name,
- version=app_version,
+ name=self._settings["app_name"],
+ version=self._settings.get("app_version", "Unknown"),
application_location=policy_location,
)
- integrated_app = IntegratedAppMetadata(name=self._settings.app_name, version=app_version)
+ integrated_app = IntegratedAppMetadata(
+ name=self._settings["app_name"], version=self._settings.get("app_version", "Unknown")
+ )
device_meta = DeviceMetadata(
operating_system_specifications=OperatingSystemSpecifications(
operating_system_platform="Unknown", operating_system_version="Unknown"
@@ -229,11 +234,13 @@ class ScopedContentProcessor:
ps_resp = cached_ps_resp
else:
try:
+ ttl = self._settings.get("cache_ttl_seconds")
+ ttl_seconds = ttl if ttl is not None else 14400
ps_resp = await self._client.get_protection_scopes(ps_req)
- await self._cache.set(cache_key, ps_resp, ttl_seconds=self._settings.cache_ttl_seconds)
+ await self._cache.set(cache_key, ps_resp, ttl_seconds=ttl_seconds)
except PurviewPaymentRequiredError as ex:
# Cache the exception at tenant level so all subsequent requests for this tenant fail fast
- await self._cache.set(tenant_payment_cache_key, ex, ttl_seconds=self._settings.cache_ttl_seconds)
+ await self._cache.set(tenant_payment_cache_key, ex, ttl_seconds=ttl_seconds)
raise
if ps_resp.scope_identifier:
diff --git a/python/packages/purview/agent_framework_purview/_settings.py b/python/packages/purview/agent_framework_purview/_settings.py
index 3710d9de52..9581d041fb 100644
--- a/python/packages/purview/agent_framework_purview/_settings.py
+++ b/python/packages/purview/agent_framework_purview/_settings.py
@@ -1,10 +1,14 @@
# Copyright (c) Microsoft. All rights reserved.
+import sys
from enum import Enum
-from agent_framework._pydantic import AFBaseSettings
-from pydantic import BaseModel, Field
-from pydantic_settings import SettingsConfigDict
+from pydantic import BaseModel
+
+if sys.version_info >= (3, 11):
+ from typing import TypedDict # pragma: no cover
+else:
+ from typing_extensions import TypedDict # type: ignore # pragma: no cover
class PurviewLocationType(str, Enum):
@@ -18,8 +22,8 @@ class PurviewLocationType(str, Enum):
class PurviewAppLocation(BaseModel):
"""Identifier representing the app's location for Purview policy evaluation."""
- location_type: PurviewLocationType = Field(..., description="The location type.")
- location_value: str = Field(..., description="The location value.")
+ location_type: PurviewLocationType
+ location_value: str
def get_policy_location(self) -> dict[str, str]:
ns = "microsoft.graph"
@@ -34,8 +38,8 @@ class PurviewAppLocation(BaseModel):
return {"@odata.type": dt, "value": self.location_value}
-class PurviewSettings(AFBaseSettings):
- """Settings for Purview integration.
+class PurviewSettings(TypedDict, total=False):
+ """Settings for Purview integration mirroring .NET PurviewSettings.
Attributes:
app_name: Public app name.
@@ -51,40 +55,30 @@ class PurviewSettings(AFBaseSettings):
max_cache_size_bytes: Maximum cache size in bytes (default 200MB).
"""
- app_name: str = Field(...)
- app_version: str | None = Field(default=None)
- tenant_id: str | None = Field(default=None)
- purview_app_location: PurviewAppLocation | None = Field(default=None)
- graph_base_uri: str = Field(default="https://graph.microsoft.com/v1.0/")
- blocked_prompt_message: str = Field(
- default="Prompt blocked by policy",
- description="Message to return when a prompt is blocked by policy.",
- )
- blocked_response_message: str = Field(
- default="Response blocked by policy",
- description="Message to return when a response is blocked by policy.",
- )
- ignore_exceptions: bool = Field(
- default=False,
- description="If True, all Purview exceptions will be logged but not thrown in middleware.",
- )
- ignore_payment_required: bool = Field(
- default=False,
- description="If True, 402 payment required errors will be logged but not thrown.",
- )
- cache_ttl_seconds: int = Field(
- default=14400,
- description="Time to live for cache entries in seconds (default 14400 = 4 hours).",
- )
- max_cache_size_bytes: int = Field(
- default=200 * 1024 * 1024,
- description="Maximum cache size in bytes (default 200MB).",
- )
+ app_name: str | None
+ app_version: str | None
+ tenant_id: str | None
+ purview_app_location: PurviewAppLocation | None
+ graph_base_uri: str | None
+ blocked_prompt_message: str | None
+ blocked_response_message: str | None
+ ignore_exceptions: bool | None
+ ignore_payment_required: bool | None
+ cache_ttl_seconds: int | None
+ max_cache_size_bytes: int | None
- model_config = SettingsConfigDict(populate_by_name=True, validate_assignment=True)
- def get_scopes(self) -> list[str]:
- from urllib.parse import urlparse
+def get_purview_scopes(settings: PurviewSettings) -> list[str]:
+ """Get the OAuth scopes for the Purview Graph API.
- host = urlparse(self.graph_base_uri).hostname or "graph.microsoft.com"
- return [f"https://{host}/.default"]
+ Args:
+ settings: The Purview settings containing graph_base_uri.
+
+ Returns:
+ A list of OAuth scope strings.
+ """
+ from urllib.parse import urlparse
+
+ graph_base_uri = settings.get("graph_base_uri", "https://graph.microsoft.com/v1.0/")
+ host = urlparse(str(graph_base_uri)).hostname or "graph.microsoft.com"
+ return [f"https://{host}/.default"]
diff --git a/python/packages/purview/tests/purview/test_chat_middleware.py b/python/packages/purview/tests/purview/test_chat_middleware.py
index bc9be01e1f..e740cdabc6 100644
--- a/python/packages/purview/tests/purview/test_chat_middleware.py
+++ b/python/packages/purview/tests/purview/test_chat_middleware.py
@@ -125,7 +125,7 @@ class TestPurviewChatPolicyMiddleware:
) -> None:
"""Test that exceptions in post-check are logged but don't affect result when ignore_exceptions=True."""
# Set ignore_exceptions to True to test exception suppression
- middleware._settings.ignore_exceptions = True
+ middleware._settings["ignore_exceptions"] = True
call_count = 0
diff --git a/python/packages/purview/tests/purview/test_middleware.py b/python/packages/purview/tests/purview/test_middleware.py
index 98dafab1e1..451aaf9df7 100644
--- a/python/packages/purview/tests/purview/test_middleware.py
+++ b/python/packages/purview/tests/purview/test_middleware.py
@@ -119,7 +119,7 @@ class TestPurviewPolicyMiddleware:
) -> None:
"""Test middleware handles result that doesn't have messages attribute."""
# Set ignore_exceptions to True so AttributeError is caught and logged
- middleware._settings.ignore_exceptions = True
+ middleware._settings["ignore_exceptions"] = True
context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")])
@@ -216,7 +216,7 @@ class TestPurviewPolicyMiddleware:
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test that post-check exceptions are propagated when ignore_exceptions=False."""
- middleware._settings.ignore_exceptions = False
+ middleware._settings["ignore_exceptions"] = False
context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")])
@@ -242,7 +242,7 @@ class TestPurviewPolicyMiddleware:
) -> None:
"""Test that exceptions in pre-check are logged but don't stop processing when ignore_exceptions=True."""
# Set ignore_exceptions to True
- middleware._settings.ignore_exceptions = True
+ middleware._settings["ignore_exceptions"] = True
context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Test")])
@@ -265,7 +265,7 @@ class TestPurviewPolicyMiddleware:
) -> None:
"""Test that exceptions in post-check are logged but don't affect result when ignore_exceptions=True."""
# Set ignore_exceptions to True
- middleware._settings.ignore_exceptions = True
+ middleware._settings["ignore_exceptions"] = True
context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Test")])
diff --git a/python/packages/purview/tests/purview/test_processor.py b/python/packages/purview/tests/purview/test_processor.py
index ab96999921..7e70072508 100644
--- a/python/packages/purview/tests/purview/test_processor.py
+++ b/python/packages/purview/tests/purview/test_processor.py
@@ -636,7 +636,6 @@ class TestScopedContentProcessorCaching:
return PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
- default_user_id="12345678-1234-1234-1234-123456789012",
purview_app_location=location,
)
diff --git a/python/packages/purview/tests/purview/test_purview_client.py b/python/packages/purview/tests/purview/test_purview_client.py
index b740b3f09c..a61724d3f0 100644
--- a/python/packages/purview/tests/purview/test_purview_client.py
+++ b/python/packages/purview/tests/purview/test_purview_client.py
@@ -47,7 +47,7 @@ class TestPurviewClient:
@pytest.fixture
def settings(self) -> PurviewSettings:
"""Create test settings."""
- return PurviewSettings(app_name="Test App", tenant_id="test-tenant", default_user_id="test-user")
+ return PurviewSettings(app_name="Test App", tenant_id="test-tenant")
@pytest.fixture
async def client(
diff --git a/python/packages/purview/tests/purview/test_settings.py b/python/packages/purview/tests/purview/test_settings.py
index 9abc27aed1..42e03a2be3 100644
--- a/python/packages/purview/tests/purview/test_settings.py
+++ b/python/packages/purview/tests/purview/test_settings.py
@@ -4,7 +4,7 @@
import pytest
-from agent_framework_purview import PurviewAppLocation, PurviewLocationType, PurviewSettings
+from agent_framework_purview import PurviewAppLocation, PurviewLocationType, PurviewSettings, get_purview_scopes
class TestPurviewSettings:
@@ -14,10 +14,10 @@ class TestPurviewSettings:
"""Test PurviewSettings with default values."""
settings = PurviewSettings(app_name="Test App")
- assert settings.app_name == "Test App"
- assert settings.graph_base_uri == "https://graph.microsoft.com/v1.0/"
- assert settings.tenant_id is None
- assert settings.purview_app_location is None
+ assert settings["app_name"] == "Test App"
+ assert settings.get("graph_base_uri") is None
+ assert settings.get("tenant_id") is None
+ assert settings.get("purview_app_location") is None
def test_settings_with_custom_values(self) -> None:
"""Test PurviewSettings with custom values."""
@@ -30,9 +30,9 @@ class TestPurviewSettings:
purview_app_location=app_location,
)
- assert settings.graph_base_uri == "https://graph.microsoft-ppe.com"
- assert settings.tenant_id == "test-tenant-id"
- assert settings.purview_app_location.location_value == "app-123"
+ assert settings["graph_base_uri"] == "https://graph.microsoft-ppe.com"
+ assert settings["tenant_id"] == "test-tenant-id"
+ assert settings["purview_app_location"].location_value == "app-123"
@pytest.mark.parametrize(
"graph_uri,expected_scope",
@@ -44,7 +44,7 @@ class TestPurviewSettings:
def test_get_scopes(self, graph_uri: str, expected_scope: str) -> None:
"""Test get_scopes returns correct scope for different URIs."""
settings = PurviewSettings(app_name="Test App", graph_base_uri=graph_uri)
- scopes = settings.get_scopes()
+ scopes = get_purview_scopes(settings)
assert len(scopes) == 1
assert expected_scope in scopes
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 5c4fdd1788..a60f52aeee 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -241,6 +241,7 @@ pytest --import-mode=importlib
--cov=agent_framework_ag_ui
--cov=agent_framework_anthropic
--cov=agent_framework_azure_ai
+--cov=agent_framework_azure_ai_search
--cov=agent_framework_azurefunctions
--cov=agent_framework_chatkit
--cov=agent_framework_copilotstudio
@@ -248,6 +249,7 @@ pytest --import-mode=importlib
--cov=agent_framework_purview
--cov=agent_framework_redis
--cov=agent_framework_orchestrations
+--cov=agent_framework_declarative
--cov-config=pyproject.toml
--cov-report=term-missing:skip-covered
--ignore-glob=packages/lab/**
diff --git a/python/samples/getting_started/orchestrations/handoff_with_tool_approval_checkpoint_resume.py b/python/samples/getting_started/orchestrations/handoff_with_tool_approval_checkpoint_resume.py
new file mode 100644
index 0000000000..ce377b654d
--- /dev/null
+++ b/python/samples/getting_started/orchestrations/handoff_with_tool_approval_checkpoint_resume.py
@@ -0,0 +1,230 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+import json
+from pathlib import Path
+from typing import Any
+
+from agent_framework import (
+ Agent,
+ Content,
+ FileCheckpointStorage,
+ Workflow,
+ tool,
+)
+from agent_framework.azure import AzureOpenAIChatClient
+from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder
+from azure.identity import AzureCliCredential
+
+"""
+Sample: Handoff Workflow with Tool Approvals + Checkpoint Resume
+
+Demonstrates resuming a handoff workflow from a checkpoint while handling both
+HandoffAgentUserRequest prompts and function approval request Content for tool calls
+(e.g., submit_refund).
+
+Scenario:
+1. User starts a conversation with the workflow.
+2. Agents may emit user input requests or tool approval requests.
+3. Workflow writes a checkpoint capturing pending requests and pauses.
+4. Process can exit/restart.
+5. On resume: Restore checkpoint, inspect pending requests, then provide responses.
+6. Workflow continues from the saved state.
+
+Pattern:
+- workflow.run(checkpoint_id=..., stream=True) to restore checkpoint and discover pending requests.
+- workflow.run(stream=True, responses=responses) to supply human replies and approvals.
+ (Two steps are needed here because the sample must inspect request types before building responses.
+ When response payloads are already known, use the single-call form:
+ workflow.run(stream=True, checkpoint_id=..., responses=responses).)
+
+Prerequisites:
+- Azure CLI authentication (az login).
+- Environment variables configured for AzureOpenAIChatClient.
+"""
+
+CHECKPOINT_DIR = Path(__file__).parent / "tmp" / "handoff_checkpoints"
+CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
+
+
+@tool(approval_mode="always_require")
+def submit_refund(refund_description: str, amount: str, order_id: str) -> str:
+ """Capture a refund request for manual review before processing."""
+ return f"refund recorded for order {order_id} (amount: {amount}) with details: {refund_description}"
+
+
+def create_agents(client: AzureOpenAIChatClient) -> tuple[Agent, Agent, Agent]:
+ """Create a simple handoff scenario: triage, refund, and order specialists."""
+
+ triage = client.as_agent(
+ name="triage_agent",
+ instructions=(
+ "You are a customer service triage agent. Listen to customer issues and determine "
+ "if they need refund help or order tracking. Use handoff_to_refund_agent or "
+ "handoff_to_order_agent to transfer them."
+ ),
+ )
+
+ refund = client.as_agent(
+ name="refund_agent",
+ instructions=(
+ "You are a refund specialist. Help customers with refund requests. "
+ "Be empathetic and ask for order numbers if not provided. "
+ "When the user confirms they want a refund and supplies order details, call submit_refund "
+ "to record the request before continuing."
+ ),
+ tools=[submit_refund],
+ )
+
+ order = client.as_agent(
+ name="order_agent",
+ instructions=(
+ "You are an order tracking specialist. Help customers track their orders. "
+ "Ask for order numbers and provide shipping updates."
+ ),
+ )
+
+ return triage, refund, order
+
+
+def create_workflow(checkpoint_storage: FileCheckpointStorage) -> Workflow:
+ """Build the handoff workflow with checkpointing enabled."""
+
+ client = AzureOpenAIChatClient(credential=AzureCliCredential())
+ triage, refund, order = create_agents(client)
+
+ # checkpoint_storage: Enable checkpointing for resume
+ # termination_condition: Terminate after 5 user messages for this demo
+ return (
+ HandoffBuilder(
+ name="checkpoint_handoff_demo",
+ participants=[triage, refund, order],
+ checkpoint_storage=checkpoint_storage,
+ termination_condition=lambda conv: sum(1 for msg in conv if msg.role == "user") >= 5,
+ )
+ .with_start_agent(triage)
+ .build()
+ )
+
+
+def print_handoff_agent_user_request(request: HandoffAgentUserRequest, request_id: str) -> None:
+ """Log pending handoff request details for debugging."""
+ print(f"\n{'=' * 60}")
+ print("User input needed")
+ print(f"Request ID: {request_id}")
+ print(f"Awaiting agent: {request.agent_response.agent_id}")
+
+ response = request.agent_response
+ if not response.messages:
+ print("(No agent messages)")
+ return
+
+ for message in response.messages:
+ if not message.text:
+ continue
+ speaker = message.author_name or message.role
+ print(f"{speaker}: {message.text}")
+
+ print(f"{'=' * 60}\n")
+
+
+def print_function_approval_request(request: Content, request_id: str) -> None:
+ """Log pending tool approval details for debugging."""
+ args = request.function_call.parse_arguments() or {} # type: ignore
+ print(f"\n{'=' * 60}")
+ print("Tool approval required")
+ print(f"Request ID: {request_id}")
+ print(f"Function: {request.function_call.name}") # type: ignore
+ print(f"Arguments:\n{json.dumps(args, indent=2)}")
+ print(f"{'=' * 60}\n")
+
+
+async def main() -> None:
+ """
+ Demonstrate the checkpoint-based pause/resume pattern for handoff workflows.
+
+ This sample shows:
+ 1. Starting a workflow and getting a HandoffAgentUserRequest
+ 2. Pausing (checkpoint is saved automatically)
+ 3. Resuming from checkpoint with a user response or tool approval
+ 4. Continuing the conversation until completion
+ """
+ # Clean up old checkpoints
+ for file in CHECKPOINT_DIR.glob("*.json"):
+ file.unlink()
+ for file in CHECKPOINT_DIR.glob("*.json.tmp"):
+ file.unlink()
+
+ storage = FileCheckpointStorage(storage_path=CHECKPOINT_DIR)
+ workflow = create_workflow(checkpoint_storage=storage)
+
+ # Scripted human input for demo purposes
+ handoff_responses = [
+ (
+ "The headphones in order 12345 arrived cracked. "
+ "Please submit the refund for $89.99 and send a replacement to my original address."
+ ),
+ "Yes, that covers the damage and refund request.",
+ "That's everything I needed for the refund.",
+ "Thanks for handling the refund.",
+ ]
+
+ print("=" * 60)
+ print("HANDOFF WORKFLOW CHECKPOINT DEMO")
+ print("=" * 60)
+
+ # Scenario: User needs help with a damaged order
+ initial_request = "Hi, my order 12345 arrived damaged. I need a refund."
+
+ # Phase 1: Initial run - workflow will pause when it needs user input
+ results = await workflow.run(message=initial_request)
+ request_events = results.get_request_info_events()
+ if not request_events:
+ print("Workflow completed without needing user input")
+ return
+
+ print("=" * 60)
+ print("WORKFLOW PAUSED with pending requests")
+ print("=" * 60)
+
+ # Phase 2: Running until no more user input is needed
+ # This creates a new workflow instance to simulate a fresh process start,
+ # but points it to the same checkpoint storage
+ while request_events:
+ print("=" * 60)
+ print("Simulating process restart...")
+ print("=" * 60)
+
+ workflow = create_workflow(checkpoint_storage=storage)
+
+ responses: dict[str, Any] = {}
+ for request_event in request_events:
+ print(f"Pending request ID: {request_event.request_id}, Type: {type(request_event.data)}")
+ if isinstance(request_event.data, HandoffAgentUserRequest):
+ print_handoff_agent_user_request(request_event.data, request_event.request_id)
+ response = handoff_responses.pop(0)
+ print(f"Responding with: {response}")
+ responses[request_event.request_id] = HandoffAgentUserRequest.create_response(response)
+ elif isinstance(request_event.data, Content) and request_event.data.type == "function_approval_request":
+ print_function_approval_request(request_event.data, request_event.request_id)
+ print("Approving tool call...")
+ responses[request_event.request_id] = request_event.data.to_function_approval_response(approved=True)
+ else:
+ # This sample only expects HandoffAgentUserRequest and function approval requests
+ raise ValueError(f"Unsupported request type: {type(request_event.data)}")
+
+ checkpoint = await storage.get_latest(workflow_name=workflow.name)
+ if not checkpoint:
+ raise RuntimeError("No checkpoints found.")
+ checkpoint_id = checkpoint.checkpoint_id
+
+ results = await workflow.run(responses=responses, checkpoint_id=checkpoint_id)
+ request_events = results.get_request_info_events()
+
+ print("\n" + "=" * 60)
+ print("DEMO COMPLETE")
+ print("=" * 60)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/python/samples/getting_started/orchestrations/magentic_checkpoint.py b/python/samples/getting_started/orchestrations/magentic_checkpoint.py
index 05437a8601..adce878f0d 100644
--- a/python/samples/getting_started/orchestrations/magentic_checkpoint.py
+++ b/python/samples/getting_started/orchestrations/magentic_checkpoint.py
@@ -2,6 +2,7 @@
import asyncio
import json
+from datetime import datetime
from pathlib import Path
from typing import cast
@@ -115,15 +116,11 @@ async def main() -> None:
print("No plan review request emitted; nothing to resume.")
return
- checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
- if not checkpoints:
+ resume_checkpoint = await checkpoint_storage.get_latest(workflow_name=workflow.name)
+ if not resume_checkpoint:
print("No checkpoints persisted.")
return
- resume_checkpoint = max(
- checkpoints,
- key=lambda cp: (cp.iteration_count, cp.timestamp),
- )
print(f"Using checkpoint {resume_checkpoint.checkpoint_id} at iteration {resume_checkpoint.iteration_count}")
# Show that the checkpoint JSON indeed contains the pending plan-review request record.
@@ -180,7 +177,7 @@ async def main() -> None:
def _pending_message_count(cp: WorkflowCheckpoint) -> int:
return sum(len(msg_list) for msg_list in cp.messages.values() if isinstance(msg_list, list))
- all_checkpoints = await checkpoint_storage.list_checkpoints(resume_checkpoint.workflow_id)
+ all_checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=resume_checkpoint.workflow_name)
later_checkpoints_with_messages = [
cp
for cp in all_checkpoints
@@ -188,10 +185,7 @@ async def main() -> None:
]
if later_checkpoints_with_messages:
- post_plan_checkpoint = max(
- later_checkpoints_with_messages,
- key=lambda cp: (cp.iteration_count, cp.timestamp),
- )
+ post_plan_checkpoint = max(later_checkpoints_with_messages, key=lambda cp: datetime.fromisoformat(cp.timestamp))
else:
later_checkpoints = [cp for cp in all_checkpoints if cp.iteration_count > resume_checkpoint.iteration_count]
@@ -199,10 +193,7 @@ async def main() -> None:
print("\nNo additional checkpoints recorded beyond plan approval; sample complete.")
return
- post_plan_checkpoint = max(
- later_checkpoints,
- key=lambda cp: (cp.iteration_count, cp.timestamp),
- )
+ post_plan_checkpoint = max(later_checkpoints, key=lambda cp: datetime.fromisoformat(cp.timestamp))
print("\n=== Stage 3: resume from post-plan checkpoint ===")
pending_messages = _pending_message_count(post_plan_checkpoint)
print(
diff --git a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py
index ec194d0fa3..12cb08a8be 100644
--- a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py
+++ b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py
@@ -3,6 +3,7 @@
import asyncio
import sys
from dataclasses import dataclass
+from datetime import datetime
from pathlib import Path
from typing import Any
@@ -25,9 +26,7 @@ from agent_framework import (
Message,
Workflow,
WorkflowBuilder,
- WorkflowCheckpoint,
WorkflowContext,
- get_checkpoint_summary,
handler,
response_handler,
)
@@ -188,9 +187,7 @@ def create_workflow(checkpoint_storage: FileCheckpointStorage) -> Workflow:
prepare_brief = BriefPreparer(id="prepare_brief", agent_id="writer")
workflow_builder = (
- WorkflowBuilder(
- max_iterations=6, start_executor=prepare_brief, checkpoint_storage=checkpoint_storage
- )
+ WorkflowBuilder(max_iterations=6, start_executor=prepare_brief, checkpoint_storage=checkpoint_storage)
.add_edge(prepare_brief, writer)
.add_edge(writer, review_gateway)
.add_edge(review_gateway, writer) # revisions loop
@@ -199,24 +196,6 @@ def create_workflow(checkpoint_storage: FileCheckpointStorage) -> Workflow:
return workflow_builder.build()
-def render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None:
- """Pretty-print saved checkpoints with the new framework summaries."""
-
- print("\nCheckpoint summary:")
- for summary in [get_checkpoint_summary(cp) for cp in sorted(checkpoints, key=lambda c: c.timestamp)]:
- # Compose a single line per checkpoint so the user can scan the output
- # and pick the resume point that still has outstanding human work.
- line = (
- f"- {summary.checkpoint_id} | timestamp={summary.timestamp} | iter={summary.iteration_count} "
- f"| targets={summary.targets} | states={summary.executor_ids}"
- )
- if summary.status:
- line += f" | status={summary.status}"
- if summary.pending_request_info_events:
- line += f" | pending_request_id={summary.pending_request_info_events[0].request_id}"
- print(line)
-
-
def prompt_for_responses(requests: dict[str, HumanApprovalRequest]) -> dict[str, str]:
"""Interactive CLI prompt for any live RequestInfo requests."""
@@ -304,16 +283,12 @@ async def main() -> None:
result = await run_interactive_session(workflow, initial_message=brief)
print(f"Workflow completed with: {result}")
- checkpoints = await storage.list_checkpoints()
+ checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
if not checkpoints:
print("No checkpoints recorded.")
return
- # Show the user what is available before we prompt for the index. The
- # summary helper keeps this output consistent with other tooling.
- render_checkpoint_summary(checkpoints)
-
- sorted_cps = sorted(checkpoints, key=lambda c: c.timestamp)
+ sorted_cps = sorted(checkpoints, key=lambda cp: datetime.fromisoformat(cp.timestamp))
print("\nAvailable checkpoints:")
for idx, cp in enumerate(sorted_cps):
print(f" [{idx}] id={cp.checkpoint_id} iter={cp.iteration_count}")
@@ -337,10 +312,6 @@ async def main() -> None:
return
chosen = sorted_cps[idx]
- summary = get_checkpoint_summary(chosen)
- if summary.status == "completed":
- print("Selected checkpoint already reflects a completed workflow; nothing to resume.")
- return
new_workflow = create_workflow(checkpoint_storage=storage)
# Resume with a fresh workflow instance. The checkpoint carries the
diff --git a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py
index 22a8423cba..572dd4f0ee 100644
--- a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py
+++ b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py
@@ -140,10 +140,9 @@ async def main():
break
# Find the latest checkpoint to resume from
- all_checkpoints = await checkpoint_storage.list_checkpoints()
- if not all_checkpoints:
+ latest_checkpoint = await checkpoint_storage.get_latest(workflow_name=workflow.name)
+ if not latest_checkpoint:
raise RuntimeError("No checkpoints available to resume from.")
- latest_checkpoint = all_checkpoints[-1]
print(
f"Checkpoint {latest_checkpoint.checkpoint_id}: "
f"(iter={latest_checkpoint.iteration_count}, messages={latest_checkpoint.messages})"
diff --git a/python/samples/getting_started/workflows/checkpoint/handoff_with_tool_approval_checkpoint_resume.py b/python/samples/getting_started/workflows/checkpoint/handoff_with_tool_approval_checkpoint_resume.py
deleted file mode 100644
index f39c997457..0000000000
--- a/python/samples/getting_started/workflows/checkpoint/handoff_with_tool_approval_checkpoint_resume.py
+++ /dev/null
@@ -1,405 +0,0 @@
-# Copyright (c) Microsoft. All rights reserved.
-
-import asyncio
-import json
-import logging
-from pathlib import Path
-from typing import cast
-
-from agent_framework import (
- Agent,
- AgentResponse,
- Content,
- FileCheckpointStorage,
- Message,
- Workflow,
- WorkflowEvent,
- tool,
-)
-from agent_framework.azure import AzureOpenAIChatClient
-from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder
-from azure.identity import AzureCliCredential
-
-"""
-Sample: Handoff Workflow with Tool Approvals + Checkpoint Resume
-
-Demonstrates resuming a handoff workflow from a checkpoint while handling both
-HandoffAgentUserRequest prompts and function approval request Content for tool calls
-(e.g., submit_refund).
-
-Scenario:
-1. User starts a conversation with the workflow.
-2. Agents may emit user input requests or tool approval requests.
-3. Workflow writes a checkpoint capturing pending requests and pauses.
-4. Process can exit/restart.
-5. On resume: Restore checkpoint, inspect pending requests, then provide responses.
-6. Workflow continues from the saved state.
-
-Pattern:
-- workflow.run(checkpoint_id=..., stream=True) to restore checkpoint and discover pending requests.
-- workflow.run(stream=True, responses=responses) to supply human replies and approvals.
- (Two steps are needed here because the sample must inspect request types before building responses.
- When response payloads are already known, use the single-call form:
- workflow.run(stream=True, checkpoint_id=..., responses=responses).)
-
-Prerequisites:
-- Azure CLI authentication (az login).
-- Environment variables configured for AzureOpenAIChatClient.
-"""
-
-CHECKPOINT_DIR = Path(__file__).parent / "tmp" / "handoff_checkpoints"
-CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
-
-
-@tool(approval_mode="always_require")
-def submit_refund(refund_description: str, amount: str, order_id: str) -> str:
- """Capture a refund request for manual review before processing."""
- return f"refund recorded for order {order_id} (amount: {amount}) with details: {refund_description}"
-
-
-def create_agents(client: AzureOpenAIChatClient) -> tuple[Agent, Agent, Agent]:
- """Create a simple handoff scenario: triage, refund, and order specialists."""
-
- triage = client.as_agent(
- name="triage_agent",
- instructions=(
- "You are a customer service triage agent. Listen to customer issues and determine "
- "if they need refund help or order tracking. Use handoff_to_refund_agent or "
- "handoff_to_order_agent to transfer them."
- ),
- )
-
- refund = client.as_agent(
- name="refund_agent",
- instructions=(
- "You are a refund specialist. Help customers with refund requests. "
- "Be empathetic and ask for order numbers if not provided. "
- "When the user confirms they want a refund and supplies order details, call submit_refund "
- "to record the request before continuing."
- ),
- tools=[submit_refund],
- )
-
- order = client.as_agent(
- name="order_agent",
- instructions=(
- "You are an order tracking specialist. Help customers track their orders. "
- "Ask for order numbers and provide shipping updates."
- ),
- )
-
- return triage, refund, order
-
-
-def create_workflow(checkpoint_storage: FileCheckpointStorage) -> tuple[Workflow, Agent, Agent, Agent]:
- """Build the handoff workflow with checkpointing enabled."""
-
- client = AzureOpenAIChatClient(credential=AzureCliCredential())
- triage, refund, order = create_agents(client)
-
- # checkpoint_storage: Enable checkpointing for resume
- # termination_condition: Terminate after 5 user messages for this demo
- workflow = (
- HandoffBuilder(
- name="checkpoint_handoff_demo",
- participants=[triage, refund, order],
- checkpoint_storage=checkpoint_storage,
- termination_condition=lambda conv: sum(1 for msg in conv if msg.role == "user") >= 5,
- )
- .with_start_agent(triage)
- .build()
- )
-
- return workflow, triage, refund, order
-
-
-def _print_handoff_agent_user_request(response: AgentResponse) -> None:
- """Display the agent's response messages when requesting user input."""
- if not response.messages:
- print("(No agent messages)")
- return
-
- print("\n[Agent is requesting your input...]")
- for message in response.messages:
- if not message.text:
- continue
- speaker = message.author_name or message.role
- print(f" {speaker}: {message.text}")
-
-
-def _print_handoff_request(request: HandoffAgentUserRequest, request_id: str) -> None:
- """Log pending handoff request details for debugging."""
- print(f"\n{'=' * 60}")
- print("WORKFLOW PAUSED - User input needed")
- print(f"Request ID: {request_id}")
- print(f"Awaiting agent: {request.agent_response.agent_id}")
-
- _print_handoff_agent_user_request(request.agent_response)
-
- print(f"{'=' * 60}\n")
-
-
-def _print_function_approval_request(request: Content, request_id: str) -> None:
- """Log pending tool approval details for debugging."""
- args = request.function_call.parse_arguments() or {} # type: ignore
- print(f"\n{'=' * 60}")
- print("WORKFLOW PAUSED - Tool approval required")
- print(f"Request ID: {request_id}")
- print(f"Function: {request.function_call.name}") # type: ignore
- print(f"Arguments:\n{json.dumps(args, indent=2)}")
- print(f"{'=' * 60}\n")
-
-
-def _build_responses_for_requests(
- pending_requests: list[WorkflowEvent],
- *,
- user_response: str | None,
- approve_tools: bool | None,
-) -> dict[str, object]:
- """Create response payloads for each pending request."""
- responses: dict[str, object] = {}
- for request in pending_requests:
- if isinstance(request.data, HandoffAgentUserRequest) and request.request_id:
- if user_response is None:
- raise ValueError("User response is required for HandoffAgentUserRequest")
- responses[request.request_id] = user_response
- elif (
- isinstance(request.data, Content)
- and request.data.type == "function_approval_request"
- and request.request_id
- ):
- if approve_tools is None:
- raise ValueError("Approval decision is required for function approval request")
- responses[request.request_id] = request.data.to_function_approval_response(approved=approve_tools)
- else:
- raise ValueError(f"Unsupported request type: {type(request.data)}")
- return responses
-
-
-async def run_until_user_input_needed(
- workflow: Workflow,
- initial_message: str | None = None,
- checkpoint_id: str | None = None,
-) -> tuple[list[WorkflowEvent], str | None]:
- """
- Run the workflow until it needs user input or approval, or completes.
-
- Returns:
- Tuple of (pending_requests, checkpoint_id_to_use_for_resume)
- """
- pending_requests: list[WorkflowEvent] = []
- latest_checkpoint_id: str | None = checkpoint_id
-
- if initial_message:
- print(f"\nStarting workflow with: {initial_message}\n")
- event_stream = workflow.run(message=initial_message, stream=True) # type: ignore[attr-defined]
- elif checkpoint_id:
- print(f"\nResuming workflow from checkpoint: {checkpoint_id}\n")
- event_stream = workflow.run(checkpoint_id=checkpoint_id, stream=True) # type: ignore[attr-defined]
- else:
- raise ValueError("Must provide either initial_message or checkpoint_id")
-
- async for event in event_stream:
- if event.type == "status":
- print(f"[Status] {event.state}")
-
- elif event.type == "request_info":
- pending_requests.append(event)
- if isinstance(event.data, HandoffAgentUserRequest):
- _print_handoff_request(event.data, event.request_id)
- elif isinstance(event.data, Content) and event.data.type == "function_approval_request":
- _print_function_approval_request(event.data, event.request_id)
-
- elif event.type == "output":
- print("\n[Workflow Completed]")
- if event.data:
- print(f"Final conversation length: {len(event.data)} messages")
- return [], None
-
- # Workflow paused with pending requests
- # The latest checkpoint was created at the end of the last superstep
- # We'll use the checkpoint storage to find it
- return pending_requests, latest_checkpoint_id
-
-
-async def resume_with_responses(
- workflow: Workflow,
- checkpoint_storage: FileCheckpointStorage,
- user_response: str | None = None,
- approve_tools: bool | None = None,
-) -> tuple[list[WorkflowEvent], str | None]:
- """
- Resume from checkpoint and send responses.
-
- Step 1: Restore checkpoint to discover pending request types.
- Step 2: Build typed responses and send via workflow.run(responses=...).
-
- When response payloads are already known, these can be combined into a single
- workflow.run(stream=True, checkpoint_id=..., responses=...) call.
- """
- print(f"\n{'=' * 60}")
- print("RESUMING WORKFLOW WITH HUMAN INPUT")
- if user_response is not None:
- print(f"User says: {user_response}")
- if approve_tools is not None:
- print(f"Approve tools: {approve_tools}")
- print(f"{'=' * 60}\n")
-
- # Get the latest checkpoint
- checkpoints = await checkpoint_storage.list_checkpoints()
- if not checkpoints:
- raise RuntimeError("No checkpoints found to resume from")
-
- # Sort by timestamp to get latest
- checkpoints.sort(key=lambda cp: cp.timestamp, reverse=True)
- latest_checkpoint = checkpoints[0]
-
- print(f"Restoring checkpoint {latest_checkpoint.checkpoint_id}")
-
- # First, restore checkpoint to discover pending requests
- restored_requests: list[WorkflowEvent] = []
- async for event in workflow.run(checkpoint_id=latest_checkpoint.checkpoint_id, stream=True): # type: ignore[attr-defined]
- if event.type == "request_info":
- restored_requests.append(event)
- if isinstance(event.data, HandoffAgentUserRequest):
- _print_handoff_request(event.data, event.request_id)
- elif isinstance(event.data, Content) and event.data.type == "function_approval_request":
- _print_function_approval_request(event.data, event.request_id)
-
- if not restored_requests:
- raise RuntimeError("No pending requests found after checkpoint restoration")
-
- responses = _build_responses_for_requests(
- restored_requests,
- user_response=user_response,
- approve_tools=approve_tools,
- )
- print(f"Sending responses for {len(responses)} request(s)")
-
- new_pending_requests: list[WorkflowEvent] = []
-
- async for event in workflow.run(stream=True, responses=responses):
- if event.type == "status":
- print(f"[Status] {event.state}")
-
- elif event.type == "output":
- print("\n[Workflow Output Event - Conversation Update]")
- if event.data and isinstance(event.data, list) and all(isinstance(msg, Message) for msg in event.data): # type: ignore
- # Now safe to cast event.data to list[Message]
- conversation = cast(list[Message], event.data) # type: ignore
- for msg in conversation[-3:]: # Show last 3 messages
- author = msg.author_name or msg.role
- text = msg.text[:100] + "..." if len(msg.text) > 100 else msg.text
- print(f" {author}: {text}")
-
- elif event.type == "request_info":
- new_pending_requests.append(event)
- if isinstance(event.data, HandoffAgentUserRequest):
- _print_handoff_request(event.data, event.request_id)
- elif isinstance(event.data, Content) and event.data.type == "function_approval_request":
- _print_function_approval_request(event.data, event.request_id)
-
- return new_pending_requests, latest_checkpoint.checkpoint_id
-
-
-async def main() -> None:
- """
- Demonstrate the checkpoint-based pause/resume pattern for handoff workflows.
-
- This sample shows:
- 1. Starting a workflow and getting a HandoffAgentUserRequest
- 2. Pausing (checkpoint is saved automatically)
- 3. Resuming from checkpoint with a user response or tool approval
- 4. Continuing the conversation until completion
- """
-
- # Enable INFO logging to see workflow progress
- logging.basicConfig(
- level=logging.INFO,
- format="[%(levelname)s] %(name)s: %(message)s",
- )
-
- # Clean up old checkpoints
- for file in CHECKPOINT_DIR.glob("*.json"):
- file.unlink()
- for file in CHECKPOINT_DIR.glob("*.json.tmp"):
- file.unlink()
-
- storage = FileCheckpointStorage(storage_path=CHECKPOINT_DIR)
- workflow, _, _, _ = create_workflow(checkpoint_storage=storage)
-
- print("=" * 60)
- print("HANDOFF WORKFLOW CHECKPOINT DEMO")
- print("=" * 60)
-
- # Scenario: User needs help with a damaged order
- initial_request = "Hi, my order 12345 arrived damaged. I need a refund."
-
- # Phase 1: Initial run - workflow will pause when it needs user input
- pending_requests, _ = await run_until_user_input_needed(
- workflow,
- initial_message=initial_request,
- )
-
- if not pending_requests:
- print("Workflow completed without needing user input")
- return
-
- print("\n>>> Workflow paused. You could exit the process here.")
- print(f">>> Checkpoint was saved. Pending requests: {len(pending_requests)}")
-
- # Scripted human input for demo purposes
- handoff_responses = [
- (
- "The headphones in order 12345 arrived cracked. "
- "Please submit the refund for $89.99 and send a replacement to my original address."
- ),
- "Yes, that covers the damage and refund request.",
- "That's everything I needed for the refund.",
- "Thanks for handling the refund.",
- ]
- approval_decisions = [True, True, True]
- handoff_index = 0
- approval_index = 0
-
- while pending_requests:
- print("\n>>> Simulating process restart...\n")
- workflow_step, _, _, _ = create_workflow(checkpoint_storage=storage)
-
- needs_user_input = any(isinstance(req.data, HandoffAgentUserRequest) for req in pending_requests)
- needs_tool_approval = any(
- isinstance(req.data, Content) and req.data.type == "function_approval_request" for req in pending_requests
- )
-
- user_response = None
- if needs_user_input:
- if handoff_index < len(handoff_responses):
- user_response = handoff_responses[handoff_index]
- handoff_index += 1
- else:
- user_response = handoff_responses[-1]
- print(f">>> Responding to handoff request with: {user_response}")
-
- approval_response = None
- if needs_tool_approval:
- if approval_index < len(approval_decisions):
- approval_response = approval_decisions[approval_index]
- approval_index += 1
- else:
- approval_response = approval_decisions[-1]
- print(">>> Approving pending tool calls from the agent.")
-
- pending_requests, _ = await resume_with_responses(
- workflow_step,
- storage,
- user_response=user_response,
- approve_tools=approval_response,
- )
-
- print("\n" + "=" * 60)
- print("DEMO COMPLETE")
- print("=" * 60)
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py b/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py
index b93a58a50c..833bd7c920 100644
--- a/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py
+++ b/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py
@@ -345,14 +345,12 @@ async def main() -> None:
if request_id is None:
raise RuntimeError("Sub-workflow completed without requesting review.")
- checkpoints = await storage.list_checkpoints(workflow.id)
- if not checkpoints:
+ resume_checkpoint = await storage.get_latest(workflow_name=workflow.name)
+ if not resume_checkpoint:
raise RuntimeError("No checkpoints found.")
# Print the checkpoint to show pending requests
# We didn't handle the request above so the request is still pending the last checkpoint
- checkpoints.sort(key=lambda cp: cp.timestamp)
- resume_checkpoint = checkpoints[-1]
print(f"Using checkpoint {resume_checkpoint.checkpoint_id} at iteration {resume_checkpoint.iteration_count}")
checkpoint_path = storage.storage_path / f"{resume_checkpoint.checkpoint_id}.json"
diff --git a/python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py b/python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py
index 4fc980e008..552ced2892 100644
--- a/python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py
+++ b/python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py
@@ -69,7 +69,7 @@ async def basic_checkpointing() -> None:
print(f"[{speaker}]: {msg.text}")
# Show checkpoints that were created
- checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
+ checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
print(f"\nCheckpoints created: {len(checkpoints)}")
for i, cp in enumerate(checkpoints[:5], 1):
print(f" {i}. {cp.checkpoint_id}")
@@ -110,7 +110,7 @@ async def checkpointing_with_thread() -> None:
print(f"[assistant]: {response2.messages[0].text}")
# Show accumulated state
- checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
+ checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
print(f"\nTotal checkpoints across both turns: {len(checkpoints)}")
if thread.message_store:
@@ -147,7 +147,7 @@ async def streaming_with_checkpoints() -> None:
print() # Newline after streaming
- checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
+ checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
print(f"\nCheckpoints created during stream: {len(checkpoints)}")
diff --git a/python/uv.lock b/python/uv.lock
index 24396ef396..ff7328ebf1 100644
--- a/python/uv.lock
+++ b/python/uv.lock
@@ -331,7 +331,7 @@ dependencies = [
{ name = "opentelemetry-semantic-conventions-ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
- { name = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
+ { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
@@ -386,7 +386,7 @@ requires-dist = [
{ name = "opentelemetry-semantic-conventions-ai", specifier = ">=0.4.13" },
{ name = "packaging", specifier = ">=24.1" },
{ name = "pydantic", specifier = ">=2,<3" },
- { name = "pydantic-settings", specifier = ">=2,<3" },
+ { name = "python-dotenv", specifier = ">=1,<2" },
{ name = "typing-extensions" },
]
provides-extras = ["all"]
@@ -1000,15 +1000,15 @@ wheels = [
[[package]]
name = "azure-core"
-version = "1.38.0"
+version = "1.38.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/dc/1b/e503e08e755ea94e7d3419c9242315f888fc664211c90d032e40479022bf/azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993", size = 363033, upload-time = "2026-01-12T17:03:05.535Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/9b/23893febea484ad8183112c9419b5eb904773adb871492b5fa8ff7b21e09/azure_core-1.38.1.tar.gz", hash = "sha256:9317db1d838e39877eb94a2240ce92fa607db68adf821817b723f0d679facbf6", size = 363323, upload-time = "2026-02-11T02:03:06.051Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fc/d8/b8fcba9464f02b121f39de2db2bf57f0b216fe11d014513d666e8634380d/azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335", size = 217825, upload-time = "2026-01-12T17:03:07.291Z" },
+ { url = "https://files.pythonhosted.org/packages/db/88/aaea2ad269ce70b446660371286272c1f6ba66541a7f6f635baf8b0db726/azure_core-1.38.1-py3-none-any.whl", hash = "sha256:69f08ee3d55136071b7100de5b198994fc1c5f89d2b91f2f43156d20fcf200a4", size = 217930, upload-time = "2026-02-11T02:03:07.548Z" },
]
[[package]]
@@ -1043,7 +1043,7 @@ wheels = [
[[package]]
name = "azure-identity"
-version = "1.25.1"
+version = "1.25.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -1052,9 +1052,9 @@ dependencies = [
{ name = "msal-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/06/8d/1a6c41c28a37eab26dc85ab6c86992c700cd3f4a597d9ed174b0e9c69489/azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456", size = 279826, upload-time = "2025-10-06T20:30:02.194Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c2/3a/439a32a5e23e45f6a91f0405949dc66cfe6834aba15a430aebfc063a81e7/azure_identity-1.25.2.tar.gz", hash = "sha256:030dbaa720266c796221c6cdbd1999b408c079032c919fef725fcc348a540fe9", size = 284709, upload-time = "2026-02-11T01:55:42.323Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/83/7b/5652771e24fff12da9dde4c20ecf4682e606b104f26419d139758cc935a6/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651", size = 191317, upload-time = "2025-10-06T20:30:04.251Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/77/f658c76f9e9a52c784bd836aaca6fd5b9aae176f1f53273e758a2bcda695/azure_identity-1.25.2-py3-none-any.whl", hash = "sha256:1b40060553d01a72ba0d708b9a46d0f61f56312e215d8896d836653ffdc6753d", size = 191423, upload-time = "2026-02-11T01:55:44.245Z" },
]
[[package]]
@@ -1324,19 +1324,19 @@ wheels = [
[[package]]
name = "claude-agent-sdk"
-version = "0.1.34"
+version = "0.1.35"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "mcp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/9d/69/faeb64e9c8f0962cbf12bee1b959acc41f87c82947ec7074a6780b417001/claude_agent_sdk-0.1.34.tar.gz", hash = "sha256:db9e4023a754d9a58a0793666fe9174ead277197cd896156d2f8784cc73c5006", size = 61196, upload-time = "2026-02-10T01:04:00.585Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4d/b9/60f21337cfb0d029cbafefdb5de5d043a5d84e44f286f04dbadda7fda932/claude_agent_sdk-0.1.35.tar.gz", hash = "sha256:0f98e2b3c71ca85abfc042e7a35c648df88e87fda41c52e6779ef7b038dcbb52", size = 61194, upload-time = "2026-02-10T23:21:04.114Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e7/7b/2ccdc12b553a61b59b0470f1cf3b0a864c79ab8a4ac8013ea22fa3e6c461/claude_agent_sdk-0.1.34-py3-none-macosx_11_0_arm64.whl", hash = "sha256:18569ab4bfb5451c4aacb51c0d44eb9802d18d8442d30c29f32b6e8a2479d210", size = 54604881, upload-time = "2026-02-10T01:03:44.575Z" },
- { url = "https://files.pythonhosted.org/packages/38/59/335b213fb3342c4405fa992cc9e45e52e18a543068f0574ae84010dc4c08/claude_agent_sdk-0.1.34-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:d7ecd7421066e405376d3feca21ccb3e9245506ba7c219858f7a7f0129877cdb", size = 69359030, upload-time = "2026-02-10T01:03:49.113Z" },
- { url = "https://files.pythonhosted.org/packages/93/3d/28c715efdad7b5c413e046f5f914d9ab888d25f2c3bb9233f1164d58d2be/claude_agent_sdk-0.1.34-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:82e9148410ec98ff4061e43e85601d8f0a2e8568d897ab82c324ccf11c297fc5", size = 69949555, upload-time = "2026-02-10T01:03:53.525Z" },
- { url = "https://files.pythonhosted.org/packages/12/3d/843159343b20d6c9b44cf4a7fe46b568d5b448276ba8ba4c49178d26ba4c/claude_agent_sdk-0.1.34-py3-none-win_amd64.whl", hash = "sha256:a64031a9bf5c70388a6a84368d350d68586d9854a1539f494b46ec6d0b6acf93", size = 72493949, upload-time = "2026-02-10T01:03:57.839Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/1a/68ca97f034a1773bd234a4572e1a660d3f37b93e8ab9c8da95c36a10fd00/claude_agent_sdk-0.1.35-py3-none-macosx_11_0_arm64.whl", hash = "sha256:df67f4deade77b16a9678b3a626c176498e40417f33b04beda9628287f375591", size = 54665881, upload-time = "2026-02-10T23:20:48.107Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/56/535f23919882397571e15f4fb0418897ba9b527dc3a8a6c84b4f537486e0/claude_agent_sdk-0.1.35-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:14963944f55ded7c8ed518feebfa5b4284aa6dd8d81aeff2e5b21a962ce65097", size = 69419673, upload-time = "2026-02-10T23:20:53.463Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/b5/763dda7d4d8c12a2ce485ef5cc98f2e7d2699bf3e0d8e2a7fb294d54c34b/claude_agent_sdk-0.1.35-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:84344dcc535d179c1fc8a11c6f34c37c3b583447bdf09d869effb26514fd7a65", size = 70007339, upload-time = "2026-02-10T23:20:57.629Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/89/10f3d0355ee873203104714d2d728315ee5919c793e3626fab94a91ea29b/claude_agent_sdk-0.1.35-py3-none-win_amd64.whl", hash = "sha256:1b3d54b47448c93f6f372acd4d1757f047c3c1e8ef5804be7a1e3e53e2c79a5f", size = 72528072, upload-time = "2026-02-10T23:21:01.418Z" },
]
[[package]]
@@ -1356,7 +1356,7 @@ name = "clr-loader"
version = "0.2.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
+ { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/24/c12faf3f61614b3131b5c98d3bf0d376b49c7feaa73edca559aeb2aee080/clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446", size = 83605, upload-time = "2026-01-03T23:13:06.984Z" }
wheels = [
@@ -1673,62 +1673,62 @@ wheels = [
[[package]]
name = "cryptography"
-version = "46.0.4"
+version = "46.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" },
{ name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" },
- { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" },
- { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" },
- { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" },
- { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" },
- { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" },
- { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" },
- { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" },
- { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" },
- { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" },
- { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" },
- { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" },
- { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" },
- { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" },
- { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" },
- { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" },
- { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" },
- { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" },
- { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" },
- { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" },
- { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" },
- { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" },
- { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" },
- { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" },
- { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" },
- { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" },
- { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" },
- { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" },
- { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" },
- { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" },
- { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" },
- { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" },
- { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" },
- { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" },
- { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" },
- { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" },
- { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" },
- { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" },
- { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" },
- { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" },
- { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" },
- { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" },
- { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" },
- { url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" },
- { url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" },
- { url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" },
- { url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" },
- { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
+ { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
+ { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
+ { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
+ { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
+ { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
+ { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
+ { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
+ { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
+ { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
+ { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
+ { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
+ { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
+ { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
+ { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
+ { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
+ { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
+ { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" },
]
[[package]]
@@ -1835,7 +1835,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" },
+ { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
@@ -1853,7 +1853,7 @@ wheels = [
[[package]]
name = "fastapi"
-version = "0.128.6"
+version = "0.128.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -1862,9 +1862,9 @@ dependencies = [
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/83/d1/195005b5e45b443e305136df47ee7df4493d782e0c039dd0d97065580324/fastapi-0.128.6.tar.gz", hash = "sha256:0cb3946557e792d731b26a42b04912f16367e3c3135ea8290f620e234f2b604f", size = 374757, upload-time = "2026-02-09T17:27:03.541Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a0/fc/af386750b3fd8d8828167e4c82b787a8eeca2eca5c5429c9db8bb7c70e04/fastapi-0.128.7.tar.gz", hash = "sha256:783c273416995486c155ad2c0e2b45905dedfaf20b9ef8d9f6a9124670639a24", size = 375325, upload-time = "2026-02-10T12:26:40.968Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/24/58/a2c4f6b240eeb148fb88cdac48f50a194aba760c1ca4988c6031c66a20ee/fastapi-0.128.6-py3-none-any.whl", hash = "sha256:bb1c1ef87d6086a7132d0ab60869d6f1ee67283b20fbf84ec0003bd335099509", size = 103674, upload-time = "2026-02-09T17:27:02.355Z" },
+ { url = "https://files.pythonhosted.org/packages/af/1a/f983b45661c79c31be575c570d46c437a5409b67a939c1b3d8d6b3ed7a7f/fastapi-0.128.7-py3-none-any.whl", hash = "sha256:6bd9bd31cb7047465f2d3fa3ba3f33b0870b17d4eaf7cdb36d1576ab060ad662", size = 103630, upload-time = "2026-02-10T12:26:39.414Z" },
]
[[package]]
@@ -2301,7 +2301,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/65/5b235b40581ad75ab97dcd8b4218022ae8e3ab77c13c919f1a1dfe9171fd/greenlet-3.3.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:04bee4775f40ecefcdaa9d115ab44736cd4b9c5fba733575bfe9379419582e13", size = 273723, upload-time = "2026-01-23T15:30:37.521Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ad/eb4729b85cba2d29499e0a04ca6fbdd8f540afd7be142fd571eea43d712f/greenlet-3.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e1457f4fed12a50e427988a07f0f9df53cf0ee8da23fab16e6732c2ec909d4", size = 574874, upload-time = "2026-01-23T16:00:54.551Z" },
{ url = "https://files.pythonhosted.org/packages/87/32/57cad7fe4c8b82fdaa098c89498ef85ad92dfbb09d5eb713adedfc2ae1f5/greenlet-3.3.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:070472cd156f0656f86f92e954591644e158fd65aa415ffbe2d44ca77656a8f5", size = 586309, upload-time = "2026-01-23T16:05:25.18Z" },
- { url = "https://files.pythonhosted.org/packages/66/66/f041005cb87055e62b0d68680e88ec1a57f4688523d5e2fb305841bc8307/greenlet-3.3.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1108b61b06b5224656121c3c8ee8876161c491cbe74e5c519e0634c837cf93d5", size = 597461, upload-time = "2026-01-23T16:15:51.943Z" },
{ url = "https://files.pythonhosted.org/packages/87/eb/8a1ec2da4d55824f160594a75a9d8354a5fe0a300fb1c48e7944265217e1/greenlet-3.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a300354f27dd86bae5fbf7002e6dd2b3255cd372e9242c933faf5e859b703fe", size = 586985, upload-time = "2026-01-23T15:32:47.968Z" },
{ url = "https://files.pythonhosted.org/packages/15/1c/0621dd4321dd8c351372ee8f9308136acb628600658a49be1b7504208738/greenlet-3.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e84b51cbebf9ae573b5fbd15df88887815e3253fc000a7d0ff95170e8f7e9729", size = 1547271, upload-time = "2026-01-23T16:04:18.977Z" },
{ url = "https://files.pythonhosted.org/packages/9d/53/24047f8924c83bea7a59c8678d9571209c6bfe5f4c17c94a78c06024e9f2/greenlet-3.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0093bd1a06d899892427217f0ff2a3c8f306182b8c754336d32e2d587c131b4", size = 1613427, upload-time = "2026-01-23T15:33:44.428Z" },
@@ -2309,7 +2308,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" },
{ url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" },
- { url = "https://files.pythonhosted.org/packages/70/ae/e2d5f0e59b94a2269b68a629173263fa40b63da32f5c231307c349315871/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f", size = 601161, upload-time = "2026-01-23T16:15:53.456Z" },
{ url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" },
{ url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" },
@@ -2318,7 +2316,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" },
{ url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" },
{ url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" },
- { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" },
{ url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" },
{ url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" },
{ url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" },
@@ -2327,7 +2324,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" },
{ url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" },
{ url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" },
- { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" },
{ url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" },
{ url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" },
{ url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" },
@@ -2336,7 +2332,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" },
{ url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" },
{ url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" },
- { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" },
{ url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" },
{ url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" },
@@ -2345,7 +2340,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" },
{ url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" },
{ url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" },
- { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" },
{ url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" },
{ url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" },
{ url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" },
@@ -3059,7 +3053,7 @@ wheels = [
[[package]]
name = "litellm"
-version = "1.81.9"
+version = "1.81.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -3075,9 +3069,9 @@ dependencies = [
{ name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ff/8f/2a08f3d86fd008b4b02254649883032068378a8551baed93e8d9dcbbdb5d/litellm-1.81.9.tar.gz", hash = "sha256:a2cd9bc53a88696c21309ef37c55556f03c501392ed59d7f4250f9932917c13c", size = 16276983, upload-time = "2026-02-07T21:14:24.473Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/40/fc/78887158b4057835ba2c647a1bd4da650fd79142f8412c6d0bbe6d8c6081/litellm-1.81.10.tar.gz", hash = "sha256:8d769a7200888e1295592af5ce5cb0ff035832250bd0102a4ca50acf5820ca50", size = 16297572, upload-time = "2026-02-11T00:17:47.347Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0b/8b/672fc06c8a2803477e61e0de383d3c6e686e0f0fc62789c21f0317494076/litellm-1.81.9-py3-none-any.whl", hash = "sha256:24ee273bc8a62299fbb754035f83fb7d8d44329c383701a2bd034f4fd1c19084", size = 14433170, upload-time = "2026-02-07T21:14:21.469Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/bb/3f3cc3d79657bc9daaa1319ec3a9d75e4889fc88d07e327f0ac02cd2ac7d/litellm-1.81.10-py3-none-any.whl", hash = "sha256:9efa1cbe61ac051f6500c267b173d988ff2d511c2eecf1c8f2ee546c0870747c", size = 14457931, upload-time = "2026-02-11T00:17:43.431Z" },
]
[package.optional-dependencies]
@@ -3119,11 +3113,11 @@ wheels = [
[[package]]
name = "litellm-proxy-extras"
-version = "0.4.33"
+version = "0.4.34"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/51/4f/1e8644cdda2892d2dc8151153ca4d8a6fc44000363677a52f9988e56713a/litellm_proxy_extras-0.4.33.tar.gz", hash = "sha256:133dc5476b540d99e75d4baef622267e7344ced97737c174679baff429e7f212", size = 23973, upload-time = "2026-02-07T19:07:32.67Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/0f/e04f9718ddfc7a87b682e0eb98f18a5179dbe497e5d02a76ebe6aaae7269/litellm_proxy_extras-0.4.34.tar.gz", hash = "sha256:39fa6c2295acc449320b5a710d150295fd0bf5f8c0d1742b5e9ae361d7bd3ed2", size = 24232, upload-time = "2026-02-10T21:59:31.948Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/c0/b9960391b983306c39f1fa28e2eedf5d0e2048879fde8707a2d80896ed10/litellm_proxy_extras-0.4.33-py3-none-any.whl", hash = "sha256:bebea1b091490df19cfa773bd311f08254dee5bb53f92d282b7a5bdfba936334", size = 52533, upload-time = "2026-02-07T19:07:31.665Z" },
+ { url = "https://files.pythonhosted.org/packages/21/71/a32bdfa74c598dde072d860ba1facaa522b4ef75c07c894c614999e73d75/litellm_proxy_extras-0.4.34-py3-none-any.whl", hash = "sha256:d455eb54f82e7c92f4f68a921240822df23158aad05fcdda7245887db7c30b90", size = 53171, upload-time = "2026-02-10T21:59:30.728Z" },
]
[[package]]
@@ -3884,7 +3878,7 @@ wheels = [
[[package]]
name = "openai"
-version = "2.18.0"
+version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -3896,9 +3890,9 @@ dependencies = [
{ name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/9e/cb/f2c9f988a06d1fcdd18ddc010f43ac384219a399eb01765493d6b34b1461/openai-2.18.0.tar.gz", hash = "sha256:5018d3bcb6651c5aac90e6d0bf9da5cde1bdd23749f67b45b37c522b6e6353af", size = 632124, upload-time = "2026-02-09T21:42:18.017Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6e/5a/f495777c02625bfa18212b6e3b73f1893094f2bf660976eb4bc6f43a1ca2/openai-2.20.0.tar.gz", hash = "sha256:2654a689208cd0bf1098bb9462e8d722af5cbe961e6bba54e6f19fb843d88db1", size = 642355, upload-time = "2026-02-10T19:02:54.145Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/20/5f/8940e0641c223eaf972732b3154f2178a968290f8cb99e8c88582cde60ed/openai-2.18.0-py3-none-any.whl", hash = "sha256:538f97e1c77a00e3a99507688c878cda7e9e63031807ba425c68478854d48b30", size = 1069897, upload-time = "2026-02-09T21:42:16.4Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/a0/cf4297aa51bbc21e83ef0ac018947fa06aea8f2364aad7c96cbf148590e6/openai-2.20.0-py3-none-any.whl", hash = "sha256:38d989c4b1075cd1f76abc68364059d822327cf1a932531d429795f4fc18be99", size = 1098479, upload-time = "2026-02-10T19:02:52.157Z" },
]
[[package]]
@@ -4353,100 +4347,100 @@ wheels = [
[[package]]
name = "pillow"
-version = "12.1.0"
+version = "12.1.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fe/41/f73d92b6b883a579e79600d391f2e21cb0df767b2714ecbd2952315dfeef/pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd", size = 5304089, upload-time = "2026-01-02T09:10:24.953Z" },
- { url = "https://files.pythonhosted.org/packages/94/55/7aca2891560188656e4a91ed9adba305e914a4496800da6b5c0a15f09edf/pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0", size = 4657815, upload-time = "2026-01-02T09:10:27.063Z" },
- { url = "https://files.pythonhosted.org/packages/e9/d2/b28221abaa7b4c40b7dba948f0f6a708bd7342c4d47ce342f0ea39643974/pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8", size = 6222593, upload-time = "2026-01-02T09:10:29.115Z" },
- { url = "https://files.pythonhosted.org/packages/71/b8/7a61fb234df6a9b0b479f69e66901209d89ff72a435b49933f9122f94cac/pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1", size = 8027579, upload-time = "2026-01-02T09:10:31.182Z" },
- { url = "https://files.pythonhosted.org/packages/ea/51/55c751a57cc524a15a0e3db20e5cde517582359508d62305a627e77fd295/pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda", size = 6335760, upload-time = "2026-01-02T09:10:33.02Z" },
- { url = "https://files.pythonhosted.org/packages/dc/7c/60e3e6f5e5891a1a06b4c910f742ac862377a6fe842f7184df4a274ce7bf/pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7", size = 7027127, upload-time = "2026-01-02T09:10:35.009Z" },
- { url = "https://files.pythonhosted.org/packages/06/37/49d47266ba50b00c27ba63a7c898f1bb41a29627ced8c09e25f19ebec0ff/pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a", size = 6449896, upload-time = "2026-01-02T09:10:36.793Z" },
- { url = "https://files.pythonhosted.org/packages/f9/e5/67fd87d2913902462cd9b79c6211c25bfe95fcf5783d06e1367d6d9a741f/pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef", size = 7151345, upload-time = "2026-01-02T09:10:39.064Z" },
- { url = "https://files.pythonhosted.org/packages/bd/15/f8c7abf82af68b29f50d77c227e7a1f87ce02fdc66ded9bf603bc3b41180/pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09", size = 6325568, upload-time = "2026-01-02T09:10:41.035Z" },
- { url = "https://files.pythonhosted.org/packages/d4/24/7d1c0e160b6b5ac2605ef7d8be537e28753c0db5363d035948073f5513d7/pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91", size = 7032367, upload-time = "2026-01-02T09:10:43.09Z" },
- { url = "https://files.pythonhosted.org/packages/f4/03/41c038f0d7a06099254c60f618d0ec7be11e79620fc23b8e85e5b31d9a44/pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea", size = 2452345, upload-time = "2026-01-02T09:10:44.795Z" },
- { url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" },
- { url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" },
- { url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" },
- { url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" },
- { url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" },
- { url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" },
- { url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" },
- { url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" },
- { url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" },
- { url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" },
- { url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" },
- { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" },
- { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" },
- { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" },
- { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" },
- { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" },
- { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" },
- { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" },
- { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" },
- { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" },
- { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" },
- { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" },
- { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" },
- { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" },
- { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" },
- { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" },
- { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" },
- { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" },
- { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" },
- { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" },
- { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" },
- { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" },
- { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" },
- { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" },
- { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" },
- { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" },
- { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" },
- { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" },
- { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" },
- { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" },
- { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" },
- { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" },
- { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" },
- { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" },
- { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" },
- { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" },
- { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" },
- { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" },
- { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" },
- { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" },
- { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" },
- { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" },
- { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" },
- { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" },
- { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" },
- { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" },
- { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" },
- { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" },
- { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" },
- { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" },
- { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" },
- { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" },
- { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" },
- { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" },
- { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" },
- { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" },
- { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" },
- { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" },
- { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" },
- { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" },
- { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" },
- { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" },
- { url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" },
- { url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" },
- { url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" },
- { url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" },
- { url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" },
- { url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" },
- { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" },
+ { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" },
+ { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" },
+ { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" },
+ { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" },
+ { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" },
+ { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" },
+ { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" },
+ { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" },
+ { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" },
+ { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
+ { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
+ { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
+ { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
+ { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
+ { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
+ { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
+ { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
+ { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
+ { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
+ { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
+ { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
+ { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
+ { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
+ { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
+ { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
+ { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
+ { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
+ { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
+ { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
+ { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
+ { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" },
+ { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" },
+ { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" },
+ { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" },
+ { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" },
]
[[package]]
@@ -4565,8 +4559,8 @@ name = "powerfx"
version = "0.0.34"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
- { name = "pythonnet", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
+ { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
+ { name = "pythonnet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/fb/6c4bf87e0c74ca1c563921ce89ca1c5785b7576bca932f7255cdf81082a7/powerfx-0.0.34.tar.gz", hash = "sha256:956992e7afd272657ed16d80f4cad24ec95d9e4a79fb9dfa4a068a09e136af32", size = 3237555, upload-time = "2025-12-22T15:50:59.682Z" }
wheels = [
@@ -5215,7 +5209,7 @@ name = "pythonnet"
version = "3.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "clr-loader", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
+ { name = "clr-loader", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" }
wheels = [
@@ -6471,15 +6465,15 @@ wheels = [
[[package]]
name = "typer-slim"
-version = "0.21.1"
+version = "0.21.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
+ { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
- { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/17/d4/064570dec6358aa9049d4708e4a10407d74c99258f8b2136bb8702303f1a/typer_slim-0.21.1.tar.gz", hash = "sha256:73495dd08c2d0940d611c5a8c04e91c2a0a98600cbd4ee19192255a233b6dbfd", size = 110478, upload-time = "2026-01-06T11:21:11.176Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a5/ca/0d9d822fd8a4c7e830cba36a2557b070d4b4a9558a0460377a61f8fb315d/typer_slim-0.21.2.tar.gz", hash = "sha256:78f20d793036a62aaf9c3798306142b08261d4b2a941c6e463081239f062a2f9", size = 120497, upload-time = "2026-02-10T19:33:45.836Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c8/0a/4aca634faf693e33004796b6cee0ae2e1dba375a800c16ab8d3eff4bb800/typer_slim-0.21.1-py3-none-any.whl", hash = "sha256:6e6c31047f171ac93cc5a973c9e617dbc5ab2bddc4d0a3135dc161b4e2020e0d", size = 47444, upload-time = "2026-01-06T11:21:12.441Z" },
+ { url = "https://files.pythonhosted.org/packages/54/03/e09325cfc40a33a82b31ba1a3f1d97e85246736856a45a43b19fcb48b1c2/typer_slim-0.21.2-py3-none-any.whl", hash = "sha256:4705082bb6c66c090f60e47c8be09a93158c139ce0aa98df7c6c47e723395e5f", size = 56790, upload-time = "2026-02-10T19:33:47.221Z" },
]
[[package]]
@@ -6565,27 +6559,27 @@ wheels = [
[[package]]
name = "uv"
-version = "0.10.0"
+version = "0.10.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/09/36/f7fe4de0ad81234ac43938fe39c6ba84595c6b3a1868d786a4d7ad19e670/uv-0.10.0.tar.gz", hash = "sha256:ad01dd614a4bb8eb732da31ade41447026427397c5ad171cc98bd59579ef57ea", size = 3854103, upload-time = "2026-02-05T20:57:55.248Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0d/9a/fe74aa0127cdc26141364e07abf25e5d69b4bf9788758fad9cfecca637aa/uv-0.10.2.tar.gz", hash = "sha256:b5016f038e191cc9ef00e17be802f44363d1b1cc3ef3454d1d76839a4246c10a", size = 3858864, upload-time = "2026-02-10T19:17:51.609Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f4/69/33fb64aee6ba138b1aaf957e20778e94a8c23732e41cdf68e6176aa2cf4e/uv-0.10.0-py3-none-linux_armv6l.whl", hash = "sha256:38dc0ccbda6377eb94095688c38e5001b8b40dfce14b9654949c1f0b6aa889df", size = 21984662, upload-time = "2026-02-05T20:57:19.076Z" },
- { url = "https://files.pythonhosted.org/packages/1a/5a/e3ff8a98cfbabc5c2d09bf304d2d9d2d7b2e7d60744241ac5ed762015e5c/uv-0.10.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a165582c1447691109d49d09dccb065d2a23852ff42bf77824ff169909aa85da", size = 21057249, upload-time = "2026-02-05T20:56:48.921Z" },
- { url = "https://files.pythonhosted.org/packages/ee/77/ec8f24f8d0f19c4fda0718d917bb78b9e6f02a4e1963b401f1c4f4614a54/uv-0.10.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:aefea608971f4f23ac3dac2006afb8eb2b2c1a2514f5fee1fac18e6c45fd70c4", size = 19827174, upload-time = "2026-02-05T20:57:10.581Z" },
- { url = "https://files.pythonhosted.org/packages/c6/7e/09b38b93208906728f591f66185a425be3acdb97c448460137d0e6ecb30a/uv-0.10.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:d4b621bcc5d0139502789dc299bae8bf55356d07b95cb4e57e50e2afcc5f43e1", size = 21629522, upload-time = "2026-02-05T20:57:29.959Z" },
- { url = "https://files.pythonhosted.org/packages/89/f3/48d92c90e869331306979efaa29a44c3e7e8376ae343edc729df0d534dfb/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:b4bea728a6b64826d0091f95f28de06dd2dc786384b3d336a90297f123b4da0e", size = 21614812, upload-time = "2026-02-05T20:56:58.103Z" },
- { url = "https://files.pythonhosted.org/packages/ff/43/d0dedfcd4fe6e36cabdbeeb43425cd788604db9d48425e7b659d0f7ba112/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc0cc2a4bcf9efbff9a57e2aed21c2d4b5a7ec2cc0096e0c33d7b53da17f6a3b", size = 21577072, upload-time = "2026-02-05T20:57:45.455Z" },
- { url = "https://files.pythonhosted.org/packages/c5/90/b8c9320fd8d86f356e37505a02aa2978ed28f9c63b59f15933e98bce97e5/uv-0.10.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:070ca2f0e8c67ca9a8f70ce403c956b7ed9d51e0c2e9dbbcc4efa5e0a2483f79", size = 22829664, upload-time = "2026-02-05T20:57:22.689Z" },
- { url = "https://files.pythonhosted.org/packages/56/9c/2c36b30b05c74b2af0e663e0e68f1d10b91a02a145e19b6774c121120c0b/uv-0.10.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8070c66149c06f9b39092a06f593a2241345ea2b1d42badc6f884c2cc089a1b1", size = 23705815, upload-time = "2026-02-05T20:57:37.604Z" },
- { url = "https://files.pythonhosted.org/packages/6c/a1/8c7fdb14ab72e26ca872e07306e496a6b8cf42353f9bf6251b015be7f535/uv-0.10.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3db1d5390b3a624de672d7b0f9c9d8197693f3b2d3d9c4d9e34686dcbc34197a", size = 22890313, upload-time = "2026-02-05T20:57:26.35Z" },
- { url = "https://files.pythonhosted.org/packages/f3/f8/5c152350b1a6d0af019801f91a1bdeac854c33deb36275f6c934f0113cb5/uv-0.10.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b46db718763bf742e986ebbc7a30ca33648957a0dcad34382970b992f5e900", size = 22769440, upload-time = "2026-02-05T20:56:53.859Z" },
- { url = "https://files.pythonhosted.org/packages/87/44/980e5399c6f4943b81754be9b7deb87bd56430e035c507984e17267d6a97/uv-0.10.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:eb95d28590edd73b8fdd80c27d699c45c52f8305170c6a90b830caf7f36670a4", size = 21695296, upload-time = "2026-02-05T20:57:06.732Z" },
- { url = "https://files.pythonhosted.org/packages/ae/e7/f44ad40275be2087b3910df4678ed62cf0c82eeb3375c4a35037a79747db/uv-0.10.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5871eef5046a81df3f1636a3d2b4ccac749c23c7f4d3a4bae5496cb2876a1814", size = 22424291, upload-time = "2026-02-05T20:57:49.067Z" },
- { url = "https://files.pythonhosted.org/packages/c2/81/31c0c0a8673140756e71a1112bf8f0fcbb48a4cf4587a7937f5bd55256b6/uv-0.10.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:1af0ec125a07edb434dfaa98969f6184c1313dbec2860c3c5ce2d533b257132a", size = 22109479, upload-time = "2026-02-05T20:57:02.258Z" },
- { url = "https://files.pythonhosted.org/packages/d7/d1/2eb51bc233bad3d13ad64a0c280fd4d1ebebf5c2939b3900a46670fa2b91/uv-0.10.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:45909b9a734250da05b10101e0a067e01ffa2d94bbb07de4b501e3cee4ae0ff3", size = 22972087, upload-time = "2026-02-05T20:57:52.847Z" },
- { url = "https://files.pythonhosted.org/packages/d2/f7/49987207b87b5c21e1f0e81c52892813e8cdf7e318b6373d6585773ebcdd/uv-0.10.0-py3-none-win32.whl", hash = "sha256:d5498851b1f07aa9c9af75578b2029a11743cb933d741f84dcbb43109a968c29", size = 20896746, upload-time = "2026-02-05T20:57:33.426Z" },
- { url = "https://files.pythonhosted.org/packages/80/b2/1370049596c6ff7fa1fe22fccf86a093982eac81017b8c8aff541d7263b2/uv-0.10.0-py3-none-win_amd64.whl", hash = "sha256:edd469425cd62bcd8c8cc0226c5f9043a94e37ed869da8268c80fdbfd3e5015e", size = 23433041, upload-time = "2026-02-05T20:57:41.41Z" },
- { url = "https://files.pythonhosted.org/packages/e3/76/1034c46244feafec2c274ac52b094f35d47c94cdb11461c24cf4be8a0c0c/uv-0.10.0-py3-none-win_arm64.whl", hash = "sha256:e90c509749b3422eebb54057434b7119892330d133b9690a88f8a6b0f3116be3", size = 21880261, upload-time = "2026-02-05T20:57:14.724Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/b5/aea88f66284d220be56ef748ed5e1bd11d819be14656a38631f4b55bfd48/uv-0.10.2-py3-none-linux_armv6l.whl", hash = "sha256:69e35aa3e91a245b015365e5e6ca383ecf72a07280c6d00c17c9173f2d3b68ab", size = 22215714, upload-time = "2026-02-10T19:17:34.281Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/72/947ba7737ae6cd50de61d268781b9e7717caa3b07e18238ffd547f9fc728/uv-0.10.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0b7eef95c36fe92e7aac399c0dce555474432cbfeaaa23975ed83a63923f78fd", size = 21276485, upload-time = "2026-02-10T19:18:15.415Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/38/5c3462b927a93be4ccaaa25138926a5fb6c9e1b72884efd7af77e451d82e/uv-0.10.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:acc08e420abab21de987151059991e3f04bc7f4044d94ca58b5dd547995b4843", size = 20048620, upload-time = "2026-02-10T19:17:26.481Z" },
+ { url = "https://files.pythonhosted.org/packages/03/51/d4509b0f5b7740c1af82202e9c69b700d5848b8bd0faa25229e8edd2c19c/uv-0.10.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:aefbcd749ab2ad48bb533ec028607607f7b03be11c83ea152dbb847226cd6285", size = 21870454, upload-time = "2026-02-10T19:17:21.838Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/7e/2bcbafcb424bb885817a7e58e6eec9314c190c55935daaafab1858bb82cd/uv-0.10.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:fad554c38d9988409ceddfac69a465e6e5f925a8b689e7606a395c20bb4d1d78", size = 21839508, upload-time = "2026-02-10T19:17:59.211Z" },
+ { url = "https://files.pythonhosted.org/packages/60/08/16df2c1f8ad121a595316b82f6e381447e8974265b2239c9135eb874f33b/uv-0.10.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6dd2dc41043e92b3316d7124a7bf48c2affe7117c93079419146f083df71933c", size = 21841283, upload-time = "2026-02-10T19:17:41.419Z" },
+ { url = "https://files.pythonhosted.org/packages/76/27/a869fec4c03af5e43db700fabe208d8ee8dbd56e0ff568ba792788d505cd/uv-0.10.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:111c05182c5630ac523764e0ec2e58d7b54eb149dbe517b578993a13c2f71aff", size = 23111967, upload-time = "2026-02-10T19:18:11.764Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/4a/fb38515d966acfbd80179e626985aab627898ffd02c70205850d6eb44df1/uv-0.10.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45c3deaba0343fd27ab5385d6b7cde0765df1a15389ee7978b14a51c32895662", size = 23911019, upload-time = "2026-02-10T19:18:26.947Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/5f/51bcbb490ddb1dcb06d767f0bde649ad2826686b9e30efa57f8ab2750a1d/uv-0.10.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bb2cac4f3be60b64a23d9f035019c30a004d378b563c94f60525c9591665a56b", size = 23030217, upload-time = "2026-02-10T19:17:37.789Z" },
+ { url = "https://files.pythonhosted.org/packages/46/69/144f6db851d49aa6f25b040dc5c8c684b8f92df9e8d452c7abc619c6ec23/uv-0.10.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937687df0380d636ceafcb728cf6357f0432588e721892128985417b283c3b54", size = 23036452, upload-time = "2026-02-10T19:18:18.97Z" },
+ { url = "https://files.pythonhosted.org/packages/66/29/3c7c4559c9310ed478e3d6c585ee0aad2852dc4d5fb14f4d92a2a12d1728/uv-0.10.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f90bca8703ae66bccfcfb7313b4b697a496c4d3df662f4a1a2696a6320c47598", size = 21941903, upload-time = "2026-02-10T19:17:30.575Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/5a/42883b5ef2ef0b1bc5b70a1da12a6854a929ff824aa8eb1a5571fb27a39b/uv-0.10.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:cca026c2e584788e1264879a123bf499dd8f169b9cafac4a2065a416e09d3823", size = 22651571, upload-time = "2026-02-10T19:18:22.74Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/b8/e4f1dda1b3b0cc6c8ac06952bfe7bc28893ff016fb87651c8fafc6dfca96/uv-0.10.2-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9f878837938103ee1307ed3ed5d9228118e3932816ab0deb451e7e16dc8ce82a", size = 22321279, upload-time = "2026-02-10T19:17:49.402Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/4b/baa16d46469e024846fc1a8aa0cfa63f1f89ad0fd3eaa985359a168c3fb0/uv-0.10.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6ec75cfe638b316b329474aa798c3988e5946ead4d9e977fe4dc6fc2ea3e0b8b", size = 23252208, upload-time = "2026-02-10T19:17:54.46Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/84/6a74e5ec2ee90e4314905e6d1d1708d473e06405e492ec38868b42645388/uv-0.10.2-py3-none-win32.whl", hash = "sha256:f7f3c7e09bf53b81f55730a67dd86299158f470dffb2bd279b6432feb198d231", size = 21118543, upload-time = "2026-02-10T19:18:07.296Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/f9/e5cc6cf3a578b87004e857274df97d3cdecd8e19e965869b9b67c094c20c/uv-0.10.2-py3-none-win_amd64.whl", hash = "sha256:7b3685aa1da15acbe080b4cba8684afbb6baf11c9b04d4d4b347cc18b7b9cfa0", size = 23620790, upload-time = "2026-02-10T19:17:45.204Z" },
+ { url = "https://files.pythonhosted.org/packages/df/7a/99979dc08ae6a65f4f7a44c5066699016c6eecdc4e695b7512c2efb53378/uv-0.10.2-py3-none-win_arm64.whl", hash = "sha256:abdd5b3c6b871b17bf852a90346eb7af881345706554fd082346b000a9393afd", size = 22035199, upload-time = "2026-02-10T19:18:03.679Z" },
]
[[package]]