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/README.md b/README.md index e86cf94e60..5dc11508e7 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Still have questions? Join our [weekly office hours](./COMMUNITY.md#public-commu ### ✨ **Highlights** - **Graph-based Workflows**: Connect agents and deterministic functions using data flows with streaming, checkpointing, human-in-the-loop, and time-travel capabilities - - [Python workflows](./python/samples/getting_started/workflows/) | [.NET workflows](./dotnet/samples/GettingStarted/Workflows/) + - [Python workflows](./python/samples/03-workflows/) | [.NET workflows](./dotnet/samples/GettingStarted/Workflows/) - **AF Labs**: Experimental packages for cutting-edge features including benchmarking, reinforcement learning, and research initiatives - [Labs directory](./python/packages/lab/) - **DevUI**: Interactive developer UI for agent development, testing, and debugging workflows @@ -73,11 +73,11 @@ Still have questions? Join our [weekly office hours](./COMMUNITY.md#public-commu - **Python and C#/.NET Support**: Full framework support for both Python and C#/.NET implementations with consistent APIs - [Python packages](./python/packages/) | [.NET source](./dotnet/src/) - **Observability**: Built-in OpenTelemetry integration for distributed tracing, monitoring, and debugging - - [Python observability](./python/samples/getting_started/observability/) | [.NET telemetry](./dotnet/samples/GettingStarted/AgentOpenTelemetry/) + - [Python observability](./python/samples/02-agents/observability/) | [.NET telemetry](./dotnet/samples/GettingStarted/AgentOpenTelemetry/) - **Multiple Agent Provider Support**: Support for various LLM providers with more being added continuously - - [Python examples](./python/samples/getting_started/agents/) | [.NET examples](./dotnet/samples/GettingStarted/AgentProviders/) + - [Python examples](./python/samples/02-agents/providers/) | [.NET examples](./dotnet/samples/GettingStarted/AgentProviders/) - **Middleware**: Flexible middleware system for request/response processing, exception handling, and custom pipelines - - [Python middleware](./python/samples/getting_started/middleware/) | [.NET middleware](./dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/) + - [Python middleware](./python/samples/02-agents/middleware/) | [.NET middleware](./dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/) ### 💬 **We want your feedback!** @@ -159,9 +159,9 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram ### Python -- [Getting Started with Agents](./python/samples/getting_started/agents): basic agent creation and tool usage -- [Chat Client Examples](./python/samples/getting_started/chat_client): direct chat client usage patterns -- [Getting Started with Workflows](./python/samples/getting_started/workflows): basic workflow creation and integration with agents +- [Getting Started with Agents](./python/samples/01-get-started): progressive tutorial from hello-world to hosting +- [Agent Concepts](./python/samples/02-agents): deep-dive samples by topic (tools, middleware, providers, etc.) +- [Getting Started with Workflows](./python/samples/03-workflows): workflow creation and integration with agents ### .NET diff --git a/agent-samples/README.md b/agent-samples/README.md index ea5c8b0aeb..953affeb08 100644 --- a/agent-samples/README.md +++ b/agent-samples/README.md @@ -1,3 +1,3 @@ # Declarative Agents -This folder contains sample agent definitions that can be run using the declarative agent support, for python see the [declarative agent python sample folder](../python/samples/getting_started/declarative/). +This folder contains sample agent definitions that can be run using the declarative agent support, for python see the [declarative agent python sample folder](../python/samples/02-agents/declarative/). diff --git a/docs/decisions/0012-python-typeddict-options.md b/docs/decisions/0012-python-typeddict-options.md index 23864c2459..5e754dc3dc 100644 --- a/docs/decisions/0012-python-typeddict-options.md +++ b/docs/decisions/0012-python-typeddict-options.md @@ -126,4 +126,4 @@ response = await client.get_response( Chosen option: **"Option 2: TypedDict with Generic Type Parameters"**, because it provides full type safety, excellent IDE support with autocompletion, and allows users to extend provider-specific options for their use cases. Extended this Generic to ChatAgents in order to also properly type the options used in agent construction and run methods. -See [typed_options.py](../../python/samples/concepts/typed_options.py) for a complete example demonstrating the usage of typed options with custom extensions. +See [typed_options.py](../../python/samples/02-agents/typed_options.py) for a complete example demonstrating the usage of typed options with custom extensions. diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 76f1a395eb..df0497e1e8 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -37,18 +37,18 @@ - + - + - - + + @@ -65,9 +65,9 @@ - - - + + + @@ -75,11 +75,11 @@ - + - + @@ -93,7 +93,7 @@ - + diff --git a/dotnet/agent-framework-release.slnf b/dotnet/agent-framework-release.slnf index 98e882ee29..ebd33c0767 100644 --- a/dotnet/agent-framework-release.slnf +++ b/dotnet/agent-framework-release.slnf @@ -25,6 +25,7 @@ "src\\Microsoft.Agents.AI.Purview\\Microsoft.Agents.AI.Purview.csproj", "src\\Microsoft.Agents.AI.Workflows.Declarative.AzureAI\\Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj", "src\\Microsoft.Agents.AI.Workflows.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj", + "src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj", "src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj", "src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj" ] diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 7399e723a7..c87a80361b 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -2,9 +2,9 @@ 1.0.0 - $(VersionPrefix)-$(VersionSuffix).260209.1 - $(VersionPrefix)-preview.260209.1 - 1.0.0-preview.260209.1 + $(VersionPrefix)-$(VersionSuffix).260212.1 + $(VersionPrefix)-preview.260212.1 + 1.0.0-preview.260212.1 Debug;Release;Publish true diff --git a/dotnet/samples/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/A2AClientServer/A2AServer/HostAgentFactory.cs index 8af2b01daf..79c3060d90 100644 --- a/dotnet/samples/A2AClientServer/A2AServer/HostAgentFactory.cs +++ b/dotnet/samples/A2AClientServer/A2AServer/HostAgentFactory.cs @@ -14,7 +14,10 @@ internal static class HostAgentFactory { internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string assistantId, IList? tools = null) { - var persistentAgentsClient = new PersistentAgentsClient(endpoint, new AzureCliCredential()); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + var persistentAgentsClient = new PersistentAgentsClient(endpoint, new DefaultAzureCredential()); PersistentAgent persistentAgent = await persistentAgentsClient.Administration.GetAgentAsync(assistantId); AIAgent agent = await persistentAgentsClient diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs index d14755db3f..cfb07d2850 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs @@ -24,6 +24,9 @@ internal static class ChatClientAgentFactory string endpoint = configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); s_deploymentName = configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. s_azureOpenAIClient = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()); diff --git a/dotnet/samples/AGUIClientServer/AGUIServer/Program.cs b/dotnet/samples/AGUIClientServer/AGUIServer/Program.cs index 418f72ad43..d2c17a5541 100644 --- a/dotnet/samples/AGUIClientServer/AGUIServer/Program.cs +++ b/dotnet/samples/AGUIClientServer/AGUIServer/Program.cs @@ -19,6 +19,9 @@ string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new In string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); // Create the AI agent with tools +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. var agent = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) diff --git a/dotnet/samples/AGUIWebChat/Server/Program.cs b/dotnet/samples/AGUIWebChat/Server/Program.cs index eb5b259016..0b474bb7f4 100644 --- a/dotnet/samples/AGUIWebChat/Server/Program.cs +++ b/dotnet/samples/AGUIWebChat/Server/Program.cs @@ -19,6 +19,9 @@ string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new In string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); // Create the AI agent +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient azureOpenAIClient = new( new Uri(endpoint), new DefaultAzureCredential()); diff --git a/dotnet/samples/Durable/Agents/AzureFunctions/01_SingleAgent/Program.cs b/dotnet/samples/Durable/Agents/AzureFunctions/01_SingleAgent/Program.cs index e629f3ee2c..cc000bd815 100644 --- a/dotnet/samples/Durable/Agents/AzureFunctions/01_SingleAgent/Program.cs +++ b/dotnet/samples/Durable/Agents/AzureFunctions/01_SingleAgent/Program.cs @@ -19,9 +19,12 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYM // Use Azure Key Credential if provided, otherwise use Azure CLI Credential. string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); + : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); // Set up an AI agent following the standard Microsoft Agent Framework pattern. const string JokerName = "Joker"; diff --git a/dotnet/samples/Durable/Agents/AzureFunctions/02_AgentOrchestration_Chaining/Program.cs b/dotnet/samples/Durable/Agents/AzureFunctions/02_AgentOrchestration_Chaining/Program.cs index 7ab6a23477..13d0852915 100644 --- a/dotnet/samples/Durable/Agents/AzureFunctions/02_AgentOrchestration_Chaining/Program.cs +++ b/dotnet/samples/Durable/Agents/AzureFunctions/02_AgentOrchestration_Chaining/Program.cs @@ -19,9 +19,12 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYM // Use Azure Key Credential if provided, otherwise use Azure CLI Credential. string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); + : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); // Single agent used by the orchestration to demonstrate sequential calls on the same session. const string WriterName = "WriterAgent"; diff --git a/dotnet/samples/Durable/Agents/AzureFunctions/03_AgentOrchestration_Concurrency/Program.cs b/dotnet/samples/Durable/Agents/AzureFunctions/03_AgentOrchestration_Concurrency/Program.cs index 621e093c67..5cf8aec044 100644 --- a/dotnet/samples/Durable/Agents/AzureFunctions/03_AgentOrchestration_Concurrency/Program.cs +++ b/dotnet/samples/Durable/Agents/AzureFunctions/03_AgentOrchestration_Concurrency/Program.cs @@ -19,9 +19,12 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYM // Use Azure Key Credential if provided, otherwise use Azure CLI Credential. string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); + : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); // Two agents used by the orchestration to demonstrate concurrent execution. const string PhysicistName = "PhysicistAgent"; diff --git a/dotnet/samples/Durable/Agents/AzureFunctions/04_AgentOrchestration_Conditionals/Program.cs b/dotnet/samples/Durable/Agents/AzureFunctions/04_AgentOrchestration_Conditionals/Program.cs index b6638edf04..803b15f487 100644 --- a/dotnet/samples/Durable/Agents/AzureFunctions/04_AgentOrchestration_Conditionals/Program.cs +++ b/dotnet/samples/Durable/Agents/AzureFunctions/04_AgentOrchestration_Conditionals/Program.cs @@ -19,9 +19,12 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYM // Use Azure Key Credential if provided, otherwise use Azure CLI Credential. string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); + : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); // Two agents used by the orchestration to demonstrate conditional logic. const string SpamDetectionName = "SpamDetectionAgent"; diff --git a/dotnet/samples/Durable/Agents/AzureFunctions/05_AgentOrchestration_HITL/Program.cs b/dotnet/samples/Durable/Agents/AzureFunctions/05_AgentOrchestration_HITL/Program.cs index 284a6af3ba..eecd6de570 100644 --- a/dotnet/samples/Durable/Agents/AzureFunctions/05_AgentOrchestration_HITL/Program.cs +++ b/dotnet/samples/Durable/Agents/AzureFunctions/05_AgentOrchestration_HITL/Program.cs @@ -19,9 +19,12 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYM // Use Azure Key Credential if provided, otherwise use Azure CLI Credential. string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); + : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); // Single agent used by the orchestration to demonstrate human-in-the-loop workflow. const string WriterName = "WriterAgent"; diff --git a/dotnet/samples/Durable/Agents/AzureFunctions/06_LongRunningTools/Program.cs b/dotnet/samples/Durable/Agents/AzureFunctions/06_LongRunningTools/Program.cs index 149e020614..a06d2d8e63 100644 --- a/dotnet/samples/Durable/Agents/AzureFunctions/06_LongRunningTools/Program.cs +++ b/dotnet/samples/Durable/Agents/AzureFunctions/06_LongRunningTools/Program.cs @@ -23,9 +23,12 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYM // Use Azure Key Credential if provided, otherwise use Azure CLI Credential. string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); + : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); // Agent used by the orchestration to write content. const string WriterAgentName = "Writer"; diff --git a/dotnet/samples/Durable/Agents/AzureFunctions/07_AgentAsMcpTool/Program.cs b/dotnet/samples/Durable/Agents/AzureFunctions/07_AgentAsMcpTool/Program.cs index 3625eaa9eb..a0767f8860 100644 --- a/dotnet/samples/Durable/Agents/AzureFunctions/07_AgentAsMcpTool/Program.cs +++ b/dotnet/samples/Durable/Agents/AzureFunctions/07_AgentAsMcpTool/Program.cs @@ -25,9 +25,12 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYM // Use Azure Key Credential if provided, otherwise use Azure CLI Credential. string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); + : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); // Define three AI agents we are going to use in this application. AIAgent agent1 = client.GetChatClient(deploymentName).AsAIAgent("You are good at telling jokes.", "Joker"); diff --git a/dotnet/samples/Durable/Agents/AzureFunctions/08_ReliableStreaming/Program.cs b/dotnet/samples/Durable/Agents/AzureFunctions/08_ReliableStreaming/Program.cs index dd90af2287..3850f967dc 100644 --- a/dotnet/samples/Durable/Agents/AzureFunctions/08_ReliableStreaming/Program.cs +++ b/dotnet/samples/Durable/Agents/AzureFunctions/08_ReliableStreaming/Program.cs @@ -40,9 +40,12 @@ int redisStreamTtlMinutes = int.TryParse( // Use Azure Key Credential if provided, otherwise use Azure CLI Credential. string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); + : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); // Travel Planner agent instructions - designed to produce longer responses for demonstrating streaming. const string TravelPlannerName = "TravelPlanner"; diff --git a/dotnet/samples/Durable/Agents/ConsoleApps/01_SingleAgent/Program.cs b/dotnet/samples/Durable/Agents/ConsoleApps/01_SingleAgent/Program.cs index 188d29ea46..7b54cf7c0a 100644 --- a/dotnet/samples/Durable/Agents/ConsoleApps/01_SingleAgent/Program.cs +++ b/dotnet/samples/Durable/Agents/ConsoleApps/01_SingleAgent/Program.cs @@ -25,9 +25,12 @@ string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SC // Use Azure Key Credential if provided, otherwise use Azure CLI Credential. string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); + : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); // Set up an AI agent following the standard Microsoft Agent Framework pattern. const string JokerName = "Joker"; diff --git a/dotnet/samples/Durable/Agents/ConsoleApps/02_AgentOrchestration_Chaining/Program.cs b/dotnet/samples/Durable/Agents/ConsoleApps/02_AgentOrchestration_Chaining/Program.cs index 91b9d2da67..77af66252c 100644 --- a/dotnet/samples/Durable/Agents/ConsoleApps/02_AgentOrchestration_Chaining/Program.cs +++ b/dotnet/samples/Durable/Agents/ConsoleApps/02_AgentOrchestration_Chaining/Program.cs @@ -29,9 +29,12 @@ string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SC // Use Azure Key Credential if provided, otherwise use Azure CLI Credential. string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); + : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); // Single agent used by the orchestration to demonstrate sequential calls on the same session. const string WriterName = "WriterAgent"; diff --git a/dotnet/samples/Durable/Agents/ConsoleApps/03_AgentOrchestration_Concurrency/Program.cs b/dotnet/samples/Durable/Agents/ConsoleApps/03_AgentOrchestration_Concurrency/Program.cs index 2093cd01f1..fd7e601f94 100644 --- a/dotnet/samples/Durable/Agents/ConsoleApps/03_AgentOrchestration_Concurrency/Program.cs +++ b/dotnet/samples/Durable/Agents/ConsoleApps/03_AgentOrchestration_Concurrency/Program.cs @@ -29,9 +29,12 @@ string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SC // Use Azure Key Credential if provided, otherwise use Azure CLI Credential. string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); + : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); // Two agents used by the orchestration to demonstrate concurrent execution. const string PhysicistName = "PhysicistAgent"; diff --git a/dotnet/samples/Durable/Agents/ConsoleApps/04_AgentOrchestration_Conditionals/Program.cs b/dotnet/samples/Durable/Agents/ConsoleApps/04_AgentOrchestration_Conditionals/Program.cs index e4062779f6..dfef999613 100644 --- a/dotnet/samples/Durable/Agents/ConsoleApps/04_AgentOrchestration_Conditionals/Program.cs +++ b/dotnet/samples/Durable/Agents/ConsoleApps/04_AgentOrchestration_Conditionals/Program.cs @@ -28,9 +28,12 @@ string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SC // Use Azure Key Credential if provided, otherwise use Azure CLI Credential. string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); + : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); // Spam detection agent const string SpamDetectionAgentName = "SpamDetectionAgent"; diff --git a/dotnet/samples/Durable/Agents/ConsoleApps/05_AgentOrchestration_HITL/Program.cs b/dotnet/samples/Durable/Agents/ConsoleApps/05_AgentOrchestration_HITL/Program.cs index c114ee6b48..ec98d55b5a 100644 --- a/dotnet/samples/Durable/Agents/ConsoleApps/05_AgentOrchestration_HITL/Program.cs +++ b/dotnet/samples/Durable/Agents/ConsoleApps/05_AgentOrchestration_HITL/Program.cs @@ -29,9 +29,12 @@ string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SC // Use Azure Key Credential if provided, otherwise use Azure CLI Credential. string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); + : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); // Single agent used by the orchestration to demonstrate human-in-the-loop workflow. const string WriterName = "WriterAgent"; diff --git a/dotnet/samples/Durable/Agents/ConsoleApps/06_LongRunningTools/Program.cs b/dotnet/samples/Durable/Agents/ConsoleApps/06_LongRunningTools/Program.cs index 8a593020c3..6a8fe08b8d 100644 --- a/dotnet/samples/Durable/Agents/ConsoleApps/06_LongRunningTools/Program.cs +++ b/dotnet/samples/Durable/Agents/ConsoleApps/06_LongRunningTools/Program.cs @@ -30,9 +30,12 @@ string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SC // Use Azure Key Credential if provided, otherwise use Azure CLI Credential. string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); + : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); // Agent used by the orchestration to write content. const string WriterAgentName = "Writer"; diff --git a/dotnet/samples/Durable/Agents/ConsoleApps/07_ReliableStreaming/Program.cs b/dotnet/samples/Durable/Agents/ConsoleApps/07_ReliableStreaming/Program.cs index 516ee889d8..9efe28a937 100644 --- a/dotnet/samples/Durable/Agents/ConsoleApps/07_ReliableStreaming/Program.cs +++ b/dotnet/samples/Durable/Agents/ConsoleApps/07_ReliableStreaming/Program.cs @@ -38,9 +38,12 @@ string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SC // Use Azure Key Credential if provided, otherwise use Azure CLI Credential. string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) - : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); + : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); // Travel Planner agent instructions - designed to produce longer responses for demonstrating streaming. const string TravelPlannerName = "TravelPlanner"; diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/Program.cs b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/Program.cs index d1384d2c21..cbb3799274 100644 --- a/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/Program.cs +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/Program.cs @@ -26,9 +26,12 @@ AgentCard agentCard = await agentCardResolver.GetAgentCardAsync(); AIAgent a2aAgent = agentCard.AsAIAgent(); // Create the main agent, and provide the a2a agent skills as a function tools. +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent( instructions: "You are a helpful assistant that helps people with travel planning.", diff --git a/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/Program.cs b/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/Program.cs index fb3cbe401e..936d9430fb 100644 --- a/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/Program.cs +++ b/dotnet/samples/GettingStarted/AGUI/Step01_GettingStarted/Server/Program.cs @@ -19,6 +19,9 @@ string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); // Create the AI agent +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. ChatClient chatClient = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) diff --git a/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/Program.cs b/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/Program.cs index 73ece031fc..5b55829b45 100644 --- a/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/Program.cs +++ b/dotnet/samples/GettingStarted/AGUI/Step02_BackendTools/Server/Program.cs @@ -74,6 +74,9 @@ AITool[] tools = ]; // Create the AI agent with tools +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. ChatClient chatClient = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) diff --git a/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/Program.cs b/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/Program.cs index fb3cbe401e..936d9430fb 100644 --- a/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/Program.cs +++ b/dotnet/samples/GettingStarted/AGUI/Step03_FrontendTools/Server/Program.cs @@ -19,6 +19,9 @@ string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); // Create the AI agent +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. ChatClient chatClient = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) diff --git a/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/Program.cs b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/Program.cs index 023b3327ba..b90f59a1d0 100644 --- a/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/Program.cs +++ b/dotnet/samples/GettingStarted/AGUI/Step04_HumanInLoop/Server/Program.cs @@ -52,6 +52,9 @@ AITool[] tools = [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(Approv #pragma warning restore MEAI001 // Create base agent +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. ChatClient openAIChatClient = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) diff --git a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/Program.cs b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/Program.cs index a6bd6f5ef6..46637e376b 100644 --- a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/Program.cs +++ b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/Program.cs @@ -29,6 +29,9 @@ string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] var jsonOptions = app.Services.GetRequiredService>().Value; // Create base agent +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. ChatClient chatClient = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) diff --git a/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs b/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs index b818de2e44..69d71e7b88 100644 --- a/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs +++ b/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs @@ -110,7 +110,10 @@ static async Task GetWeatherAsync([Description("The location to get the return $"The weather in {location} is cloudy with a high of 15°C."; } -using var instrumentedChatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +using var instrumentedChatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsIChatClient() // Converts a native OpenAI SDK ChatClient into a Microsoft.Extensions.AI.IChatClient .AsBuilder() diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Anthropic/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Anthropic/Program.cs index b281274051..a099d5aad3 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Anthropic/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Anthropic/Program.cs @@ -17,11 +17,14 @@ string? apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY"); const string JokerInstructions = "You are good at telling jokes."; const string JokerName = "JokerAgent"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. using AnthropicClient client = (resource is null) ? new AnthropicClient() { ApiKey = apiKey ?? throw new InvalidOperationException("ANTHROPIC_API_KEY is required when no ANTHROPIC_RESOURCE is provided") } // If no resource is provided, use Anthropic public API : (apiKey is not null) ? new AnthropicFoundryClient(new AnthropicFoundryApiKeyCredentials(apiKey, resource)) // If an apiKey is provided, use Foundry with ApiKey authentication - : new AnthropicFoundryClient(new AnthropicFoundryIdentityTokenCredentials(new AzureCliCredential(), resource, ["https://ai.azure.com/.default"])); // Otherwise, use Foundry with Azure TokenCredential authentication + : new AnthropicFoundryClient(new AnthropicFoundryIdentityTokenCredentials(new DefaultAzureCredential(), resource, ["https://ai.azure.com/.default"])); // Otherwise, use Foundry with Azure TokenCredential authentication AIAgent agent = client.AsAIAgent(model: deploymentName, instructions: JokerInstructions, name: JokerName); diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs index 07b160e880..20da6b3720 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs @@ -13,7 +13,10 @@ const string JokerName = "Joker"; const string JokerInstructions = "You are good at telling jokes."; // Get a client to create/retrieve server side agents with. -var persistentAgentsClient = new PersistentAgentsClient(endpoint, new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +var persistentAgentsClient = new PersistentAgentsClient(endpoint, new DefaultAzureCredential()); // You can create a server side persistent agent with the Azure.AI.Agents.Persistent SDK. var agentMetadata = await persistentAgentsClient.Administration.CreateAgentAsync( diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Program.cs index 4ac6f40022..ced1665951 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureAIProject/Program.cs @@ -13,7 +13,10 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_D const string JokerName = "JokerAgent"; // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -var aiProjectClient = new AIProjectClient(new Uri(endpoint), new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); // Define the agent you want to create. (Prompt Agent in this case) var agentVersionCreationOptions = new AgentVersionCreationOptions(new PromptAgentDefinition(model: deploymentName) { Instructions = "You are good at telling jokes." }); diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryModel/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryModel/Program.cs index d22fc627ff..752b6d0ec1 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryModel/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryModel/Program.cs @@ -19,8 +19,11 @@ var model = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_MODEL_DEPLOYMENT") var clientOptions = new OpenAIClientOptions() { Endpoint = new Uri(endpoint) }; // Create the OpenAI client with either an API key or Azure CLI credential. +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. OpenAIClient client = string.IsNullOrWhiteSpace(apiKey) - ? new OpenAIClient(new BearerTokenPolicy(new AzureCliCredential(), "https://ai.azure.com/.default"), clientOptions) + ? new OpenAIClient(new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), clientOptions) : new OpenAIClient(new ApiKeyCredential(apiKey), clientOptions); AIAgent agent = client diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIChatCompletion/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIChatCompletion/Program.cs index ea647c2d4f..1f83f6fbef 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIChatCompletion/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIChatCompletion/Program.cs @@ -10,9 +10,12 @@ using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIResponses/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIResponses/Program.cs index 31a24b6585..5dfcafbb6d 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIResponses/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIResponses/Program.cs @@ -10,9 +10,12 @@ using OpenAI.Responses; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetResponsesClient(deploymentName) .AsAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs index dd294040c4..1c9c9a3964 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs @@ -31,14 +31,14 @@ namespace SampleApp protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => new(new CustomAgentSession()); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { if (session is not CustomAgentSession typedSession) { throw new ArgumentException($"The provided session is not of type {nameof(CustomAgentSession)}.", nameof(session)); } - return typedSession.Serialize(jsonSerializerOptions); + return new(typedSession.Serialize(jsonSerializerOptions)); } protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs index ec55abf3a4..4e2065e0eb 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/Program.cs @@ -20,7 +20,10 @@ var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_E // Replace this with a vector store implementation of your choice that can persist the chat history long term. VectorStore vectorStore = new InMemoryVectorStore(new InMemoryVectorStoreOptions() { - EmbeddingGenerator = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + EmbeddingGenerator = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetEmbeddingClient(embeddingDeploymentName) .AsIEmbeddingGenerator() }); @@ -28,7 +31,7 @@ VectorStore vectorStore = new InMemoryVectorStore(new InMemoryVectorStoreOptions // Create the agent and add the ChatHistoryMemoryProvider to store chat messages in the vector store. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent(new ChatClientAgentOptions { diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs index 4a0dbe0839..a81c496b5e 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/Program.cs @@ -24,9 +24,12 @@ using HttpClient mem0HttpClient = new(); mem0HttpClient.BaseAddress = new Uri(mem0ServiceUri); mem0HttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", mem0ApiKey); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent(new ChatClientAgentOptions() { @@ -55,7 +58,7 @@ await Task.Delay(TimeSpan.FromSeconds(2)); Console.WriteLine(await agent.RunAsync("What do you already know about my upcoming trip?", session)); Console.WriteLine("\n>> Serialize and deserialize the session to demonstrate persisted state\n"); -JsonElement serializedSession = agent.SerializeSession(session); +JsonElement serializedSession = await agent.SerializeSessionAsync(session); AgentSession restoredSession = await agent.DeserializeSessionAsync(serializedSession); Console.WriteLine(await agent.RunAsync("Can you recap the personal details you remember?", restoredSession)); diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/Program.cs index edd9248ff9..4a736674fc 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/Program.cs @@ -18,9 +18,12 @@ using SampleApp; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. ChatClient chatClient = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName); // Create the agent and provide a factory to add our custom memory component to @@ -47,7 +50,7 @@ Console.WriteLine(await agent.RunAsync("My name is Ruaidhrí", session)); Console.WriteLine(await agent.RunAsync("I am 20 years old", session)); // We can serialize the session. The serialized state will include the state of the memory component. -JsonElement sesionElement = agent.SerializeSession(session); +JsonElement sesionElement = await agent.SerializeSessionAsync(session); Console.WriteLine("\n>> Use deserialized session with previously created memories\n"); 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/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs index ca798aa333..516585f7dc 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs @@ -18,9 +18,12 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? th var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient azureOpenAIClient = new( new Uri(endpoint), - new AzureCliCredential()); + new DefaultAzureCredential()); // Create an In-Memory vector store that uses the Azure OpenAI embedding model to generate embeddings. VectorStore vectorStore = new InMemoryVectorStore(new() diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs index 3648ccc898..4120f2d604 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/Program.cs @@ -19,9 +19,12 @@ var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_E var afOverviewUrl = "https://github.com/MicrosoftDocs/semantic-kernel-docs/blob/main/agent-framework/overview/agent-framework-overview.md"; var afMigrationUrl = "https://raw.githubusercontent.com/MicrosoftDocs/semantic-kernel-docs/refs/heads/main/agent-framework/migration-guide/from-semantic-kernel/index.md"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AzureOpenAIClient azureOpenAIClient = new( new Uri(endpoint), - new AzureCliCredential()); + new DefaultAzureCredential()); // Create a Qdrant vector store that uses the Azure OpenAI embedding model to generate embeddings. QdrantClient client = new("localhost"); diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs index bcc823de46..06da840df4 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/Program.cs @@ -22,9 +22,12 @@ TextSearchProviderOptions textSearchOptions = new() RecentMessageMemoryLimit = 6, }; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent(new ChatClientAgentOptions { diff --git a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs index cfb40f4029..4234be6c5a 100644 --- a/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs @@ -15,9 +15,12 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOIN var deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; // Create an AI Project client and get an OpenAI client that works with the foundry service. +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIProjectClient aiProjectClient = new( new Uri(endpoint), - new AzureCliCredential()); + new DefaultAzureCredential()); OpenAIClient openAIClient = aiProjectClient.GetProjectOpenAIClient(); // Upload the file that contains the data to be used for RAG to the Foundry service. diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step01_Running/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step01_Running/Program.cs index 3ce20975b9..e461f9ba75 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step01_Running/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step01_Running/Program.cs @@ -10,9 +10,12 @@ using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step02_MultiturnConversation/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step02_MultiturnConversation/Program.cs index 6a60de132d..5d49e806ed 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step02_MultiturnConversation/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step02_MultiturnConversation/Program.cs @@ -10,9 +10,12 @@ using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Program.cs index 87cc021fb3..da0b638562 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Program.cs @@ -18,9 +18,12 @@ static string GetWeather([Description("The location to get the weather for.")] s => $"The weather in {location} is cloudy with a high of 15°C."; // Create the chat client and agent, and provide the function tool to the agent. +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent(instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs index 12c4af9d56..5bdfc9421c 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs @@ -23,9 +23,12 @@ static string GetWeather([Description("The location to get the weather for.")] s // Create the chat client and agent. // Note that we are wrapping the function tool with ApprovalRequiredAIFunction to require user approval before invoking it. +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent(instructions: "You are a helpful assistant", tools: [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather))]); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Program.cs index 38762ebfd1..851b3340d5 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Program.cs @@ -15,9 +15,12 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? th var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; // Create chat client to be used by chat client agents. +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. ChatClient chatClient = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName); // Create the ChatClientAgent with the specified name and instructions. diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Program.cs index 8acbff2690..e22c377929 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Program.cs @@ -12,9 +12,12 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? th var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; // Create the agent +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); @@ -25,7 +28,7 @@ AgentSession session = await agent.CreateSessionAsync(); Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", session)); // Serialize the session state to a JsonElement, so it can be stored for later use. -JsonElement serializedSession = agent.SerializeSession(session); +JsonElement serializedSession = await agent.SerializeSessionAsync(session); // Save the serialized session to a temporary file (for demonstration purposes). string tempFilePath = Path.GetTempFileName(); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyChatHistoryStorage/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyChatHistoryStorage/Program.cs index 33176d8fdf..0eaf3d8bc5 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyChatHistoryStorage/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyChatHistoryStorage/Program.cs @@ -25,9 +25,12 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT VectorStore vectorStore = new InMemoryVectorStore(); // Create the agent +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent(new ChatClientAgentOptions { @@ -49,7 +52,7 @@ Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", session // Serialize the session state, so it can be stored for later use. // Since the chat history is stored in the vector store, the serialized session // only contains the guid that the messages are stored under in the vector store. -JsonElement serializedSession = agent.SerializeSession(session); +JsonElement serializedSession = await agent.SerializeSessionAsync(session); Console.WriteLine("\n--- Serialized session ---\n"); Console.WriteLine(JsonSerializer.Serialize(serializedSession, new JsonSerializerOptions { WriteIndented = true })); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Program.cs index 6a969d7512..20a0c252a2 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Program.cs @@ -27,7 +27,10 @@ if (!string.IsNullOrWhiteSpace(applicationInsightsConnectionString)) using var tracerProvider = tracerProviderBuilder.Build(); // Create the agent, and enable OpenTelemetry instrumentation. -AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent(instructions: "You are good at telling jokes.", name: "Joker") .AsBuilder() diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step09_DependencyInjection/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step09_DependencyInjection/Program.cs index 1eb0c16742..218ab1a10e 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step09_DependencyInjection/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step09_DependencyInjection/Program.cs @@ -21,9 +21,12 @@ HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); builder.Services.AddSingleton(new ChatClientAgentOptions() { Name = "Joker", ChatOptions = new() { Instructions = "You are good at telling jokes." } }); // Add a chat client to the service collection. +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. builder.Services.AddKeyedChatClient("AzureOpenAI", (sp) => new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsIChatClient()); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Program.cs index 16bc3cd51e..3ecad341b0 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Program.cs @@ -12,7 +12,10 @@ using ModelContextProtocol.Server; var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -var persistentAgentsClient = new PersistentAgentsClient(endpoint, new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +var persistentAgentsClient = new PersistentAgentsClient(endpoint, new DefaultAzureCredential()); // Create a server side persistent agent var agentMetadata = await persistentAgentsClient.Administration.CreateAgentAsync( diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step11_UsingImages/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step11_UsingImages/Program.cs index 9e5985b8c0..984a9e3b5c 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step11_UsingImages/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step11_UsingImages/Program.cs @@ -11,7 +11,10 @@ using ChatMessage = Microsoft.Extensions.AI.ChatMessage; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +var agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent( name: "VisionAgent", diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step12_AsFunctionTool/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step12_AsFunctionTool/Program.cs index 765174072f..aca1a95ce4 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step12_AsFunctionTool/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step12_AsFunctionTool/Program.cs @@ -17,9 +17,12 @@ static string GetWeather([Description("The location to get the weather for.")] s => $"The weather in {location} is cloudy with a high of 15°C."; // Create the chat client and agent, and provide the function tool to the agent. +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent weatherAgent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent( instructions: "You answer questions about the weather.", @@ -30,7 +33,7 @@ AIAgent weatherAgent = new AzureOpenAIClient( // Create the main agent, and provide the weather agent as a function tool. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent(instructions: "You are a helpful assistant who responds in French.", tools: [weatherAgent.AsAIFunction()]); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Program.cs index 2104ba536b..5d9c70a5fd 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Program.cs @@ -19,9 +19,12 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT var stateStore = new Dictionary(); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetResponsesClient(deploymentName) .AsAIAgent( name: "SpaceNovelWriter", @@ -40,7 +43,7 @@ AgentResponse response = await agent.RunAsync("Write a very long novel about a t // Poll for background responses until complete. while (response.ContinuationToken is not null) { - PersistAgentState(agent, session, response.ContinuationToken); + await PersistAgentState(agent, session, response.ContinuationToken); await Task.Delay(TimeSpan.FromSeconds(10)); @@ -52,9 +55,9 @@ while (response.ContinuationToken is not null) Console.WriteLine(response.Text); -void PersistAgentState(AIAgent agent, AgentSession? session, ResponseContinuationToken? continuationToken) +async Task PersistAgentState(AIAgent agent, AgentSession? session, ResponseContinuationToken? continuationToken) { - stateStore["session"] = agent.SerializeSession(session!); + stateStore["session"] = await agent.SerializeSessionAsync(session!); stateStore["continuationToken"] = JsonSerializer.SerializeToElement(continuationToken, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken))); } diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs index e795b87366..d98689f895 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs @@ -17,7 +17,10 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? th var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; // Get a client to create/retrieve server side agents with -var azureOpenAIClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +var azureOpenAIClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetChatClient(deploymentName); [Description("Get the weather for a given location.")] diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/README.md b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/README.md index bacf33f828..d9433d6230 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/README.md +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/README.md @@ -7,7 +7,7 @@ This sample demonstrates how to add middleware to intercept: ## What This Sample Shows -1. Azure OpenAI integration via `AzureOpenAIClient` and `AzureCliCredential` +1. Azure OpenAI integration via `AzureOpenAIClient` and `DefaultAzureCredential` 2. Chat client middleware using `ChatClientBuilder.Use(...)` 3. Agent run middleware (PII redaction and wording guardrails) 4. Function invocation middleware (logging and overriding a tool result) diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step15_Plugins/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step15_Plugins/Program.cs index 54f977352b..2e9b405183 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step15_Plugins/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step15_Plugins/Program.cs @@ -27,9 +27,12 @@ services.AddSingleton(); // The plugin depends on WeatherProvider a IServiceProvider serviceProvider = services.BuildServiceProvider(); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent( instructions: "You are a helpful assistant that helps people find information.", diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Program.cs index f9a5a1fc01..77abb7898a 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Program.cs @@ -16,9 +16,12 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? th var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; // Construct the agent, and provide a factory to create an in-memory chat message store with a reducer that keeps only the last 2 non-system messages. +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent(new ChatClientAgentOptions { diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/Program.cs index 1fa436b156..62db550556 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/Program.cs @@ -10,9 +10,12 @@ using OpenAI.Responses; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetResponsesClient(deploymentName) .AsAIAgent(); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Program.cs index d7ebc9fca4..c14b9e5b55 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Program.cs @@ -16,7 +16,10 @@ PersistentAgentsAdministrationClientOptions persistentAgentsClientOptions = new( persistentAgentsClientOptions.Retry.NetworkTimeout = TimeSpan.FromMinutes(20); // Get a client to create/retrieve server side agents with. -PersistentAgentsClient persistentAgentsClient = new(endpoint, new AzureCliCredential(), persistentAgentsClientOptions); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +PersistentAgentsClient persistentAgentsClient = new(endpoint, new DefaultAzureCredential(), persistentAgentsClientOptions); // Define and configure the Deep Research tool. DeepResearchToolDefinition deepResearchTool = new(new DeepResearchDetails( diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step19_Declarative/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step19_Declarative/Program.cs index 1fc985b3bb..215833c795 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step19_Declarative/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step19_Declarative/Program.cs @@ -11,9 +11,12 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? th var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; // Create the chat client +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. IChatClient chatClient = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsIChatClient(); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step20_AdditionalAIContext/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step20_AdditionalAIContext/Program.cs index 055172fa82..b04cf836bb 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step20_AdditionalAIContext/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step20_AdditionalAIContext/Program.cs @@ -34,9 +34,12 @@ Func> loadNextThreeCalendarEvents = async () => }; // Create an agent with an AI context provider attached that aggregates two other providers: +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent(new ChatClientAgentOptions() { @@ -65,7 +68,7 @@ Console.WriteLine(await agent.RunAsync("I need to make a dentist appointment for Console.WriteLine(await agent.RunAsync("I've taken Sally to soccer practice.", session) + "\n"); // We can serialize the session, and it will contain both the chat history and the data that each AI context provider serialized. -JsonElement serializedSession = agent.SerializeSession(session); +JsonElement serializedSession = await agent.SerializeSessionAsync(session); // Let's print it to console to show the contents. Console.WriteLine(JsonSerializer.Serialize(serializedSession, options: new JsonSerializerOptions() { WriteIndented = true, IndentSize = 2 }) + "\n"); // The serialized session can be stored long term in a persistent store, but in this case we will just deserialize again and continue the conversation. diff --git a/dotnet/samples/GettingStarted/DeclarativeAgents/ChatClient/Program.cs b/dotnet/samples/GettingStarted/DeclarativeAgents/ChatClient/Program.cs index bed16f496a..270acfb946 100644 --- a/dotnet/samples/GettingStarted/DeclarativeAgents/ChatClient/Program.cs +++ b/dotnet/samples/GettingStarted/DeclarativeAgents/ChatClient/Program.cs @@ -12,9 +12,12 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? th var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; // Create the chat client +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. IChatClient chatClient = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsIChatClient(); diff --git a/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Program.cs b/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Program.cs index 7fded8c55b..d35c1385cc 100644 --- a/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Program.cs +++ b/dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Program.cs @@ -46,7 +46,10 @@ internal static class Program var endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? "gpt-4o-mini"; - var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsIChatClient(); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs index 4f370b410e..72450b1ae4 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs @@ -13,7 +13,10 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJEC const string JokerName = "JokerAgent"; // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); // Define the agent you want to create. (Prompt Agent in this case) AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = "You are good at telling jokes." }); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs index 6da363905e..79ded7c557 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs @@ -14,7 +14,10 @@ const string JokerInstructions = "You are good at telling jokes."; const string JokerName = "JokerAgent"; // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); // Define the agent you want to create. (Prompt Agent in this case) AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructions }); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs index 1e1e7d72a8..d74180db14 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs @@ -14,7 +14,10 @@ const string JokerInstructions = "You are good at telling jokes."; const string JokerName = "JokerAgent"; // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); // Define the agent you want to create. (Prompt Agent in this case) AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructions }); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/Program.cs index 9393b71b9a..43ff40aa04 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/Program.cs @@ -20,7 +20,10 @@ const string AssistantInstructions = "You are a helpful assistant that can get w const string AssistantName = "WeatherAssistant"; // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); // Define the agent with function tools. AITool tool = AIFunctionFactory.Create(GetWeather); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/Program.cs index 1f98e485f9..f7a847aeb4 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/Program.cs @@ -23,7 +23,10 @@ const string AssistantInstructions = "You are a helpful assistant that can get w const string AssistantName = "WeatherAssistant"; // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); ApprovalRequiredAIFunction approvalTool = new(AIFunctionFactory.Create(GetWeather, name: nameof(GetWeather))); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs index d252b82b1e..fc26d09ea7 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs @@ -19,7 +19,10 @@ const string AssistantInstructions = "You are a helpful assistant that extracts const string AssistantName = "StructuredOutputAssistant"; // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); // Create ChatClientAgent directly ChatClientAgent agent = await aiProjectClient.CreateAIAgentAsync( diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/Program.cs index 7e839bce95..2d163b0fb1 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/Program.cs @@ -14,7 +14,10 @@ const string JokerInstructions = "You are good at telling jokes."; const string JokerName = "JokerAgent"; // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: JokerInstructions); @@ -25,7 +28,7 @@ AgentSession session = await agent.CreateSessionAsync(); Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", session)); // Serialize the session state to a JsonElement, so it can be stored for later use. -JsonElement serializedSession = agent.SerializeSession(session); +JsonElement serializedSession = await agent.SerializeSessionAsync(session); // Save the serialized session to a temporary file (for demonstration purposes). string tempFilePath = Path.GetTempFileName(); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/Program.cs index ac0dc23012..35bac61639 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step07_Observability/Program.cs @@ -29,7 +29,10 @@ if (!string.IsNullOrWhiteSpace(applicationInsightsConnectionString)) using var tracerProvider = tracerProviderBuilder.Build(); // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); // Define the agent you want to create. (Prompt Agent in this case) AIAgent agent = (await aiProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: JokerInstructions)) diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/Program.cs index 4c53d4ab9c..16708c4e99 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step08_DependencyInjection/Program.cs @@ -15,7 +15,10 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJEC const string JokerInstructions = "You are good at telling jokes."; const string JokerName = "JokerAgent"; -AIProjectClient aIProjectClient = new(new Uri(endpoint), new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aIProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); // Create a new agent if one doesn't exist already. ChatClientAgent agent; diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/Program.cs index cfa4b39534..e33f754912 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/Program.cs @@ -25,7 +25,10 @@ await using var mcpClient = await McpClient.CreateAsync(new StdioClientTransport IList mcpTools = await mcpClient.ListToolsAsync(); string agentName = "AgentWithMCP"; // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); Console.WriteLine($"Creating the agent '{agentName}' ..."); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs index 8f38378626..732ca25dde 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs @@ -14,14 +14,17 @@ const string VisionInstructions = "You are a helpful agent that can analyze imag const string VisionName = "VisionAgent"; // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); // Define the agent you want to create. (Prompt Agent in this case) AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: VisionName, model: deploymentName, instructions: VisionInstructions); 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/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/Program.cs index ccf0a0f012..7bbe478b4c 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/Program.cs @@ -21,7 +21,10 @@ static string GetWeather([Description("The location to get the weather for.")] s => $"The weather in {location} is cloudy with a high of 15°C."; // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); // Create the weather agent with function tools. AITool weatherTool = AIFunctionFactory.Create(GetWeather); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/Program.cs index 71f70f04b8..c91cc6a6f1 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/Program.cs @@ -20,7 +20,10 @@ const string AssistantInstructions = "You are an AI assistant that helps people const string AssistantName = "InformationAssistant"; // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); [Description("Get the weather for a given location.")] static string GetWeather([Description("The location to get the weather for.")] string location) diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/README.md index 04192a2cc6..eaaafe7ab1 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step12_Middleware/README.md @@ -4,7 +4,7 @@ This sample demonstrates how to add middleware to intercept agent runs and funct ## What This Sample Shows -1. Azure Foundry Agents integration via `AIProjectClient` and `AzureCliCredential` +1. Azure Foundry Agents integration via `AIProjectClient` and `DefaultAzureCredential` 2. Agent run middleware (logging and monitoring) 3. Function invocation middleware (logging and overriding tool results) 4. Per-request agent run middleware diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/Program.cs index b761c120db..1f18dc21db 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/Program.cs @@ -30,7 +30,10 @@ services.AddSingleton(); // The plugin depends on WeatherProvider a IServiceProvider serviceProvider = services.BuildServiceProvider(); // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); // Define the agent with plugin tools // Define the agent you want to create. (Prompt Agent in this case) diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/Program.cs index 858c678528..77bfa6f8d7 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/Program.cs @@ -19,7 +19,10 @@ const string AgentNameMEAI = "CoderAgent-MEAI"; const string AgentNameNative = "CoderAgent-NATIVE"; // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); // Option 1 - Using HostedCodeInterpreterTool + AgentOptions (MEAI + AgentFramework) // Create the server side agent version diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/Program.cs index e3294be059..8bdda9bdcd 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/Program.cs @@ -18,8 +18,11 @@ internal sealed class Program string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "computer-use-preview"; + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. - AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); const string AgentInstructions = @" You are a computer automation assistant. diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/FoundryAgents_Step27_LocalMCP.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/FoundryAgents_Step27_LocalMCP.csproj new file mode 100644 index 0000000000..1e3e6f57e3 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/FoundryAgents_Step27_LocalMCP.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + + enable + enable + $(NoWarn);CA1812 + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/Program.cs new file mode 100644 index 0000000000..a9c4d4b7ed --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/Program.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use a local MCP (Model Context Protocol) client with Azure Foundry Agents. +// The MCP tools are resolved locally by connecting directly to the MCP server via HTTP, +// and then passed to the Foundry agent as client-side tools. +// This sample uses the Microsoft Learn MCP endpoint to search documentation. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +const string AgentInstructions = "You are a helpful assistant that can help with Microsoft documentation questions. Use the Microsoft Learn MCP tool to search for documentation."; +const string AgentName = "DocsAgent"; + +// Connect to the MCP server locally via HTTP (Streamable HTTP transport). +// The MCP server is hosted at Microsoft Learn and provides documentation search capabilities. +Console.WriteLine("Connecting to MCP server at https://learn.microsoft.com/api/mcp ..."); + +await using McpClient mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new() +{ + Endpoint = new Uri("https://learn.microsoft.com/api/mcp"), + Name = "Microsoft Learn MCP", +})); + +// Retrieve the list of tools available on the MCP server (resolved locally). +IList mcpTools = await mcpClient.ListToolsAsync(); +Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}"); + +// Wrap each MCP tool with a DelegatingAIFunction to log local invocations. +List wrappedTools = mcpTools.Select(tool => (AITool)new LoggingMcpTool(tool)).ToList(); + +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +// Create the agent with the locally-resolved MCP tools. +AIAgent agent = await aiProjectClient.CreateAIAgentAsync( + model: deploymentName, + name: AgentName, + instructions: AgentInstructions, + tools: wrappedTools); + +Console.WriteLine($"Agent '{agent.Name}' created successfully."); + +try +{ + // First query + const string Prompt1 = "How does one create an Azure storage account using az cli?"; + Console.WriteLine($"\nUser: {Prompt1}\n"); + AgentResponse response1 = await agent.RunAsync(Prompt1); + Console.WriteLine($"Agent: {response1}"); + + Console.WriteLine("\n=======================================\n"); + + // Second query + const string Prompt2 = "What is Microsoft Agent Framework?"; + Console.WriteLine($"User: {Prompt2}\n"); + AgentResponse response2 = await agent.RunAsync(Prompt2); + Console.WriteLine($"Agent: {response2}"); +} +finally +{ + // Cleanup by removing the agent when done + await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); + Console.WriteLine($"\nAgent '{agent.Name}' deleted."); +} + +/// +/// Wraps an MCP tool to log when it is invoked locally, +/// confirming that the MCP call is happening client-side. +/// +internal sealed class LoggingMcpTool(AIFunction innerFunction) : DelegatingAIFunction(innerFunction) +{ + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + Console.WriteLine($" >> [LOCAL MCP] Invoking tool '{this.Name}' locally..."); + return base.InvokeCoreAsync(arguments, cancellationToken); + } +} diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/README.md new file mode 100644 index 0000000000..e8883b7e95 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/README.md @@ -0,0 +1,48 @@ +# Using Local MCP Client with Azure Foundry Agents + +This sample demonstrates how to use a local MCP (Model Context Protocol) client with Azure Foundry Agents. Unlike the hosted MCP approach where Azure Foundry invokes the MCP server on the service side, this sample connects to the MCP server directly from the client via HTTP (Streamable HTTP transport) and passes the resolved tools to the agent. + +## What this sample demonstrates + +- Connecting to an MCP server locally using `HttpClientTransport` +- Discovering available tools from the MCP server client-side +- Passing locally-resolved MCP tools to a Foundry agent +- Using the Microsoft Learn MCP endpoint for documentation search +- Managing agent lifecycle (creation and deletion) + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10 SDK or later +- Azure Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (for Azure credential authentication) + +**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). + +Set the following environment variables: + +```powershell +$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step27_LocalMCP +``` + +## Expected behavior + +The sample will: + +1. Connect to the Microsoft Learn MCP server via HTTP and list available tools +2. Create an agent with the locally-resolved MCP tools +3. Ask two questions about Microsoft documentation +4. The agent will use the MCP tools (invoked locally) to search Microsoft Learn documentation +5. Display the agent's responses with information from the documentation +6. Clean up resources by deleting the agent diff --git a/dotnet/samples/GettingStarted/FoundryAgents/README.md b/dotnet/samples/GettingStarted/FoundryAgents/README.md index ba5af8de5a..d7bfe4d035 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/README.md @@ -58,6 +58,7 @@ Before you begin, ensure you have the following prerequisites: |[Using plugins](./FoundryAgents_Step13_Plugins/)|This sample demonstrates how to use plugins with a Foundry agent| |[Code interpreter](./FoundryAgents_Step14_CodeInterpreter/)|This sample demonstrates how to use the code interpreter tool with a Foundry agent| |[Computer use](./FoundryAgents_Step15_ComputerUse/)|This sample demonstrates how to use computer use capabilities with a Foundry agent| +|[Local MCP](./FoundryAgents_Step27_LocalMCP/)|This sample demonstrates how to use a local MCP client with a Foundry agent| ## Running the samples from the console diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server/Program.cs b/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server/Program.cs index 568830bb04..d773332fdd 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server/Program.cs +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server/Program.cs @@ -23,9 +23,12 @@ await using var mcpClient = await McpClient.CreateAsync(new StdioClientTransport // Retrieve the list of tools available on the GitHub server var mcpTools = await mcpClient.ListToolsAsync().ConfigureAwait(false); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent(instructions: "You answer questions related to GitHub repositories only.", tools: [.. mcpTools.Cast()]); diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/Program.cs b/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/Program.cs index 1a08945680..564ede4477 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/Program.cs +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/Program.cs @@ -46,9 +46,12 @@ await using var mcpClient = await McpClient.CreateAsync(transport, loggerFactory // Retrieve the list of tools available on the GitHub server var mcpTools = await mcpClient.ListToolsAsync().ConfigureAwait(false); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent(instructions: "You answer questions related to the weather.", tools: [.. mcpTools]); diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs b/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs index 27677073c6..f6397ce182 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs @@ -13,7 +13,10 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOIN var model = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4.1-mini"; // Get a client to create/retrieve server side agents with. -var persistentAgentsClient = new PersistentAgentsClient(endpoint, new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +var persistentAgentsClient = new PersistentAgentsClient(endpoint, new DefaultAzureCredential()); // **** MCP Tool with Auto Approval **** // ************************************* diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/Program.cs b/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/Program.cs index 79f7ff1302..194952e68a 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/Program.cs +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/Program.cs @@ -27,9 +27,12 @@ var mcpTool = new HostedMcpServerTool( }; // Create an agent based on Azure OpenAI Responses as the backend. +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetResponsesClient(deploymentName) .AsAIAgent( instructions: "You answer questions by searching the Microsoft Learn content only.", @@ -56,7 +59,7 @@ var mcpToolWithApproval = new HostedMcpServerTool( // Create an agent based on Azure OpenAI Responses as the backend. AIAgent agentWithRequiredApproval = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetResponsesClient(deploymentName) .AsAIAgent( instructions: "You answer questions by searching the Microsoft Learn content only.", diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs b/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs index 02fbdf3ecc..1017111082 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs @@ -34,7 +34,10 @@ public static class Program // Set up the Azure OpenAI client var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create the executors var sloganWriter = new SloganWriterExecutor("SloganWriter", chatClient); diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/Program.cs index 35809685ea..48a41b73d6 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/Program.cs @@ -24,7 +24,10 @@ public static class Program var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - var persistentAgentsClient = new PersistentAgentsClient(endpoint, new AzureCliCredential()); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + var persistentAgentsClient = new PersistentAgentsClient(endpoint, new DefaultAzureCredential()); // Create agents AIAgent frenchAgent = await GetTranslationAgentAsync("French", persistentAgentsClient, deploymentName); diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/GroupChatToolApproval/Program.cs b/dotnet/samples/GettingStarted/Workflows/Agents/GroupChatToolApproval/Program.cs index 267c6d7ce5..0508ad80a8 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/GroupChatToolApproval/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/GroupChatToolApproval/Program.cs @@ -45,8 +45,11 @@ public static class Program var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // 1. Create AI client - IChatClient client = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) + IChatClient client = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsIChatClient(); diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs index 48436b7b8f..ff402e2e66 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs @@ -32,7 +32,10 @@ public static class Program // Set up the Azure OpenAI client var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create the workflow and turn it into an agent var workflow = WorkflowFactory.BuildWorkflow(chatClient); diff --git a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs index e5373554c3..bed7d3fe6d 100644 --- a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs @@ -34,7 +34,10 @@ public static class Program // Set up the Azure OpenAI client var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create the executors ChatClientAgent physicist = new( diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs index 0f762ea40d..a04f081ef2 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs @@ -37,7 +37,10 @@ public static class Program // Set up the Azure OpenAI client var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create agents AIAgent spamDetectionAgent = GetSpamDetectionAgent(chatClient); diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/Program.cs b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/Program.cs index ccda3fa19e..4919bcea09 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/Program.cs @@ -38,7 +38,10 @@ public static class Program // Set up the Azure OpenAI client var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create agents AIAgent spamDetectionAgent = GetSpamDetectionAgent(chatClient); diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/Program.cs b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/Program.cs index 49faff39da..32a727f8b7 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/Program.cs @@ -40,7 +40,10 @@ public static class Program // Set up the Azure OpenAI client var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create agents AIAgent emailAnalysisAgent = GetEmailAnalysisAgent(chatClient); diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/CustomerSupport/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/CustomerSupport/Program.cs index f18b8b4658..0547c37b72 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/CustomerSupport/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/CustomerSupport/Program.cs @@ -61,7 +61,10 @@ internal sealed class Program private static async Task CreateAgentsAsync(Uri foundryEndpoint, IConfiguration configuration, TicketingPlugin plugin) { - AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential()); await aiProjectClient.CreateAgentAsync( agentName: "SelfServiceAgent", diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/Program.cs index 7aaa61b398..ae410a0371 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/Program.cs @@ -47,7 +47,10 @@ internal sealed class Program private static async Task CreateAgentsAsync(Uri foundryEndpoint, IConfiguration configuration) { - AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential()); await aiProjectClient.CreateAgentAsync( agentName: "ResearchAgent", diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Generated.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Generated.cs index 49a6ced2b7..59383b9cfa 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Generated.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Generated.cs @@ -49,7 +49,7 @@ public static class SampleWorkflowProvider /// /// Invokes an agent to process messages and return a response within a conversation context. /// - internal sealed class QuestionStudentExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExecutor(id: "question_student", session, agentProvider) + internal sealed class QuestionStudentExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : AgentExecutor(id: "question_student", session, agentProvider) { // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) @@ -86,7 +86,7 @@ public static class SampleWorkflowProvider /// /// Invokes an agent to process messages and return a response within a conversation context. /// - internal sealed class QuestionTeacherExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExecutor(id: "question_teacher", session, agentProvider) + internal sealed class QuestionTeacherExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : AgentExecutor(id: "question_teacher", session, agentProvider) { // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Program.cs index bfc738c336..0566a5ff55 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Program.cs @@ -47,9 +47,12 @@ internal sealed class Program private Workflow CreateWorkflow() { + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. DeclarativeWorkflowOptions options = - new(new AzureAgentProvider(new Uri(this.FoundryEndpoint), new AzureCliCredential())) + new(new AzureAgentProvider(new Uri(this.FoundryEndpoint), new DefaultAzureCredential())) { Configuration = this.Configuration }; diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs index d1f0980ec7..fdbf9d1026 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs @@ -67,12 +67,15 @@ internal sealed class Program /// /// Create the workflow from the declarative YAML. Includes definition of the - /// and the associated . + /// and the associated . /// private Workflow CreateWorkflow() { + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // Create the agent provider that will service agent requests within the workflow. - AzureAgentProvider agentProvider = new(new Uri(this.FoundryEndpoint), new AzureCliCredential()) + AzureAgentProvider agentProvider = new(new Uri(this.FoundryEndpoint), new DefaultAzureCredential()) { // Functions included here will be auto-executed by the framework. Functions = this.Functions diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/Program.cs index bc092a7600..a5000635e4 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/Program.cs @@ -56,7 +56,10 @@ internal sealed class Program private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration, AIFunction[] functions) { - AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential()); await aiProjectClient.CreateAgentAsync( agentName: "MenuAgent", diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/HostedWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/HostedWorkflow/Program.cs index b6011a1a71..2cf24a137b 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/HostedWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/HostedWorkflow/Program.cs @@ -34,8 +34,11 @@ internal sealed class Program IConfiguration configuration = Application.InitializeConfig(); Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint)); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // Create the agent service client - AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential()); // Ensure sample agents exist in Foundry. await CreateAgentsAsync(aiProjectClient, configuration); diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/Program.cs index 9aab54b4cf..523865cc62 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/Program.cs @@ -47,7 +47,10 @@ internal sealed class Program private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration) { - AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential()); await aiProjectClient.CreateAgentAsync( agentName: "LocationTriageAgent", diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Program.cs index 229658310d..43ff497930 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Program.cs @@ -46,7 +46,10 @@ internal sealed class Program private static async Task CreateAgentsAsync(Uri foundryEndpoint, IConfiguration configuration) { - AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential()); await aiProjectClient.CreateAgentAsync( agentName: "AnalystAgent", diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs index 7422e29f63..80afd61911 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs @@ -46,7 +46,10 @@ internal sealed class Program private static async Task CreateAgentsAsync(Uri foundryEndpoint, IConfiguration configuration) { - AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential()); await aiProjectClient.CreateAgentAsync( agentName: "StudentAgent", diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/Program.cs index 3ccfc46d88..691c3d2243 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/Program.cs @@ -47,7 +47,10 @@ internal sealed class Program private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration) { - AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential()); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential()); await aiProjectClient.CreateAgentAsync( agentName: "DocumentSearchAgent", diff --git a/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/Program.cs index 7d7d4d69fd..c61e690adb 100644 --- a/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/Program.cs @@ -73,7 +73,10 @@ public static class Program // Set up the Azure OpenAI client var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsIChatClient() .AsBuilder() diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/Program.cs index 4e61b5def6..126401250c 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/Program.cs @@ -30,7 +30,10 @@ public static class Program // Set up the Azure OpenAI client var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create agents AIAgent frenchAgent = GetTranslationAgent("French", chatClient); diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs index 225f11b59a..0df9d34913 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs @@ -25,7 +25,10 @@ public static class Program // Set up the Azure OpenAI client. var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - var client = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + var client = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()).GetChatClient(deploymentName).AsIChatClient(); Console.Write("Choose workflow type ('sequential', 'concurrent', 'handoffs', 'groupchat'): "); switch (Console.ReadLine()) diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/Program.cs index 096471811c..5269b5b974 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/Program.cs @@ -43,7 +43,10 @@ public static class Program // Set up the Azure OpenAI client var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create executors for text processing UserInputExecutor userInput = new(); diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs index 5654b23baf..38bb80dddc 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs @@ -50,7 +50,10 @@ public static class Program // Set up the Azure OpenAI client string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create executors for content creation and review WriterExecutor writer = new(chatClient); diff --git a/dotnet/samples/HostedAgents/AgentWithHostedMCP/Program.cs b/dotnet/samples/HostedAgents/AgentWithHostedMCP/Program.cs index 4dffdf92bd..827b161052 100644 --- a/dotnet/samples/HostedAgents/AgentWithHostedMCP/Program.cs +++ b/dotnet/samples/HostedAgents/AgentWithHostedMCP/Program.cs @@ -22,6 +22,9 @@ AITool mcpTool = new HostedMcpServerTool(serverName: "microsoft_learn", serverAd }; // Create an agent with the MCP tool using Azure OpenAI Responses. +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) diff --git a/dotnet/samples/HostedAgents/AgentWithTextSearchRag/Program.cs b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/Program.cs index b197ffeefc..ae94a52f67 100644 --- a/dotnet/samples/HostedAgents/AgentWithTextSearchRag/Program.cs +++ b/dotnet/samples/HostedAgents/AgentWithTextSearchRag/Program.cs @@ -21,6 +21,9 @@ TextSearchProviderOptions textSearchOptions = new() RecentMessageMemoryLimit = 6, }; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) diff --git a/dotnet/samples/HostedAgents/AgentsInWorkflows/Program.cs b/dotnet/samples/HostedAgents/AgentsInWorkflows/Program.cs index b1d8a922fd..bd37a8311f 100644 --- a/dotnet/samples/HostedAgents/AgentsInWorkflows/Program.cs +++ b/dotnet/samples/HostedAgents/AgentsInWorkflows/Program.cs @@ -15,6 +15,9 @@ using Microsoft.Extensions.AI; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsIChatClient(); diff --git a/dotnet/samples/M365Agent/AFAgentApplication.cs b/dotnet/samples/M365Agent/AFAgentApplication.cs index 6ebfa81897..7e58819a65 100644 --- a/dotnet/samples/M365Agent/AFAgentApplication.cs +++ b/dotnet/samples/M365Agent/AFAgentApplication.cs @@ -80,7 +80,7 @@ internal sealed class AFAgentApplication : AgentApplication } // Serialize and save the updated conversation history back to turn state. - JsonElement sessionElementEnd = this._agent.SerializeSession(agentSession, JsonUtilities.DefaultOptions); + JsonElement sessionElementEnd = await this._agent.SerializeSessionAsync(agentSession, JsonUtilities.DefaultOptions, cancellationToken); turnState.SetValue("conversation.chatHistory", sessionElementEnd); // End the streaming response diff --git a/dotnet/samples/M365Agent/Program.cs b/dotnet/samples/M365Agent/Program.cs index 834ac2180a..6e4bc0c0b4 100644 --- a/dotnet/samples/M365Agent/Program.cs +++ b/dotnet/samples/M365Agent/Program.cs @@ -36,9 +36,12 @@ if (builder.Configuration.GetSection("AIServices").GetValue("UseAzureOpenA var deploymentName = builder.Configuration.GetSection("AIServices:AzureOpenAI").GetValue("DeploymentName")!; var endpoint = builder.Configuration.GetSection("AIServices:AzureOpenAI").GetValue("Endpoint")!; + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. chatClient = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsIChatClient(); } diff --git a/dotnet/samples/M365Agent/README.md b/dotnet/samples/M365Agent/README.md index e61fa438f5..08e1c3d6c2 100644 --- a/dotnet/samples/M365Agent/README.md +++ b/dotnet/samples/M365Agent/README.md @@ -21,7 +21,7 @@ This Agent Sample is intended to introduce you the basics of integrating Agent F "AzureOpenAI": { "DeploymentName": "", // This is the Deployment (as opposed to model) Name of the Azure OpenAI model "Endpoint": "", // This is the Endpoint of the Azure OpenAI resource - "ApiKey": "" // This is the API Key of the Azure OpenAI resource. Optional, uses AzureCliCredential if not provided + "ApiKey": "" // This is the API Key of the Azure OpenAI resource. Optional, uses DefaultAzureCredential if not provided }, "OpenAI": { "ModelId": "", // This is the Model ID of the OpenAI model diff --git a/dotnet/samples/Purview/AgentWithPurview/Program.cs b/dotnet/samples/Purview/AgentWithPurview/Program.cs index a4b27c47cd..fc0974c5bd 100644 --- a/dotnet/samples/Purview/AgentWithPurview/Program.cs +++ b/dotnet/samples/Purview/AgentWithPurview/Program.cs @@ -24,9 +24,12 @@ TokenCredential browserCredential = new InteractiveBrowserCredential( ClientId = purviewClientAppId }); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. using IChatClient client = new AzureOpenAIClient( new Uri(endpoint), - new AzureCliCredential()) + new DefaultAzureCredential()) .GetResponsesClient(deploymentName) .AsIChatClient() .AsBuilder() diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index 533b50c8fe..aea99b4e3d 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -66,7 +66,7 @@ public sealed class A2AAgent : AIAgent => new(new A2AAgentSession() { ContextId = contextId }); /// - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { _ = Throw.IfNull(session); @@ -75,7 +75,7 @@ public sealed class A2AAgent : AIAgent throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized."); } - return typedSession.Serialize(jsonSerializerOptions); + return new(typedSession.Serialize(jsonSerializerOptions)); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs index 881b398658..6258937cd2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs @@ -157,7 +157,8 @@ public abstract class AIAgent /// /// The to serialize. /// Optional settings to customize the serialization process. - /// A containing the serialized session state. + /// The to monitor for cancellation requests. The default is . + /// A value task that represents the asynchronous operation. The task result contains a with the serialized session state. /// is . /// The type of is not supported by this agent. /// @@ -165,19 +166,20 @@ public abstract class AIAgent /// allowing conversations to resume across application restarts or be migrated between /// different agent instances. Use to restore the session. /// - public JsonElement SerializeSession(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) - => this.SerializeSessionCore(session, jsonSerializerOptions); + public ValueTask SerializeSessionAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => this.SerializeSessionCoreAsync(session, jsonSerializerOptions, cancellationToken); /// /// Core implementation of session serialization logic. /// /// The to serialize. /// Optional settings to customize the serialization process. - /// A containing the serialized session state. + /// The to monitor for cancellation requests. The default is . + /// A value task that represents the asynchronous operation. The task result contains a with the serialized session state. /// /// This is the primary session serialization method that implementations must override. /// - protected abstract JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null); + protected abstract ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default); /// /// Deserializes an agent session from its JSON serialized representation. diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs index a4b606e6a1..76ff8752b9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs @@ -32,23 +32,23 @@ namespace Microsoft.Agents.AI; /// public abstract class AIContextProvider { - private readonly string _sourceName; + private readonly string _sourceId; /// /// Initializes a new instance of the class. /// protected AIContextProvider() { - this._sourceName = this.GetType().FullName!; + this._sourceId = this.GetType().FullName!; } /// - /// Initializes a new instance of the class with the specified source name. + /// Initializes a new instance of the class with the specified source id. /// - /// The source name to stamp on for each messages produced by the . - protected AIContextProvider(string sourceName) + /// The source id to stamp on for each messages produced by the . + protected AIContextProvider(string sourceId) { - this._sourceName = sourceName; + this._sourceId = sourceId; } /// @@ -76,27 +76,9 @@ public abstract class AIContextProvider return aiContext; } - aiContext.Messages = aiContext.Messages.Select(message => - { - if (message.AdditionalProperties != null - // Check if the message was already tagged with this provider's source type - && message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out var messageSourceType) - && messageSourceType is AgentRequestMessageSourceType typedMessageSourceType - && typedMessageSourceType == AgentRequestMessageSourceType.AIContextProvider - // Check if the message was already tagged with this provider's source - && message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out var messageSource) - && messageSource is string typedMessageSource - && typedMessageSource == this._sourceName) - { - return message; - } - - message = message.Clone(); - message.AdditionalProperties ??= new(); - message.AdditionalProperties[AgentRequestMessageSourceType.AdditionalPropertiesKey] = AgentRequestMessageSourceType.AIContextProvider; - message.AdditionalProperties[AgentRequestMessageSource.AdditionalPropertiesKey] = this._sourceName; - return message; - }).ToList(); + aiContext.Messages = aiContext.Messages + .Select(message => message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.AIContextProvider, this._sourceId)) + .ToList(); return aiContext; } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSource.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSource.cs deleted file mode 100644 index 127f1c1b8d..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSource.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI; - -/// -/// Provides a constant for the key used to store the source of the agent request message. -/// -public static class AgentRequestMessageSource -{ - /// - /// Provides the key used in to store the source of the agent request message. - /// - public static readonly string AdditionalPropertiesKey = "Agent.RequestMessageSource"; -} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSourceAttribution.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSourceAttribution.cs new file mode 100644 index 0000000000..2c606814ce --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSourceAttribution.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI; + +/// +/// Represents attribution information for the source of an agent request message for a specific run, including the component type and +/// identifier. +/// +/// +/// Use this struct to identify which component provided a message during an agent run. +/// This is useful to allow filtering of messages based on their source, such as distinguishing between user input, middleware-generated messages, and chat history. +/// +public readonly struct AgentRequestMessageSourceAttribution : IEquatable +{ + /// + /// Provides the key used in to store the + /// associated with the agent request message. + /// + public static readonly string AdditionalPropertiesKey = "_attribution"; + + /// + /// Initializes a new instance of the struct with the specified source type and identifier. + /// + /// The of the component that provided the message. + /// The unique identifier of the component that provided the message. + public AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType sourceType, string? sourceId) + { + this.SourceType = sourceType; + this.SourceId = sourceId; + } + + /// + /// Gets the type of component that provided the message for the current agent run. + /// + public AgentRequestMessageSourceType SourceType { get; } + + /// + /// Gets the unique identifier of the component that provided the message for the current agent run. + /// + public string? SourceId { get; } + + /// + /// Determines whether the specified is equal to the current instance. + /// + /// The to compare with the current instance. + /// if the specified instance is equal to the current instance; otherwise, . + public bool Equals(AgentRequestMessageSourceAttribution other) + { + return this.SourceType == other.SourceType && + string.Equals(this.SourceId, other.SourceId, StringComparison.Ordinal); + } + + /// + /// Determines whether the specified object is equal to the current instance. + /// + /// The object to compare with the current instance. + /// if the specified object is equal to the current instance; otherwise, . + public override bool Equals(object? obj) + { + return obj is AgentRequestMessageSourceAttribution other && this.Equals(other); + } + + /// + /// Returns a hash code for the current instance. + /// + /// A hash code for the current instance. + public override int GetHashCode() + { + unchecked + { + int hash = 17; + hash = (hash * 31) + this.SourceType.GetHashCode(); + hash = (hash * 31) + (this.SourceId?.GetHashCode() ?? 0); + return hash; + } + } + + /// + /// Determines whether two instances are equal. + /// + /// The first instance to compare. + /// The second instance to compare. + /// if the instances are equal; otherwise, . + public static bool operator ==(AgentRequestMessageSourceAttribution left, AgentRequestMessageSourceAttribution right) + { + return left.Equals(right); + } + + /// + /// Determines whether two instances are not equal. + /// + /// The first instance to compare. + /// The second instance to compare. + /// if the instances are not equal; otherwise, . + public static bool operator !=(AgentRequestMessageSourceAttribution left, AgentRequestMessageSourceAttribution right) + { + return !left.Equals(right); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSourceType.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSourceType.cs index 1cca747906..14bbbe3388 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSourceType.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSourceType.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -14,15 +13,10 @@ namespace Microsoft.Agents.AI; /// This type helps to identify whether a message came from outside the agent pipeline, /// whether it was produced by middleware, or came from chat history. /// -public sealed class AgentRequestMessageSourceType : IEquatable +public readonly struct AgentRequestMessageSourceType : IEquatable { /// - /// Provides the key used in to store the source type of the agent request message. - /// - public static readonly string AdditionalPropertiesKey = "Agent.RequestMessageSourceType"; - - /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the struct. /// /// The string value representing the source of the agent request message. public AgentRequestMessageSourceType(string value) => this.Value = Throw.IfNullOrWhitespace(value); @@ -30,7 +24,7 @@ public sealed class AgentRequestMessageSourceType : IEquatable /// Get the string value representing the source of the agent request message. /// - public string Value { get; } + public string Value { get { return field ?? External.Value; } } /// /// The message came from outside the agent pipeline (e.g., user input). @@ -52,18 +46,8 @@ public sealed class AgentRequestMessageSourceType : IEquatable /// The to compare to this instance. /// if the value of the parameter is the same as the value of this instance; otherwise, . - public bool Equals(AgentRequestMessageSourceType? other) + public bool Equals(AgentRequestMessageSourceType other) { - if (other is null) - { - return false; - } - - if (ReferenceEquals(this, other)) - { - return true; - } - return string.Equals(this.Value, other.Value, StringComparison.Ordinal); } @@ -72,7 +56,7 @@ public sealed class AgentRequestMessageSourceType : IEquatable /// The object to compare to this instance. /// if is a and its value is the same as this instance; otherwise, . - public override bool Equals(object? obj) => this.Equals(obj as AgentRequestMessageSourceType); + public override bool Equals(object? obj) => obj is AgentRequestMessageSourceType other && this.Equals(other); /// /// Returns the hash code for this instance. @@ -86,13 +70,8 @@ public sealed class AgentRequestMessageSourceType : IEquatableThe first to compare. /// The second to compare. /// if the value of is the same as the value of ; otherwise, . - public static bool operator ==(AgentRequestMessageSourceType? left, AgentRequestMessageSourceType? right) + public static bool operator ==(AgentRequestMessageSourceType left, AgentRequestMessageSourceType right) { - if (left is null) - { - return right is null; - } - return left.Equals(right); } @@ -102,5 +81,5 @@ public sealed class AgentRequestMessageSourceType : IEquatableThe first to compare. /// The second to compare. /// if the value of is different from the value of ; otherwise, . - public static bool operator !=(AgentRequestMessageSourceType? left, AgentRequestMessageSourceType? right) => !(left == right); + public static bool operator !=(AgentRequestMessageSourceType left, AgentRequestMessageSourceType right) => !(left == right); } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSession.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSession.cs index 3efce9be17..4c62eccc99 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSession.cs @@ -36,7 +36,7 @@ namespace Microsoft.Agents.AI; /// /// To support conversations that may need to survive application restarts or separate service requests, an can be serialized /// and deserialized, so that it can be saved in a persistent store. -/// The provides the method to serialize the session to a +/// The provides the method to serialize the session to a /// and the method /// can be used to deserialize the session. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs index f49c5d46a7..086ad02061 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs @@ -37,23 +37,23 @@ namespace Microsoft.Agents.AI; /// public abstract class ChatHistoryProvider { - private readonly string _sourceName; + private readonly string _sourceId; /// /// Initializes a new instance of the class. /// protected ChatHistoryProvider() { - this._sourceName = this.GetType().FullName!; + this._sourceId = this.GetType().FullName!; } /// - /// Initializes a new instance of the class with the specified source name. + /// Initializes a new instance of the class with the specified source id. /// - /// The source name to stamp on for each messages produced by the . - protected ChatHistoryProvider(string sourceName) + /// The source id to stamp on for each messages produced by the . + protected ChatHistoryProvider(string sourceId) { - this._sourceName = sourceName; + this._sourceId = sourceId; } /// @@ -89,27 +89,7 @@ public abstract class ChatHistoryProvider { var messages = await this.InvokingCoreAsync(context, cancellationToken).ConfigureAwait(false); - return messages.Select(message => - { - if (message.AdditionalProperties != null - // Check if the message was already tagged with this provider's source type - && message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out var messageSourceType) - && messageSourceType is AgentRequestMessageSourceType typedMessageSourceType - && typedMessageSourceType == AgentRequestMessageSourceType.ChatHistory - // Check if the message was already tagged with this provider's source - && message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out var messageSource) - && messageSource is string typedMessageSource - && typedMessageSource == this._sourceName) - { - return message; - } - - message = message.Clone(); - message.AdditionalProperties ??= new(); - message.AdditionalProperties[AgentRequestMessageSourceType.AdditionalPropertiesKey] = AgentRequestMessageSourceType.ChatHistory; - message.AdditionalProperties[AgentRequestMessageSource.AdditionalPropertiesKey] = this._sourceName; - return message; - }); + return messages.Select(message => message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.ChatHistory, this._sourceId)); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProviderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProviderExtensions.cs index c2ff8bf3e5..4c8ef2489f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProviderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProviderExtensions.cs @@ -45,7 +45,7 @@ public static class ChatHistoryProviderExtensions innerProvider: provider, invokedMessagesFilter: (ctx) => { - ctx.RequestMessages = ctx.RequestMessages.Where(x => x.GetAgentRequestMessageSource() != AgentRequestMessageSourceType.AIContextProvider); + ctx.RequestMessages = ctx.RequestMessages.Where(x => x.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.AIContextProvider); return ctx; }); } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageExtensions.cs index 01edcb4eff..052e48ce56 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageExtensions.cs @@ -10,18 +10,66 @@ namespace Microsoft.Agents.AI; public static class ChatMessageExtensions { /// - /// Gets the source of the provided in the context of messages passed into an agent run. + /// Gets the source type of the provided in the context of messages passed into an agent run. /// - /// The for which we need the source. - /// An value indicating the source of the . Defaults to The for which we need the source type. + /// An value indicating the source type of the . Defaults to if no explicit source is defined. - public static AgentRequestMessageSourceType GetAgentRequestMessageSource(this ChatMessage message) + public static AgentRequestMessageSourceType GetAgentRequestMessageSourceType(this ChatMessage message) { - if (message.AdditionalProperties?.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out var source) is true && source is AgentRequestMessageSourceType typedSource) + if (message.AdditionalProperties?.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out var attribution) is true + && attribution is AgentRequestMessageSourceAttribution typedAttribution) { - return typedSource; + return typedAttribution.SourceType; } return AgentRequestMessageSourceType.External; } + + /// + /// Gets the source id of the provided in the context of messages passed into an agent run. + /// + /// The for which we need the source id. + /// An value indicating the source id of the . Defaults to + /// if no explicit source id is defined. + public static string? GetAgentRequestMessageSourceId(this ChatMessage message) + { + if (message.AdditionalProperties?.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out var attribution) is true + && attribution is AgentRequestMessageSourceAttribution typedAttribution) + { + return typedAttribution.SourceId; + } + + return null; + } + + /// + /// Ensure that the provided message is tagged with the provided source type and source id in the context of a specific agent run. + /// + /// The message to tag. + /// The source type to tag the message with. + /// The source id to tag the message with. + /// The tagged message. + /// + /// If the message is already tagged with the provided source type and source id, it is returned as is. + /// Otherwise, a cloned message is returned with the appropriate tagging in the AdditionalProperties. + /// + public static ChatMessage AsAgentRequestMessageSourcedMessage(this ChatMessage message, AgentRequestMessageSourceType sourceType, string? sourceId = null) + { + if (message.AdditionalProperties != null + // Check if the message was already tagged with the required source type and source id + && message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out var messageSourceAttribution) + && messageSourceAttribution is AgentRequestMessageSourceAttribution typedMessageSourceAttribution + && typedMessageSourceAttribution.SourceType == sourceType + && typedMessageSourceAttribution.SourceId == sourceId) + { + return message; + } + + message = message.Clone(); + message.AdditionalProperties ??= new(); + message.AdditionalProperties[AgentRequestMessageSourceAttribution.AdditionalPropertiesKey] = + new AgentRequestMessageSourceAttribution(sourceType, sourceId); + return message; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/DelegatingAIAgent.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/DelegatingAIAgent.cs index b20ba43dd1..94a2c531cf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/DelegatingAIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/DelegatingAIAgent.cs @@ -77,8 +77,8 @@ public abstract class DelegatingAIAgent : AIAgent protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => this.InnerAgent.CreateSessionAsync(cancellationToken); /// - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) - => this.InnerAgent.SerializeSession(session, jsonSerializerOptions); + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => this.InnerAgent.SerializeSessionAsync(session, jsonSerializerOptions, cancellationToken); /// protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs index 18a5ba3b72..8433ff3b6f 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.CopilotStudio/CopilotStudioAgent.cs b/dotnet/src/Microsoft.Agents.AI.CopilotStudio/CopilotStudioAgent.cs index 48642139a9..c631f90f17 100644 --- a/dotnet/src/Microsoft.Agents.AI.CopilotStudio/CopilotStudioAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.CopilotStudio/CopilotStudioAgent.cs @@ -54,7 +54,7 @@ public class CopilotStudioAgent : AIAgent => new(new CopilotStudioAgentSession() { ConversationId = conversationId }); /// - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { Throw.IfNull(session); @@ -63,7 +63,7 @@ public class CopilotStudioAgent : AIAgent throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized."); } - return typedSession.Serialize(jsonSerializerOptions); + return new(typedSession.Serialize(jsonSerializerOptions)); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md index c34a8fe95d..d8260fcb84 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md @@ -11,6 +11,7 @@ - Moved AgentSession.Serialize to AIAgent.SerializeSession ([#3650](https://github.com/microsoft/agent-framework/pull/3650)) - Renamed serializedSession parameter to serializedState on DeserializeSessionAsync for consistency ([#3681](https://github.com/microsoft/agent-framework/pull/3681)) - Introduce Core method pattern for Session management methods on AIAgent ([#3699](https://github.com/microsoft/agent-framework/pull/3699)) +- Changed AIAgent.SerializeSession to AIAgent.SerializeSessionAsync ([#3879](https://github.com/microsoft/agent-framework/pull/3879)) ## v1.0.0-preview.251204.1 diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs index 547e999449..c790222e50 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs @@ -45,8 +45,9 @@ public sealed class DurableAIAgent : AIAgent /// /// The session to serialize. /// Optional JSON serializer options. + /// The cancellation token. /// A containing the serialized session state. - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { if (session is null) { @@ -58,7 +59,7 @@ public sealed class DurableAIAgent : AIAgent throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized."); } - return durableSession.Serialize(jsonSerializerOptions); + return new(durableSession.Serialize(jsonSerializerOptions)); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs index 618b43916c..f0f7a4ffd4 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs @@ -11,7 +11,7 @@ internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient) public override string? Name { get; } = name; - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { if (session is null) { @@ -23,7 +23,7 @@ internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient) throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized."); } - return durableSession.Serialize(jsonSerializerOptions); + return new(durableSession.Serialize(jsonSerializerOptions)); } protected override ValueTask DeserializeSessionCoreAsync( diff --git a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs index 06c7f24ee2..8319832613 100644 --- a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs @@ -98,7 +98,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable => new(new GitHubCopilotAgentSession() { SessionId = sessionId }); /// - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { _ = Throw.IfNull(session); @@ -107,7 +107,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized."); } - return typedSession.Serialize(jsonSerializerOptions); + return new(typedSession.Serialize(jsonSerializerOptions)); } /// @@ -147,21 +147,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable // Create or resume a session with streaming enabled SessionConfig sessionConfig = this._sessionConfig != null - ? new SessionConfig - { - Model = this._sessionConfig.Model, - Tools = this._sessionConfig.Tools, - SystemMessage = this._sessionConfig.SystemMessage, - AvailableTools = this._sessionConfig.AvailableTools, - ExcludedTools = this._sessionConfig.ExcludedTools, - Provider = this._sessionConfig.Provider, - OnPermissionRequest = this._sessionConfig.OnPermissionRequest, - McpServers = this._sessionConfig.McpServers, - CustomAgents = this._sessionConfig.CustomAgents, - SkillDirectories = this._sessionConfig.SkillDirectories, - DisabledSkills = this._sessionConfig.DisabledSkills, - Streaming = true - } + ? CopySessionConfig(this._sessionConfig) : new SessionConfig { Streaming = true }; CopilotSession copilotSession; @@ -217,16 +203,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 +230,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable } finally { - CleanupTempFiles(tempFiles); + CleanupTempDir(tempDir); } } finally @@ -284,16 +269,64 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable } private ResumeSessionConfig CreateResumeConfig() + { + return CopyResumeSessionConfig(this._sessionConfig); + } + + /// + /// Copies all supported properties from a source into a new instance + /// with set to true. + /// + internal static SessionConfig CopySessionConfig(SessionConfig source) + { + return new SessionConfig + { + Model = source.Model, + ReasoningEffort = source.ReasoningEffort, + Tools = source.Tools, + SystemMessage = source.SystemMessage, + AvailableTools = source.AvailableTools, + ExcludedTools = source.ExcludedTools, + Provider = source.Provider, + OnPermissionRequest = source.OnPermissionRequest, + OnUserInputRequest = source.OnUserInputRequest, + Hooks = source.Hooks, + WorkingDirectory = source.WorkingDirectory, + ConfigDir = source.ConfigDir, + McpServers = source.McpServers, + CustomAgents = source.CustomAgents, + SkillDirectories = source.SkillDirectories, + DisabledSkills = source.DisabledSkills, + InfiniteSessions = source.InfiniteSessions, + Streaming = true + }; + } + + /// + /// Copies all supported properties from a source into a new + /// with set to true. + /// + internal static ResumeSessionConfig CopyResumeSessionConfig(SessionConfig? source) { return new ResumeSessionConfig { - Tools = this._sessionConfig?.Tools, - Provider = this._sessionConfig?.Provider, - OnPermissionRequest = this._sessionConfig?.OnPermissionRequest, - McpServers = this._sessionConfig?.McpServers, - CustomAgents = this._sessionConfig?.CustomAgents, - SkillDirectories = this._sessionConfig?.SkillDirectories, - DisabledSkills = this._sessionConfig?.DisabledSkills, + Model = source?.Model, + ReasoningEffort = source?.ReasoningEffort, + Tools = source?.Tools, + SystemMessage = source?.SystemMessage, + AvailableTools = source?.AvailableTools, + ExcludedTools = source?.ExcludedTools, + Provider = source?.Provider, + OnPermissionRequest = source?.OnPermissionRequest, + OnUserInputRequest = source?.OnUserInputRequest, + Hooks = source?.Hooks, + WorkingDirectory = source?.WorkingDirectory, + ConfigDir = source?.ConfigDir, + McpServers = source?.McpServers, + CustomAgents = source?.CustomAgents, + SkillDirectories = source?.SkillDirectories, + DisabledSkills = source?.DisabledSkills, + InfiniteSessions = source?.InfiniteSessions, Streaming = true }; } @@ -410,49 +443,26 @@ 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 + attachments.Add(new UserMessageDataAttachmentsItemFile { - Type = UserMessageDataAttachmentsItemType.File, Path = tempFilePath, DisplayName = Path.GetFileName(tempFilePath) }); @@ -460,19 +470,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.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj index f4f79c27bd..4c436263c1 100644 --- a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj @@ -17,6 +17,10 @@ + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs index 26a07ce573..9999527505 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs @@ -30,11 +30,10 @@ public sealed class InMemoryAgentSessionStore : AgentSessionStore private readonly ConcurrentDictionary _threads = new(); /// - public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) { var key = GetKey(conversationId, agent.Id); - this._threads[key] = agent.SerializeSession(session); - return default; + this._threads[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs index 8a0c016f07..a230eb0e4e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs @@ -138,7 +138,7 @@ public sealed class Mem0Provider : AIContextProvider string queryText = string.Join( Environment.NewLine, context.RequestMessages - .Where(m => m.GetAgentRequestMessageSource() == AgentRequestMessageSourceType.External) + .Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External) .Where(m => !string.IsNullOrWhiteSpace(m.Text)) .Select(m => m.Text)); @@ -217,7 +217,7 @@ public sealed class Mem0Provider : AIContextProvider // Persist request and response messages after invocation. await this.PersistMessagesAsync( context.RequestMessages - .Where(m => m.GetAgentRequestMessageSource() == AgentRequestMessageSourceType.External) + .Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External) .Concat(context.ResponseMessages ?? []), cancellationToken).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs index a401288127..ee9978fdf2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs @@ -21,12 +21,14 @@ internal abstract class ProcessContentMetadataBase : GraphDataTypeBase /// The unique identifier for the content. /// Indicates if the content is truncated. /// The name of the content. - protected ProcessContentMetadataBase(ContentBase content, string identifier, bool isTruncated, string name) : base(ProcessConversationMetadataDataType) + /// The correlation ID for the content. + protected ProcessContentMetadataBase(ContentBase content, string identifier, bool isTruncated, string name, string correlationId) : base(ProcessConversationMetadataDataType) { this.Identifier = identifier; this.IsTruncated = isTruncated; this.Content = content; this.Name = name; + this.CorrelationId = correlationId; } /// @@ -55,7 +57,7 @@ internal abstract class ProcessContentMetadataBase : GraphDataTypeBase /// Identifier to group multiple contents. /// [JsonPropertyName("correlationId")] - public string? CorrelationId { get; set; } + public string CorrelationId { get; set; } /// /// Gets or sets the sequenceNumber. diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs index 86bedb9248..9100eac02e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs @@ -15,7 +15,7 @@ internal sealed class ProcessConversationMetadata : ProcessContentMetadataBase /// /// Initializes a new instance of the class. /// - public ProcessConversationMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name) : base(contentBase, identifier, isTruncated, name) + public ProcessConversationMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name, string correlationId) : base(contentBase, identifier, isTruncated, name, correlationId) { this.DataType = ProcessConversationMetadataDataType; } diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs index a9f1749bed..89c0912e09 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs @@ -14,7 +14,7 @@ internal sealed class ProcessFileMetadata : ProcessContentMetadataBase /// /// Initializes a new instance of the class. /// - public ProcessFileMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name) : base(contentBase, identifier, isTruncated, name) + public ProcessFileMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name, string correlationId) : base(contentBase, identifier, isTruncated, name, correlationId) { this.DataType = ProcessFileMetadataDataType; } diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs index 7ea854e5ec..cbb286216b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewAgent.cs @@ -30,9 +30,9 @@ internal class PurviewAgent : AIAgent, IDisposable } /// - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { - return this._innerAgent.SerializeSession(session, jsonSerializerOptions); + return this._innerAgent.SerializeSessionAsync(session, jsonSerializerOptions, cancellationToken); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs index cb400805c6..508f531bbe 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs @@ -19,7 +19,7 @@ public class PurviewSettings /// The publicly visible name of the application. public PurviewSettings(string appName) { - this.AppName = appName; + this.AppName = string.IsNullOrWhiteSpace(appName) ? throw new ArgumentException("AppName cannot be null or whitespace.", nameof(appName)) : appName; } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs index 5a63448478..9b6cdc2ffd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs @@ -53,7 +53,7 @@ internal sealed class PurviewWrapper : IDisposable } } - return Guid.NewGuid().ToString(); + return string.Empty; } /// @@ -136,12 +136,15 @@ internal sealed class PurviewWrapper : IDisposable /// The agent's response. This could be the response from the agent or a message indicating that Purview has blocked the prompt or response. public async Task ProcessAgentContentAsync(IEnumerable messages, AgentSession? session, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) { - string sessionId = GetSessionIdFromAgentSession(session, messages); - string? resolvedUserId = null; - + string sessionId = string.Empty; try { + sessionId = GetSessionIdFromAgentSession(session, messages); + if (string.IsNullOrEmpty(sessionId)) + { + sessionId = Guid.NewGuid().ToString(); + } (bool shouldBlockPrompt, resolvedUserId) = await this._scopedProcessor.ProcessMessagesAsync(messages, sessionId, Activity.UploadText, this._purviewSettings, null, cancellationToken).ConfigureAwait(false); if (shouldBlockPrompt) @@ -171,7 +174,19 @@ internal sealed class PurviewWrapper : IDisposable try { - (bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, sessionId, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false); + string sessionIdResponse = GetSessionIdFromAgentSession(session, messages); + if (string.IsNullOrEmpty(sessionIdResponse)) + { + if (string.IsNullOrEmpty(sessionId)) + { + sessionIdResponse = Guid.NewGuid().ToString(); + } + else + { + sessionIdResponse = sessionId; + } + } + (bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, sessionIdResponse, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false); if (shouldBlockResponse) { diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs index 177b1a07d8..3fb7aa6c4d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs @@ -121,9 +121,10 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor { string messageId = message.MessageId ?? Guid.NewGuid().ToString(); ContentBase content = new PurviewTextContent(message.Text); - ProcessConversationMetadata conversationmetadata = new(content, messageId, false, $"Agent Framework Message {messageId}") + string correlationId = (sessionId ?? Guid.NewGuid().ToString()) + "@AF"; + ProcessConversationMetadata conversationMetadata = new(content, messageId, false, $"Agent Framework Message {messageId}", correlationId) { - CorrelationId = sessionId ?? Guid.NewGuid().ToString() + SequenceNumber = DateTime.UtcNow.Ticks, }; ActivityMetadata activityMetadata = new(activity); PolicyLocation policyLocation; @@ -162,7 +163,7 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor OperatingSystemVersion = "Unknown" } }; - ContentToProcess contentToProcess = new([conversationmetadata], activityMetadata, deviceMetadata, integratedAppMetadata, protectedAppMetadata); + ContentToProcess contentToProcess = new([conversationMetadata], activityMetadata, deviceMetadata, integratedAppMetadata, protectedAppMetadata); if (userId == null && tokenInfo?.UserId != null) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs index a86e2a2f4a..9f909ad84e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs @@ -25,7 +25,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative; /// project endpoint and credentials to authenticate requests. /// A instance representing the endpoint URL of the Foundry project. This must be a valid, non-null URI pointing to the project. /// The credentials used to authenticate with the Foundry project. This must be a valid instance of . -public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential projectCredentials) : WorkflowAgentProvider +public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential projectCredentials) : ResponseAgentProvider { private readonly Dictionary _versionCache = []; private readonly Dictionary _agentCache = []; @@ -123,7 +123,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj IAsyncEnumerable agentResponse = messages is not null ? agent.RunStreamingAsync([.. messages], null, runOptions, cancellationToken) : - agent.RunStreamingAsync([new ChatMessage(ChatRole.User, string.Empty)], null, runOptions, cancellationToken); + agent.RunStreamingAsync([], null, runOptions, cancellationToken); await foreach (AgentResponseUpdate update in agentResponse.ConfigureAwait(false)) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/AddConversationMessageTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/AddConversationMessageTemplate.cs index 52cb6eb2e2..0064483589 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/AddConversationMessageTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/AddConversationMessageTemplate.cs @@ -1,7 +1,7 @@ // ------------------------------------------------------------------------------ // // This code was generated by a tool. -// Runtime Version: 17.0.0.0 +// Runtime Version: 18.0.0.0 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -10,16 +10,13 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen { using Microsoft.Agents.AI.Workflows.Declarative.Extensions; - using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; - using Microsoft.Extensions.AI; - using System.Collections.Generic; using System; /// /// Class to produce the template output /// - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "18.0.0.0")] internal partial class AddConversationMessageTemplate : ActionTemplate { /// @@ -35,19 +32,10 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen this.Write("\n"); this.Write("\n"); this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n\n/// \n/// Adds a new message to the specified agent conversation\n/// \ninternal sealed class "); + this.Write("\n/// \n/// Adds a new message to the specified agent conversation\n/// \ninternal sealed class "); this.Write(this.ToStringHelper.ToStringWithCulture(this.Name)); - this.Write("Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExe" + + this.Write("Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExe" + "cutor(id: \""); this.Write(this.ToStringHelper.ToStringWithCulture(this.Id)); this.Write("\", session)\n{\n // \n protected override async ValueTask(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync>("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateEnumExpression( - EnumExpression expression, - string targetVariable, - IDictionary resultMap, - string defaultValue = null, - bool qualifyResult = false, - bool isNullable = false) - where TWrapper : EnumWrapper -{ - string resultType = $"{GetTypeAlias()}{(isNullable ? "?" : "")}"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - resultMap.TryGetValue(expression.LiteralValue, out string resultValue); - if (qualifyResult) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("."); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultValue)); - -this.Write(";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(resultValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateIntExpression(IntExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "int?" : "int"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "0")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateListExpression(ValueExpression expression, string targetVariable) -{ - string typeName = GetTypeAlias(); - if (expression is null) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write("> = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - void EvaluateRecordExpression(ObjectExpression expression, string targetVariable) { string resultTypeName = $"Dictionary()}?>?"; @@ -803,116 +351,6 @@ this.Write(").ConfigureAwait(false);"); } -void EvaluateValueExpression(ValueExpression expression, string targetVariable) => - EvaluateValueExpression(expression, targetVariable); - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) -{ - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - void EvaluateMessageTemplate(TemplateLine templateLine, string variableName) { if (templateLine is not null) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/AddConversationMessageTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/AddConversationMessageTemplate.tt index 439f62f8db..d86b624ac9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/AddConversationMessageTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/AddConversationMessageTemplate.tt @@ -1,12 +1,16 @@ <#@ template language="C#" inherits="ActionTemplate" visibility="internal" linePragmas="false" #> <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> -<#@ include file="Snippets/Index.tt" once="true" #> - +<#@ import namespace="Microsoft.Agents.AI.Workflows.Declarative.Extensions" #> +<#@ import namespace="Microsoft.Agents.ObjectModel" #> +<#@ include file="Snippets/AssignVariableTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateRecordExpressionTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateStringExpressionTemplate.tt" once="true" #> +<#@ include file="Snippets/FormatMessageTemplate.tt" once="true" #> /// /// Adds a new message to the specified agent conversation /// -internal sealed class <#= this.Name #>Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session) +internal sealed class <#= this.Name #>Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session) { // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ClearAllVariablesTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ClearAllVariablesTemplate.cs index ea534767bc..0d87f1c739 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ClearAllVariablesTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ClearAllVariablesTemplate.cs @@ -9,11 +9,8 @@ // ------------------------------------------------------------------------------ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen { - using Microsoft.Agents.AI.Workflows.Declarative.Extensions; - using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; - using Microsoft.Agents.ObjectModel; - using Microsoft.Extensions.AI; using System.Collections.Generic; + using Microsoft.Agents.ObjectModel; using System; /// @@ -27,17 +24,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen /// public override string TransformText() { - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); this.Write("\n"); this.Write("\n"); this.Write("\n"); @@ -87,85 +73,6 @@ this.Write("\n "); } -void EvaluateBoolExpression(BoolExpression expression, string targetVariable, bool defaultValue = false) -{ - if (expression is null) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync>("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - void EvaluateEnumExpression( EnumExpression expression, string targetVariable, @@ -310,601 +217,5 @@ this.Write(").ConfigureAwait(false);"); } } - -void EvaluateIntExpression(IntExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "int?" : "int"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "0")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateListExpression(ValueExpression expression, string targetVariable) -{ - string typeName = GetTypeAlias(); - if (expression is null) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write("> = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateRecordExpression(ObjectExpression expression, string targetVariable) -{ - string resultTypeName = $"Dictionary()}?>?"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" =\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateStringExpression(StringExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "string?" : "string"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "string.Empty")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - if (expression.LiteralValue.Contains("\n")) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = \n \"\"\"\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write("\n \"\"\";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.LiteralValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) => - EvaluateValueExpression(expression, targetVariable); - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) -{ - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateMessageTemplate(TemplateLine templateLine, string variableName) -{ - if (templateLine is not null) - { -this.Write("\n string "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" =\n await context.FormatTemplateAsync(\n \"\"\""); - - - FormatMessageTemplate(templateLine); -this.Write("\n \"\"\");"); - - - } - else - { -this.Write("\n string? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" = null;"); - - - } -} - -void FormatMessageTemplate(TemplateLine line) -{ - foreach (string text in line.ToTemplateString().ByLine()) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(text)); - - - } -} - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ClearAllVariablesTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ClearAllVariablesTemplate.tt index fbac67edf2..b86d189ddb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ClearAllVariablesTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ClearAllVariablesTemplate.tt @@ -1,7 +1,10 @@ <#@ template language="C#" inherits="ActionTemplate" visibility="internal" linePragmas="false" #> <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> -<#@ include file="Snippets/Index.tt" once="true" #> +<#@ import namespace="System.Collections.Generic" #> +<#@ import namespace="Microsoft.Agents.ObjectModel" #> +<#@ include file="Snippets/AssignVariableTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateEnumExpressionTemplate.tt" once="true" #> /// /// Reset all the state for the targeted variable scope. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ConditionGroupTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ConditionGroupTemplate.cs index 8f330d9afd..eabaff77d8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ConditionGroupTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ConditionGroupTemplate.cs @@ -9,11 +9,8 @@ // ------------------------------------------------------------------------------ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen { - using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; - using Microsoft.Extensions.AI; - using System.Collections.Generic; using System; /// @@ -27,17 +24,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen /// public override string TransformText() { - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); this.Write("\n"); this.Write("\n"); this.Write("\n"); @@ -182,746 +168,5 @@ this.Write(").ConfigureAwait(false);"); } } - -void EvaluateEnumExpression( - EnumExpression expression, - string targetVariable, - IDictionary resultMap, - string defaultValue = null, - bool qualifyResult = false, - bool isNullable = false) - where TWrapper : EnumWrapper -{ - string resultType = $"{GetTypeAlias()}{(isNullable ? "?" : "")}"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - resultMap.TryGetValue(expression.LiteralValue, out string resultValue); - if (qualifyResult) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("."); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultValue)); - -this.Write(";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(resultValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateIntExpression(IntExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "int?" : "int"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "0")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateListExpression(ValueExpression expression, string targetVariable) -{ - string typeName = GetTypeAlias(); - if (expression is null) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write("> = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateRecordExpression(ObjectExpression expression, string targetVariable) -{ - string resultTypeName = $"Dictionary()}?>?"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" =\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateStringExpression(StringExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "string?" : "string"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "string.Empty")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - if (expression.LiteralValue.Contains("\n")) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = \n \"\"\"\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write("\n \"\"\";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.LiteralValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) => - EvaluateValueExpression(expression, targetVariable); - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) -{ - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateMessageTemplate(TemplateLine templateLine, string variableName) -{ - if (templateLine is not null) - { -this.Write("\n string "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" =\n await context.FormatTemplateAsync(\n \"\"\""); - - - FormatMessageTemplate(templateLine); -this.Write("\n \"\"\");"); - - - } - else - { -this.Write("\n string? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" = null;"); - - - } -} - -void FormatMessageTemplate(TemplateLine line) -{ - foreach (string text in line.ToTemplateString().ByLine()) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(text)); - - - } -} - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ConditionGroupTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ConditionGroupTemplate.tt index d91cadc4d2..37ec8863f1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ConditionGroupTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ConditionGroupTemplate.tt @@ -1,7 +1,10 @@ <#@ template language="C#" inherits="ActionTemplate" visibility="internal" linePragmas="false" #> <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> -<#@ include file="Snippets/Index.tt" once="true" #> +<#@ import namespace="Microsoft.Agents.AI.Workflows.Declarative.ObjectModel" #> +<#@ import namespace="Microsoft.Agents.ObjectModel" #> +<#@ include file="Snippets/AssignVariableTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateBoolExpressionTemplate.tt" once="true" #> /// /// Conditional branching similar to an if / elseif / elseif / else chain. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CopyConversationMessagesTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CopyConversationMessagesTemplate.cs index 9826408215..78fadd1982 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CopyConversationMessagesTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CopyConversationMessagesTemplate.cs @@ -1,7 +1,7 @@ // ------------------------------------------------------------------------------ // // This code was generated by a tool. -// Runtime Version: 17.0.0.0 +// Runtime Version: 18.0.0.0 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -9,17 +9,14 @@ // ------------------------------------------------------------------------------ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen { - using Microsoft.Agents.AI.Workflows.Declarative.Extensions; - using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; using Microsoft.Extensions.AI; - using System.Collections.Generic; using System; /// /// Class to produce the template output /// - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "18.0.0.0")] internal partial class CopyConversationMessagesTemplate : ActionTemplate { /// @@ -27,16 +24,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen /// public override string TransformText() { - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); this.Write("\n"); this.Write("\n"); this.Write("\n"); @@ -47,7 +34,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen this.Write("\n/// \n/// Copies one or more messages into the specified agent conversat" + "ion.\n/// \ninternal sealed class "); this.Write(this.ToStringHelper.ToStringWithCulture(this.Name)); - this.Write("Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExe" + + this.Write("Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExe" + "cutor(id: \""); this.Write(this.ToStringHelper.ToStringWithCulture(this.Id)); this.Write("\", session)\n{\n // \n protected override async ValueTask(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync>("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateEnumExpression( - EnumExpression expression, - string targetVariable, - IDictionary resultMap, - string defaultValue = null, - bool qualifyResult = false, - bool isNullable = false) - where TWrapper : EnumWrapper -{ - string resultType = $"{GetTypeAlias()}{(isNullable ? "?" : "")}"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - resultMap.TryGetValue(expression.LiteralValue, out string resultValue); - if (qualifyResult) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("."); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultValue)); - -this.Write(";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(resultValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateIntExpression(IntExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "int?" : "int"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "0")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateListExpression(ValueExpression expression, string targetVariable) -{ - string typeName = GetTypeAlias(); - if (expression is null) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write("> = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateRecordExpression(ObjectExpression expression, string targetVariable) -{ - string resultTypeName = $"Dictionary()}?>?"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" =\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - void EvaluateStringExpression(StringExpression expression, string targetVariable, bool isNullable = false) { string typeName = isNullable ? "string?" : "string"; @@ -881,46 +320,5 @@ this.Write(").ConfigureAwait(false);"); } } - -void EvaluateMessageTemplate(TemplateLine templateLine, string variableName) -{ - if (templateLine is not null) - { -this.Write("\n string "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" =\n await context.FormatTemplateAsync(\n \"\"\""); - - - FormatMessageTemplate(templateLine); -this.Write("\n \"\"\");"); - - - } - else - { -this.Write("\n string? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" = null;"); - - - } -} - -void FormatMessageTemplate(TemplateLine line) -{ - foreach (string text in line.ToTemplateString().ByLine()) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(text)); - - - } -} - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CopyConversationMessagesTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CopyConversationMessagesTemplate.tt index 8014af3f8b..b1bbcabb8c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CopyConversationMessagesTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CopyConversationMessagesTemplate.tt @@ -1,11 +1,15 @@ <#@ template language="C#" inherits="ActionTemplate" visibility="internal" linePragmas="false" #> <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> -<#@ include file="Snippets/Index.tt" once="true" #> +<#@ import namespace="Microsoft.Agents.ObjectModel" #> +<#@ import namespace="Microsoft.Extensions.AI" #> +<#@ include file="Snippets/AssignVariableTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateStringExpressionTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateValueExpressionTemplate.tt" once="true" #> /// /// Copies one or more messages into the specified agent conversation. /// -internal sealed class <#= this.Name #>Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session) +internal sealed class <#= this.Name #>Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session) { // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CreateConversationTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CreateConversationTemplate.cs index f9f0c52939..58d4217285 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CreateConversationTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CreateConversationTemplate.cs @@ -1,7 +1,7 @@ // ------------------------------------------------------------------------------ // // This code was generated by a tool. -// Runtime Version: 17.0.0.0 +// Runtime Version: 18.0.0.0 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -9,17 +9,13 @@ // ------------------------------------------------------------------------------ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen { - using Microsoft.Agents.AI.Workflows.Declarative.Extensions; - using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; - using Microsoft.Extensions.AI; - using System.Collections.Generic; using System; /// /// Class to produce the template output /// - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "18.0.0.0")] internal partial class CreateConversationTemplate : ActionTemplate { /// @@ -27,19 +23,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen /// public override string TransformText() { - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); this.Write("\n"); this.Write("\n"); this.Write("\n"); @@ -49,7 +32,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen this.Write(this.ToStringHelper.ToStringWithCulture(this.Model.ConversationId)); this.Write("\" variable.\n/// \ninternal sealed class "); this.Write(this.ToStringHelper.ToStringWithCulture(this.Name)); - this.Write("Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExe" + + this.Write("Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExe" + "cutor(id: \""); this.Write(this.ToStringHelper.ToStringWithCulture(this.Id)); this.Write(@""", session) @@ -91,825 +74,5 @@ this.Write("\n "); } } - -void EvaluateBoolExpression(BoolExpression expression, string targetVariable, bool defaultValue = false) -{ - if (expression is null) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync>("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateEnumExpression( - EnumExpression expression, - string targetVariable, - IDictionary resultMap, - string defaultValue = null, - bool qualifyResult = false, - bool isNullable = false) - where TWrapper : EnumWrapper -{ - string resultType = $"{GetTypeAlias()}{(isNullable ? "?" : "")}"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - resultMap.TryGetValue(expression.LiteralValue, out string resultValue); - if (qualifyResult) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("."); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultValue)); - -this.Write(";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(resultValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateIntExpression(IntExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "int?" : "int"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "0")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateListExpression(ValueExpression expression, string targetVariable) -{ - string typeName = GetTypeAlias(); - if (expression is null) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write("> = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateRecordExpression(ObjectExpression expression, string targetVariable) -{ - string resultTypeName = $"Dictionary()}?>?"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" =\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateStringExpression(StringExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "string?" : "string"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "string.Empty")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - if (expression.LiteralValue.Contains("\n")) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = \n \"\"\"\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write("\n \"\"\";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.LiteralValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) => - EvaluateValueExpression(expression, targetVariable); - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) -{ - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateMessageTemplate(TemplateLine templateLine, string variableName) -{ - if (templateLine is not null) - { -this.Write("\n string "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" =\n await context.FormatTemplateAsync(\n \"\"\""); - - - FormatMessageTemplate(templateLine); -this.Write("\n \"\"\");"); - - - } - else - { -this.Write("\n string? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" = null;"); - - - } -} - -void FormatMessageTemplate(TemplateLine line) -{ - foreach (string text in line.ToTemplateString().ByLine()) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(text)); - - - } -} - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CreateConversationTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CreateConversationTemplate.tt index 859f25a56a..c2e2ebd786 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CreateConversationTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/CreateConversationTemplate.tt @@ -1,11 +1,12 @@ <#@ template language="C#" inherits="ActionTemplate" visibility="internal" linePragmas="false" #> <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> -<#@ include file="Snippets/Index.tt" once="true" #> +<#@ import namespace="Microsoft.Agents.ObjectModel" #> +<#@ include file="Snippets/AssignVariableTemplate.tt" once="true" #> /// /// Creates a new conversation and stores the identifier value to the "<#= this.Model.ConversationId #>" variable. /// -internal sealed class <#= this.Name #>Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session) +internal sealed class <#= this.Name #>Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session) { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/EditTableV2Template.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/EditTableV2Template.cs index 56a1048946..a05ddba863 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/EditTableV2Template.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/EditTableV2Template.cs @@ -9,11 +9,6 @@ // ------------------------------------------------------------------------------ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen { - using Microsoft.Agents.AI.Workflows.Declarative.Extensions; - using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; - using Microsoft.Agents.ObjectModel; - using Microsoft.Extensions.AI; - using System.Collections.Generic; using System; /// @@ -27,21 +22,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen /// public override string TransformText() { - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); this.Write("\n"); this.Write("\n"); this.Write("\n/// \n/// Modify items in a list\n/// \ninternal sealed class "); @@ -53,853 +33,5 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen " {\n return default;\n }\n}"); return this.GenerationEnvironment.ToString(); } - -void AssignVariable(PropertyPath targetVariable, string valueVariable, bool tightFormat = false) -{ - if (targetVariable is not null) - { -this.Write("\n await context.QueueStateUpdateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(VariableName(targetVariable))); - -this.Write("\", value: "); - -this.Write(this.ToStringHelper.ToStringWithCulture(valueVariable)); - -this.Write(", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(VariableScope(targetVariable))); - -this.Write("\").ConfigureAwait(false);"); - - - if (!tightFormat) - { -this.Write("\n "); - -} - } -} - - -void EvaluateBoolExpression(BoolExpression expression, string targetVariable, bool defaultValue = false) -{ - if (expression is null) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync>("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateEnumExpression( - EnumExpression expression, - string targetVariable, - IDictionary resultMap, - string defaultValue = null, - bool qualifyResult = false, - bool isNullable = false) - where TWrapper : EnumWrapper -{ - string resultType = $"{GetTypeAlias()}{(isNullable ? "?" : "")}"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - resultMap.TryGetValue(expression.LiteralValue, out string resultValue); - if (qualifyResult) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("."); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultValue)); - -this.Write(";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(resultValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateIntExpression(IntExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "int?" : "int"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "0")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateListExpression(ValueExpression expression, string targetVariable) -{ - string typeName = GetTypeAlias(); - if (expression is null) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write("> = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateRecordExpression(ObjectExpression expression, string targetVariable) -{ - string resultTypeName = $"Dictionary()}?>?"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" =\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateStringExpression(StringExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "string?" : "string"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "string.Empty")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - if (expression.LiteralValue.Contains("\n")) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = \n \"\"\"\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write("\n \"\"\";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.LiteralValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) => - EvaluateValueExpression(expression, targetVariable); - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) -{ - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateMessageTemplate(TemplateLine templateLine, string variableName) -{ - if (templateLine is not null) - { -this.Write("\n string "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" =\n await context.FormatTemplateAsync(\n \"\"\""); - - - FormatMessageTemplate(templateLine); -this.Write("\n \"\"\");"); - - - } - else - { -this.Write("\n string? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" = null;"); - - - } -} - -void FormatMessageTemplate(TemplateLine line) -{ - foreach (string text in line.ToTemplateString().ByLine()) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(text)); - - - } -} - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/EditTableV2Template.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/EditTableV2Template.tt index a39630ac5b..e783089c51 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/EditTableV2Template.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/EditTableV2Template.tt @@ -1,7 +1,6 @@ <#@ template language="C#" inherits="ActionTemplate" visibility="internal" linePragmas="false" #> <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> -<#@ include file="Snippets/Index.tt" once="true" #> /// /// Modify items in a list /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ForeachTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ForeachTemplate.cs index 2815bb4bdc..de779128f5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ForeachTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ForeachTemplate.cs @@ -9,11 +9,7 @@ // ------------------------------------------------------------------------------ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen { - using Microsoft.Agents.AI.Workflows.Declarative.Extensions; - using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; - using Microsoft.Extensions.AI; - using System.Collections.Generic; using System; /// @@ -27,18 +23,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen /// public override string TransformText() { - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); this.Write("\n"); this.Write("\n"); this.Write("\n"); @@ -81,7 +65,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen this._values = [evaluatedValue]; } - await this.ResetAsync(context, null, cancellationToken).ConfigureAwait(false); + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); return default; } @@ -100,9 +84,19 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen AssignVariable(this.Index, "this._index", tightFormat: true); } - this.Write("\n\n this._index++;\n }\n }\n\n public async ValueTask ResetAsy" + - "nc(IWorkflowContext context, object? _, CancellationToken cancellationToken)\n " + - " {"); + this.Write(@" + + this._index++; + } + } + + public async ValueTask CompleteAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + { + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken cancellationToken) + {"); AssignVariable(this.Value, "UnassignedValue.Instance", tightFormat: true); @@ -143,675 +137,6 @@ this.Write("\n "); } -void EvaluateBoolExpression(BoolExpression expression, string targetVariable, bool defaultValue = false) -{ - if (expression is null) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync>("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateEnumExpression( - EnumExpression expression, - string targetVariable, - IDictionary resultMap, - string defaultValue = null, - bool qualifyResult = false, - bool isNullable = false) - where TWrapper : EnumWrapper -{ - string resultType = $"{GetTypeAlias()}{(isNullable ? "?" : "")}"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - resultMap.TryGetValue(expression.LiteralValue, out string resultValue); - if (qualifyResult) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("."); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultValue)); - -this.Write(";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(resultValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateIntExpression(IntExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "int?" : "int"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "0")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateListExpression(ValueExpression expression, string targetVariable) -{ - string typeName = GetTypeAlias(); - if (expression is null) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write("> = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateRecordExpression(ObjectExpression expression, string targetVariable) -{ - string resultTypeName = $"Dictionary()}?>?"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" =\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateStringExpression(StringExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "string?" : "string"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "string.Empty")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - if (expression.LiteralValue.Contains("\n")) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = \n \"\"\"\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write("\n \"\"\";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.LiteralValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - void EvaluateValueExpression(ValueExpression expression, string targetVariable) => EvaluateValueExpression(expression, targetVariable); @@ -921,46 +246,5 @@ this.Write(").ConfigureAwait(false);"); } } - -void EvaluateMessageTemplate(TemplateLine templateLine, string variableName) -{ - if (templateLine is not null) - { -this.Write("\n string "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" =\n await context.FormatTemplateAsync(\n \"\"\""); - - - FormatMessageTemplate(templateLine); -this.Write("\n \"\"\");"); - - - } - else - { -this.Write("\n string? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" = null;"); - - - } -} - -void FormatMessageTemplate(TemplateLine line) -{ - foreach (string text in line.ToTemplateString().ByLine()) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(text)); - - - } -} - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ForeachTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ForeachTemplate.tt index 5e77d26b05..8a9c8d5910 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ForeachTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ForeachTemplate.tt @@ -1,7 +1,9 @@ <#@ template language="C#" inherits="ActionTemplate" visibility="internal" linePragmas="false" #> <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> -<#@ include file="Snippets/Index.tt" once="true" #> +<#@ import namespace="Microsoft.Agents.ObjectModel" #> +<#@ include file="Snippets/AssignVariableTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateValueExpressionTemplate.tt" once="true" #> /// /// Loops over a list assignign the loop variable to "<#= this.Model.Value #>" variable. /// @@ -34,7 +36,7 @@ internal sealed class <#= this.Name #>Executor(FormulaSession session) : ActionE this._values = [evaluatedValue]; } - await this.ResetAsync(context, null, cancellationToken).ConfigureAwait(false); + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); return default; } @@ -57,7 +59,12 @@ internal sealed class <#= this.Name #>Executor(FormulaSession session) : ActionE } } - public async ValueTask ResetAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + public async ValueTask CompleteAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + { + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken cancellationToken) {<# AssignVariable(this.Value, "UnassignedValue.Instance", tightFormat: true); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/InvokeAzureAgentTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/InvokeAzureAgentTemplate.cs index 2f4bbf63d5..beb3634df3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/InvokeAzureAgentTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/InvokeAzureAgentTemplate.cs @@ -1,7 +1,7 @@ // ------------------------------------------------------------------------------ // // This code was generated by a tool. -// Runtime Version: 17.0.0.0 +// Runtime Version: 18.0.0.0 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -9,17 +9,16 @@ // ------------------------------------------------------------------------------ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen { + using System.Collections.Generic; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; - using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; using Microsoft.Extensions.AI; - using System.Collections.Generic; using System; /// /// Class to produce the template output /// - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "18.0.0.0")] internal partial class InvokeAzureAgentTemplate : ActionTemplate { /// @@ -37,17 +36,10 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen this.Write("\n"); this.Write("\n"); this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); this.Write("\n/// \n/// Invokes an agent to process messages and return a response wit" + "hin a conversation context.\n/// \ninternal sealed class "); this.Write(this.ToStringHelper.ToStringWithCulture(this.Name)); - this.Write("Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExec" + + this.Write("Executor(FormulaSession session, ResponseAgentProvider agentProvider) : AgentExec" + "utor(id: \""); this.Write(this.ToStringHelper.ToStringWithCulture(this.Id)); this.Write("\", session, agentProvider)\n{\n // \n protected override async V" + @@ -191,259 +183,6 @@ this.Write(").ConfigureAwait(false);"); } -void EvaluateEnumExpression( - EnumExpression expression, - string targetVariable, - IDictionary resultMap, - string defaultValue = null, - bool qualifyResult = false, - bool isNullable = false) - where TWrapper : EnumWrapper -{ - string resultType = $"{GetTypeAlias()}{(isNullable ? "?" : "")}"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - resultMap.TryGetValue(expression.LiteralValue, out string resultValue); - if (qualifyResult) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("."); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultValue)); - -this.Write(";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(resultValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateIntExpression(IntExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "int?" : "int"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "0")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - void EvaluateListExpression(ValueExpression expression, string targetVariable) { string typeName = GetTypeAlias(); @@ -552,114 +291,6 @@ this.Write(").ConfigureAwait(false);"); } -void EvaluateRecordExpression(ObjectExpression expression, string targetVariable) -{ - string resultTypeName = $"Dictionary()}?>?"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" =\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - void EvaluateStringExpression(StringExpression expression, string targetVariable, bool isNullable = false) { string typeName = isNullable ? "string?" : "string"; @@ -780,156 +411,5 @@ this.Write(").ConfigureAwait(false);"); } } - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) => - EvaluateValueExpression(expression, targetVariable); - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) -{ - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateMessageTemplate(TemplateLine templateLine, string variableName) -{ - if (templateLine is not null) - { -this.Write("\n string "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" =\n await context.FormatTemplateAsync(\n \"\"\""); - - - FormatMessageTemplate(templateLine); -this.Write("\n \"\"\");"); - - - } - else - { -this.Write("\n string? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" = null;"); - - - } -} - -void FormatMessageTemplate(TemplateLine line) -{ - foreach (string text in line.ToTemplateString().ByLine()) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(text)); - - - } -} - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/InvokeAzureAgentTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/InvokeAzureAgentTemplate.tt index b4ca34174d..7c86a2de2f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/InvokeAzureAgentTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/InvokeAzureAgentTemplate.tt @@ -1,11 +1,18 @@ <#@ template language="C#" inherits="ActionTemplate" visibility="internal" linePragmas="false" #> <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> -<#@ include file="Snippets/Index.tt" once="true" #> +<#@ import namespace="System.Collections.Generic" #> +<#@ import namespace="Microsoft.Agents.AI.Workflows.Declarative.Extensions" #> +<#@ import namespace="Microsoft.Agents.ObjectModel" #> +<#@ import namespace="Microsoft.Extensions.AI" #> +<#@ include file="Snippets/AssignVariableTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateBoolExpressionTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateListExpressionTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateStringExpressionTemplate.tt" once="true" #> /// /// Invokes an agent to process messages and return a response within a conversation context. /// -internal sealed class <#= this.Name #>Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExecutor(id: "<#= this.Id #>", session, agentProvider) +internal sealed class <#= this.Name #>Executor(FormulaSession session, ResponseAgentProvider agentProvider) : AgentExecutor(id: "<#= this.Id #>", session, agentProvider) { // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ParseValueTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ParseValueTemplate.cs index a2415bea8f..4572f39973 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ParseValueTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ParseValueTemplate.cs @@ -9,11 +9,7 @@ // ------------------------------------------------------------------------------ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen { - using Microsoft.Agents.AI.Workflows.Declarative.Extensions; - using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; - using Microsoft.Extensions.AI; - using System.Collections.Generic; using System; /// @@ -27,19 +23,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen /// public override string TransformText() { - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); this.Write("\n"); this.Write("\n"); this.Write("\n"); @@ -112,825 +95,5 @@ this.Write("\n "); } } - -void EvaluateBoolExpression(BoolExpression expression, string targetVariable, bool defaultValue = false) -{ - if (expression is null) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync>("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateEnumExpression( - EnumExpression expression, - string targetVariable, - IDictionary resultMap, - string defaultValue = null, - bool qualifyResult = false, - bool isNullable = false) - where TWrapper : EnumWrapper -{ - string resultType = $"{GetTypeAlias()}{(isNullable ? "?" : "")}"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - resultMap.TryGetValue(expression.LiteralValue, out string resultValue); - if (qualifyResult) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("."); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultValue)); - -this.Write(";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(resultValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateIntExpression(IntExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "int?" : "int"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "0")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateListExpression(ValueExpression expression, string targetVariable) -{ - string typeName = GetTypeAlias(); - if (expression is null) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write("> = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateRecordExpression(ObjectExpression expression, string targetVariable) -{ - string resultTypeName = $"Dictionary()}?>?"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" =\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateStringExpression(StringExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "string?" : "string"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "string.Empty")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - if (expression.LiteralValue.Contains("\n")) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = \n \"\"\"\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write("\n \"\"\";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.LiteralValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) => - EvaluateValueExpression(expression, targetVariable); - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) -{ - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateMessageTemplate(TemplateLine templateLine, string variableName) -{ - if (templateLine is not null) - { -this.Write("\n string "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" =\n await context.FormatTemplateAsync(\n \"\"\""); - - - FormatMessageTemplate(templateLine); -this.Write("\n \"\"\");"); - - - } - else - { -this.Write("\n string? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" = null;"); - - - } -} - -void FormatMessageTemplate(TemplateLine line) -{ - foreach (string text in line.ToTemplateString().ByLine()) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(text)); - - - } -} - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ParseValueTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ParseValueTemplate.tt index 5a7c073293..246e1d331b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ParseValueTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ParseValueTemplate.tt @@ -1,7 +1,8 @@ <#@ template language="C#" inherits="ActionTemplate" visibility="internal" linePragmas="false" #> <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> -<#@ include file="Snippets/Index.tt" once="true" #> +<#@ import namespace="Microsoft.Agents.ObjectModel" #> +<#@ include file="Snippets/AssignVariableTemplate.tt" once="true" #> /// /// Parses a string or untyped value to the provided data type. When the input is a string, it will be treated as JSON. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/QuestionTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/QuestionTemplate.cs index cb3cfa89ad..61afa0309a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/QuestionTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/QuestionTemplate.cs @@ -9,11 +9,6 @@ // ------------------------------------------------------------------------------ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen { - using Microsoft.Agents.AI.Workflows.Declarative.Extensions; - using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; - using Microsoft.Agents.ObjectModel; - using Microsoft.Extensions.AI; - using System.Collections.Generic; using System; /// @@ -27,21 +22,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen /// public override string TransformText() { - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); this.Write("\n"); this.Write("\n"); this.Write("\n/// \n/// Request input.\n/// \ninternal sealed class "); @@ -53,853 +33,5 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen " {\n return default;\n }\n}"); return this.GenerationEnvironment.ToString(); } - -void AssignVariable(PropertyPath targetVariable, string valueVariable, bool tightFormat = false) -{ - if (targetVariable is not null) - { -this.Write("\n await context.QueueStateUpdateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(VariableName(targetVariable))); - -this.Write("\", value: "); - -this.Write(this.ToStringHelper.ToStringWithCulture(valueVariable)); - -this.Write(", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(VariableScope(targetVariable))); - -this.Write("\").ConfigureAwait(false);"); - - - if (!tightFormat) - { -this.Write("\n "); - -} - } -} - - -void EvaluateBoolExpression(BoolExpression expression, string targetVariable, bool defaultValue = false) -{ - if (expression is null) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync>("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateEnumExpression( - EnumExpression expression, - string targetVariable, - IDictionary resultMap, - string defaultValue = null, - bool qualifyResult = false, - bool isNullable = false) - where TWrapper : EnumWrapper -{ - string resultType = $"{GetTypeAlias()}{(isNullable ? "?" : "")}"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - resultMap.TryGetValue(expression.LiteralValue, out string resultValue); - if (qualifyResult) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("."); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultValue)); - -this.Write(";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(resultValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateIntExpression(IntExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "int?" : "int"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "0")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateListExpression(ValueExpression expression, string targetVariable) -{ - string typeName = GetTypeAlias(); - if (expression is null) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write("> = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateRecordExpression(ObjectExpression expression, string targetVariable) -{ - string resultTypeName = $"Dictionary()}?>?"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" =\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateStringExpression(StringExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "string?" : "string"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "string.Empty")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - if (expression.LiteralValue.Contains("\n")) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = \n \"\"\"\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write("\n \"\"\";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.LiteralValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) => - EvaluateValueExpression(expression, targetVariable); - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) -{ - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateMessageTemplate(TemplateLine templateLine, string variableName) -{ - if (templateLine is not null) - { -this.Write("\n string "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" =\n await context.FormatTemplateAsync(\n \"\"\""); - - - FormatMessageTemplate(templateLine); -this.Write("\n \"\"\");"); - - - } - else - { -this.Write("\n string? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" = null;"); - - - } -} - -void FormatMessageTemplate(TemplateLine line) -{ - foreach (string text in line.ToTemplateString().ByLine()) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(text)); - - - } -} - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/QuestionTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/QuestionTemplate.tt index 5b98d30c9a..7d3a15af6b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/QuestionTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/QuestionTemplate.tt @@ -1,7 +1,6 @@ <#@ template language="C#" inherits="ActionTemplate" visibility="internal" linePragmas="false" #> <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> -<#@ include file="Snippets/Index.tt" once="true" #> /// /// Request input. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ResetVariableTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ResetVariableTemplate.cs index 597e567d5c..bd56580a7b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ResetVariableTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ResetVariableTemplate.cs @@ -9,11 +9,7 @@ // ------------------------------------------------------------------------------ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen { - using Microsoft.Agents.AI.Workflows.Declarative.Extensions; - using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; - using Microsoft.Extensions.AI; - using System.Collections.Generic; using System; /// @@ -27,19 +23,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen /// public override string TransformText() { - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); this.Write("\n"); this.Write("\n"); this.Write("\n"); @@ -87,825 +70,5 @@ this.Write("\n "); } } - -void EvaluateBoolExpression(BoolExpression expression, string targetVariable, bool defaultValue = false) -{ - if (expression is null) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync>("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateEnumExpression( - EnumExpression expression, - string targetVariable, - IDictionary resultMap, - string defaultValue = null, - bool qualifyResult = false, - bool isNullable = false) - where TWrapper : EnumWrapper -{ - string resultType = $"{GetTypeAlias()}{(isNullable ? "?" : "")}"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - resultMap.TryGetValue(expression.LiteralValue, out string resultValue); - if (qualifyResult) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("."); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultValue)); - -this.Write(";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(resultValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateIntExpression(IntExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "int?" : "int"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "0")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateListExpression(ValueExpression expression, string targetVariable) -{ - string typeName = GetTypeAlias(); - if (expression is null) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write("> = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateRecordExpression(ObjectExpression expression, string targetVariable) -{ - string resultTypeName = $"Dictionary()}?>?"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" =\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateStringExpression(StringExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "string?" : "string"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "string.Empty")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - if (expression.LiteralValue.Contains("\n")) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = \n \"\"\"\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write("\n \"\"\";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.LiteralValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) => - EvaluateValueExpression(expression, targetVariable); - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) -{ - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateMessageTemplate(TemplateLine templateLine, string variableName) -{ - if (templateLine is not null) - { -this.Write("\n string "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" =\n await context.FormatTemplateAsync(\n \"\"\""); - - - FormatMessageTemplate(templateLine); -this.Write("\n \"\"\");"); - - - } - else - { -this.Write("\n string? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" = null;"); - - - } -} - -void FormatMessageTemplate(TemplateLine line) -{ - foreach (string text in line.ToTemplateString().ByLine()) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(text)); - - - } -} - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ResetVariableTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ResetVariableTemplate.tt index 80eb9b4323..0565a2328b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ResetVariableTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ResetVariableTemplate.tt @@ -1,7 +1,8 @@ <#@ template language="C#" inherits="ActionTemplate" visibility="internal" linePragmas="false" #> <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> -<#@ include file="Snippets/Index.tt" once="true" #> +<#@ import namespace="Microsoft.Agents.ObjectModel" #> +<#@ include file="Snippets/AssignVariableTemplate.tt" once="true" #> /// /// Resets the value of the "<#= this.Model.Variable #>" variable, potentially causing re-evaluation /// of the default value, question or action that provides the value to this variable. diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/RetrieveConversationMessageTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/RetrieveConversationMessageTemplate.cs index a2a679e688..4015cffa71 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/RetrieveConversationMessageTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/RetrieveConversationMessageTemplate.cs @@ -9,11 +9,7 @@ // ------------------------------------------------------------------------------ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen { - using Microsoft.Agents.AI.Workflows.Declarative.Extensions; - using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; - using Microsoft.Extensions.AI; - using System.Collections.Generic; using System; /// @@ -27,17 +23,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen /// public override string TransformText() { - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); this.Write("\n"); this.Write("\n"); this.Write("\n"); @@ -47,7 +32,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen this.Write("\n/// \n/// Retrieves a list of messages from an agent conversation.\n/// <" + "/summary>\ninternal sealed class "); this.Write(this.ToStringHelper.ToStringWithCulture(this.Name)); - this.Write("Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExe" + + this.Write("Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExe" + "cutor(id: \""); this.Write(this.ToStringHelper.ToStringWithCulture(this.Id)); this.Write("\", session)\n{\n // \n protected override async ValueTask(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync>("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateEnumExpression( - EnumExpression expression, - string targetVariable, - IDictionary resultMap, - string defaultValue = null, - bool qualifyResult = false, - bool isNullable = false) - where TWrapper : EnumWrapper -{ - string resultType = $"{GetTypeAlias()}{(isNullable ? "?" : "")}"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - resultMap.TryGetValue(expression.LiteralValue, out string resultValue); - if (qualifyResult) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("."); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultValue)); - -this.Write(";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(resultValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateIntExpression(IntExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "int?" : "int"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "0")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateListExpression(ValueExpression expression, string targetVariable) -{ - string typeName = GetTypeAlias(); - if (expression is null) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write("> = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - void EvaluateRecordExpression(ObjectExpression expression, string targetVariable) { string resultTypeName = $"Dictionary()}?>?"; @@ -761,156 +306,5 @@ this.Write(").ConfigureAwait(false);"); } } - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) => - EvaluateValueExpression(expression, targetVariable); - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) -{ - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateMessageTemplate(TemplateLine templateLine, string variableName) -{ - if (templateLine is not null) - { -this.Write("\n string "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" =\n await context.FormatTemplateAsync(\n \"\"\""); - - - FormatMessageTemplate(templateLine); -this.Write("\n \"\"\");"); - - - } - else - { -this.Write("\n string? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" = null;"); - - - } -} - -void FormatMessageTemplate(TemplateLine line) -{ - foreach (string text in line.ToTemplateString().ByLine()) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(text)); - - - } -} - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/RetrieveConversationMessageTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/RetrieveConversationMessageTemplate.tt index e2e3754bda..487a1c54c6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/RetrieveConversationMessageTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/RetrieveConversationMessageTemplate.tt @@ -1,11 +1,14 @@ <#@ template language="C#" inherits="ActionTemplate" visibility="internal" linePragmas="false" #> <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> -<#@ include file="Snippets/Index.tt" once="true" #> +<#@ import namespace="Microsoft.Agents.ObjectModel" #> +<#@ include file="Snippets/AssignVariableTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateRecordExpressionTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateStringExpressionTemplate.tt" once="true" #> /// /// Retrieves a list of messages from an agent conversation. /// -internal sealed class <#= this.Name #>Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session) +internal sealed class <#= this.Name #>Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session) { // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/RetrieveConversationMessagesTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/RetrieveConversationMessagesTemplate.cs index 08fb0a75ec..d9b1ff94dd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/RetrieveConversationMessagesTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/RetrieveConversationMessagesTemplate.cs @@ -1,7 +1,7 @@ // ------------------------------------------------------------------------------ // // This code was generated by a tool. -// Runtime Version: 17.0.0.0 +// Runtime Version: 18.0.0.0 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -9,17 +9,15 @@ // ------------------------------------------------------------------------------ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen { - using Microsoft.Agents.AI.Workflows.Declarative.Extensions; - using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; - using Microsoft.Agents.ObjectModel; - using Microsoft.Extensions.AI; using System.Collections.Generic; + using Microsoft.Agents.AI.Workflows.Declarative.Extensions; + using Microsoft.Agents.ObjectModel; using System; /// /// Class to produce the template output /// - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "18.0.0.0")] internal partial class RetrieveConversationMessagesTemplate : ActionTemplate { /// @@ -37,17 +35,10 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen this.Write("\n"); this.Write("\n"); this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); this.Write("\n/// \n/// Retrieves a specific message from an agent conversation.\n/// <" + "/summary>\ninternal sealed class "); this.Write(this.ToStringHelper.ToStringWithCulture(this.Name)); - this.Write("Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExe" + + this.Write("Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExe" + "cutor(id: \""); this.Write(this.ToStringHelper.ToStringWithCulture(this.Id)); this.Write("\", session)\n{\n // \n protected override async ValueTask(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync>("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - void EvaluateEnumExpression( EnumExpression expression, string targetVariable, @@ -440,114 +352,6 @@ this.Write(").ConfigureAwait(false);"); } -void EvaluateListExpression(ValueExpression expression, string targetVariable) -{ - string typeName = GetTypeAlias(); - if (expression is null) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write("> = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - void EvaluateRecordExpression(ObjectExpression expression, string targetVariable) { string resultTypeName = $"Dictionary()}?>?"; @@ -776,156 +580,5 @@ this.Write(").ConfigureAwait(false);"); } } - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) => - EvaluateValueExpression(expression, targetVariable); - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) -{ - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateMessageTemplate(TemplateLine templateLine, string variableName) -{ - if (templateLine is not null) - { -this.Write("\n string "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" =\n await context.FormatTemplateAsync(\n \"\"\""); - - - FormatMessageTemplate(templateLine); -this.Write("\n \"\"\");"); - - - } - else - { -this.Write("\n string? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" = null;"); - - - } -} - -void FormatMessageTemplate(TemplateLine line) -{ - foreach (string text in line.ToTemplateString().ByLine()) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(text)); - - - } -} - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/RetrieveConversationMessagesTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/RetrieveConversationMessagesTemplate.tt index 96aec79282..d36a29310d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/RetrieveConversationMessagesTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/RetrieveConversationMessagesTemplate.tt @@ -1,11 +1,18 @@ <#@ template language="C#" inherits="ActionTemplate" visibility="internal" linePragmas="false" #> <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> -<#@ include file="Snippets/Index.tt" once="true" #> +<#@ import namespace="System.Collections.Generic" #> +<#@ import namespace="Microsoft.Agents.AI.Workflows.Declarative.Extensions" #> +<#@ import namespace="Microsoft.Agents.ObjectModel" #> +<#@ include file="Snippets/AssignVariableTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateEnumExpressionTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateIntExpressionTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateRecordExpressionTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateStringExpressionTemplate.tt" once="true" #> /// /// Retrieves a specific message from an agent conversation. /// -internal sealed class <#= this.Name #>Executor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session) +internal sealed class <#= this.Name #>Executor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "<#= this.Id #>", session) { // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SendActivityTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SendActivityTemplate.cs index 509127bdf4..cce0854bff 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SendActivityTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SendActivityTemplate.cs @@ -10,10 +10,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen { using Microsoft.Agents.AI.Workflows.Declarative.Extensions; - using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; - using Microsoft.Extensions.AI; - using System.Collections.Generic; using System; /// @@ -27,18 +24,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen /// public override string TransformText() { - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); this.Write("\n"); this.Write("\n"); this.Write("\n"); @@ -71,822 +56,15 @@ if (this.Model.Activity is MessageActivityTemplate messageActivity) } - this.Write("\n );\n AgentResponse response = new([new ChatMessage(ChatRole" + - ".Assistant, activityText)]);\n await context.AddEventAsync(new AgentRes" + - "ponseEvent(this.Id, response)).ConfigureAwait(false);"); + this.Write("\n );\n AgentResponse response = new([new ChatMessage(ChatRole.As" + + "sistant, activityText)]);\n await context.AddEventAsync(new AgentResponseE" + + "vent(this.Id, response)).ConfigureAwait(false);"); } this.Write("\n\n return default;\n }\n}"); return this.GenerationEnvironment.ToString(); } -void AssignVariable(PropertyPath targetVariable, string valueVariable, bool tightFormat = false) -{ - if (targetVariable is not null) - { -this.Write("\n await context.QueueStateUpdateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(VariableName(targetVariable))); - -this.Write("\", value: "); - -this.Write(this.ToStringHelper.ToStringWithCulture(valueVariable)); - -this.Write(", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(VariableScope(targetVariable))); - -this.Write("\").ConfigureAwait(false);"); - - - if (!tightFormat) - { -this.Write("\n "); - -} - } -} - - -void EvaluateBoolExpression(BoolExpression expression, string targetVariable, bool defaultValue = false) -{ - if (expression is null) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync>("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateEnumExpression( - EnumExpression expression, - string targetVariable, - IDictionary resultMap, - string defaultValue = null, - bool qualifyResult = false, - bool isNullable = false) - where TWrapper : EnumWrapper -{ - string resultType = $"{GetTypeAlias()}{(isNullable ? "?" : "")}"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - resultMap.TryGetValue(expression.LiteralValue, out string resultValue); - if (qualifyResult) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("."); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultValue)); - -this.Write(";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(resultValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateIntExpression(IntExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "int?" : "int"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "0")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateListExpression(ValueExpression expression, string targetVariable) -{ - string typeName = GetTypeAlias(); - if (expression is null) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write("> = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateRecordExpression(ObjectExpression expression, string targetVariable) -{ - string resultTypeName = $"Dictionary()}?>?"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" =\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateStringExpression(StringExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "string?" : "string"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "string.Empty")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - if (expression.LiteralValue.Contains("\n")) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = \n \"\"\"\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write("\n \"\"\";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.LiteralValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) => - EvaluateValueExpression(expression, targetVariable); - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) -{ - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - void EvaluateMessageTemplate(TemplateLine templateLine, string variableName) { if (templateLine is not null) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SendActivityTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SendActivityTemplate.tt index f11d2181b4..283ac57e1b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SendActivityTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SendActivityTemplate.tt @@ -1,7 +1,9 @@ <#@ template language="C#" inherits="ActionTemplate" visibility="internal" linePragmas="false" #> <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> -<#@ include file="Snippets/Index.tt" once="true" #> +<#@ import namespace="Microsoft.Agents.AI.Workflows.Declarative.Extensions" #> +<#@ import namespace="Microsoft.Agents.ObjectModel" #> +<#@ include file="Snippets/FormatMessageTemplate.tt" once="true" #> /// /// Formats a message template and sends an activity event. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetMultipleVariablesTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetMultipleVariablesTemplate.cs index 65e0c71e99..52e86a1f65 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetMultipleVariablesTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetMultipleVariablesTemplate.cs @@ -9,11 +9,7 @@ // ------------------------------------------------------------------------------ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen { - using Microsoft.Agents.AI.Workflows.Declarative.Extensions; - using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; - using Microsoft.Extensions.AI; - using System.Collections.Generic; using System; /// @@ -27,18 +23,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen /// public override string TransformText() { - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); this.Write("\n"); this.Write("\n"); this.Write("\n"); @@ -98,675 +82,6 @@ this.Write("\n "); } -void EvaluateBoolExpression(BoolExpression expression, string targetVariable, bool defaultValue = false) -{ - if (expression is null) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync>("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateEnumExpression( - EnumExpression expression, - string targetVariable, - IDictionary resultMap, - string defaultValue = null, - bool qualifyResult = false, - bool isNullable = false) - where TWrapper : EnumWrapper -{ - string resultType = $"{GetTypeAlias()}{(isNullable ? "?" : "")}"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - resultMap.TryGetValue(expression.LiteralValue, out string resultValue); - if (qualifyResult) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("."); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultValue)); - -this.Write(";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(resultValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateIntExpression(IntExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "int?" : "int"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "0")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateListExpression(ValueExpression expression, string targetVariable) -{ - string typeName = GetTypeAlias(); - if (expression is null) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write("> = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateRecordExpression(ObjectExpression expression, string targetVariable) -{ - string resultTypeName = $"Dictionary()}?>?"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" =\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateStringExpression(StringExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "string?" : "string"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "string.Empty")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - if (expression.LiteralValue.Contains("\n")) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = \n \"\"\"\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write("\n \"\"\";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.LiteralValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - void EvaluateValueExpression(ValueExpression expression, string targetVariable) => EvaluateValueExpression(expression, targetVariable); @@ -876,46 +191,5 @@ this.Write(").ConfigureAwait(false);"); } } - -void EvaluateMessageTemplate(TemplateLine templateLine, string variableName) -{ - if (templateLine is not null) - { -this.Write("\n string "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" =\n await context.FormatTemplateAsync(\n \"\"\""); - - - FormatMessageTemplate(templateLine); -this.Write("\n \"\"\");"); - - - } - else - { -this.Write("\n string? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" = null;"); - - - } -} - -void FormatMessageTemplate(TemplateLine line) -{ - foreach (string text in line.ToTemplateString().ByLine()) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(text)); - - - } -} - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetMultipleVariablesTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetMultipleVariablesTemplate.tt index 3746488c27..44b697eb2f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetMultipleVariablesTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetMultipleVariablesTemplate.tt @@ -1,7 +1,9 @@ <#@ template language="C#" inherits="ActionTemplate" visibility="internal" linePragmas="false" #> <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> -<#@ include file="Snippets/Index.tt" once="true" #> +<#@ import namespace="Microsoft.Agents.ObjectModel" #> +<#@ include file="Snippets/AssignVariableTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateValueExpressionTemplate.tt" once="true" #> /// /// Assigns an evaluated expression, other variable, or literal value to one or more variables. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetTextVariableTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetTextVariableTemplate.cs index a8ac685649..81a9ee28bf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetTextVariableTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetTextVariableTemplate.cs @@ -10,10 +10,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen { using Microsoft.Agents.AI.Workflows.Declarative.Extensions; - using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; - using Microsoft.Extensions.AI; - using System.Collections.Generic; using System; /// @@ -27,17 +24,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen /// public override string TransformText() { - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); this.Write("\n"); this.Write("\n"); this.Write("\n"); @@ -87,785 +73,6 @@ this.Write("\n "); } -void EvaluateBoolExpression(BoolExpression expression, string targetVariable, bool defaultValue = false) -{ - if (expression is null) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync>("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateEnumExpression( - EnumExpression expression, - string targetVariable, - IDictionary resultMap, - string defaultValue = null, - bool qualifyResult = false, - bool isNullable = false) - where TWrapper : EnumWrapper -{ - string resultType = $"{GetTypeAlias()}{(isNullable ? "?" : "")}"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - resultMap.TryGetValue(expression.LiteralValue, out string resultValue); - if (qualifyResult) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("."); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultValue)); - -this.Write(";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(resultValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateIntExpression(IntExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "int?" : "int"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "0")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateListExpression(ValueExpression expression, string targetVariable) -{ - string typeName = GetTypeAlias(); - if (expression is null) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write("> = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateRecordExpression(ObjectExpression expression, string targetVariable) -{ - string resultTypeName = $"Dictionary()}?>?"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" =\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateStringExpression(StringExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "string?" : "string"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "string.Empty")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - if (expression.LiteralValue.Contains("\n")) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = \n \"\"\"\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write("\n \"\"\";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.LiteralValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) => - EvaluateValueExpression(expression, targetVariable); - -void EvaluateValueExpression(ValueExpression expression, string targetVariable) -{ - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - void EvaluateMessageTemplate(TemplateLine templateLine, string variableName) { if (templateLine is not null) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetTextVariableTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetTextVariableTemplate.tt index fc5996e20a..1d16ee92ea 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetTextVariableTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetTextVariableTemplate.tt @@ -1,7 +1,10 @@ <#@ template language="C#" inherits="ActionTemplate" visibility="internal" linePragmas="false" #> <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> -<#@ include file="Snippets/Index.tt" once="true" #> +<#@ import namespace="Microsoft.Agents.AI.Workflows.Declarative.Extensions" #> +<#@ import namespace="Microsoft.Agents.ObjectModel" #> +<#@ include file="Snippets/AssignVariableTemplate.tt" once="true" #> +<#@ include file="Snippets/FormatMessageTemplate.tt" once="true" #> /// /// Assigns an evaluated message template to the "<#= this.Model.Variable #>" variable. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetVariableTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetVariableTemplate.cs index 5d2e08b3e4..972fc1e5c9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetVariableTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetVariableTemplate.cs @@ -9,11 +9,7 @@ // ------------------------------------------------------------------------------ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen { - using Microsoft.Agents.AI.Workflows.Declarative.Extensions; - using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; - using Microsoft.Extensions.AI; - using System.Collections.Generic; using System; /// @@ -27,18 +23,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen /// public override string TransformText() { - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); - this.Write("\n"); this.Write("\n"); this.Write("\n"); this.Write("\n"); @@ -89,675 +73,6 @@ this.Write("\n "); } -void EvaluateBoolExpression(BoolExpression expression, string targetVariable, bool defaultValue = false) -{ - if (expression is null) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatBoolValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync>("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n bool "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateEnumExpression( - EnumExpression expression, - string targetVariable, - IDictionary resultMap, - string defaultValue = null, - bool qualifyResult = false, - bool isNullable = false) - where TWrapper : EnumWrapper -{ - string resultType = $"{GetTypeAlias()}{(isNullable ? "?" : "")}"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(defaultValue))); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - resultMap.TryGetValue(expression.LiteralValue, out string resultValue); - if (qualifyResult) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write("."); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultValue)); - -this.Write(";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatValue(resultValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultType)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateIntExpression(IntExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "int?" : "int"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "0")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateListExpression(ValueExpression expression, string targetVariable) -{ - string typeName = GetTypeAlias(); - if (expression is null) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(GetTypeAlias())); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write("> = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n IList<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateListAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateRecordExpression(ObjectExpression expression, string targetVariable) -{ - string resultTypeName = $"Dictionary()}?>?"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = null;"); - - - } - else if (expression.IsLiteral) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" =\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatDataValue(expression.LiteralValue))); - -this.Write(";"); - - - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write("? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateExpressionAsync<"); - -this.Write(this.ToStringHelper.ToStringWithCulture(resultTypeName)); - -this.Write(">("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - -void EvaluateStringExpression(StringExpression expression, string targetVariable, bool isNullable = false) -{ - string typeName = isNullable ? "string?" : "string"; - if (expression is null) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(isNullable ? "null" : "string.Empty")); - -this.Write(";"); - - - } - else if (expression.IsLiteral) - { - if (expression.LiteralValue.Contains("\n")) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = \n \"\"\"\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.LiteralValue)); - -this.Write("\n \"\"\";"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = "); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.LiteralValue))); - -this.Write(";"); - - - } - } - else if (expression.IsVariableReference && expression.VariableReference.SegmentCount == 2) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.ReadStateAsync(key: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.VariableName)); - -this.Write("\", scopeName: \""); - -this.Write(this.ToStringHelper.ToStringWithCulture(expression.VariableReference.NamespaceAlias)); - -this.Write("\").ConfigureAwait(false);"); - - - } - else if (expression.IsVariableReference) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.VariableReference.ToString()))); - -this.Write(").ConfigureAwait(false);"); - - - } - else - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(typeName)); - -this.Write(" "); - -this.Write(this.ToStringHelper.ToStringWithCulture(targetVariable)); - -this.Write(" = await context.EvaluateValueAsync("); - -this.Write(this.ToStringHelper.ToStringWithCulture(FormatStringValue(expression.ExpressionText))); - -this.Write(").ConfigureAwait(false);"); - - - } -} - - void EvaluateValueExpression(ValueExpression expression, string targetVariable) => EvaluateValueExpression(expression, targetVariable); @@ -867,46 +182,5 @@ this.Write(").ConfigureAwait(false);"); } } - -void EvaluateMessageTemplate(TemplateLine templateLine, string variableName) -{ - if (templateLine is not null) - { -this.Write("\n string "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" =\n await context.FormatTemplateAsync(\n \"\"\""); - - - FormatMessageTemplate(templateLine); -this.Write("\n \"\"\");"); - - - } - else - { -this.Write("\n string? "); - -this.Write(this.ToStringHelper.ToStringWithCulture(variableName)); - -this.Write(" = null;"); - - - } -} - -void FormatMessageTemplate(TemplateLine line) -{ - foreach (string text in line.ToTemplateString().ByLine()) - { -this.Write("\n "); - -this.Write(this.ToStringHelper.ToStringWithCulture(text)); - - - } -} - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetVariableTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetVariableTemplate.tt index 42e12c1f99..2adbb1922c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetVariableTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/SetVariableTemplate.tt @@ -1,7 +1,9 @@ <#@ template language="C#" inherits="ActionTemplate" visibility="internal" linePragmas="false" #> <#@ output extension=".cs" #> <#@ assembly name="System.Core" #> -<#@ include file="Snippets/Index.tt" once="true" #> +<#@ import namespace="Microsoft.Agents.ObjectModel" #> +<#@ include file="Snippets/AssignVariableTemplate.tt" once="true" #> +<#@ include file="Snippets/EvaluateValueExpressionTemplate.tt" once="true" #> /// /// Assigns an evaluated expression, other variable, or literal value to the "<#= this.Model.Variable #>" variable. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/Snippets/Index.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/Snippets/Index.tt deleted file mode 100644 index 7b4e68deb3..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/Snippets/Index.tt +++ /dev/null @@ -1,14 +0,0 @@ -<#@ import namespace="Microsoft.Agents.AI.Workflows.Declarative.Extensions" #> -<#@ import namespace="Microsoft.Agents.AI.Workflows.Declarative.ObjectModel" #> -<#@ import namespace="Microsoft.Agents.ObjectModel" #> -<#@ import namespace="Microsoft.Extensions.AI" #> -<#@ import namespace="System.Collections.Generic" #> -<#@ include file="AssignVariableTemplate.tt" once="true" #> -<#@ include file="EvaluateBoolExpressionTemplate.tt" once="true" #> -<#@ include file="EvaluateEnumExpressionTemplate.tt" once="true" #> -<#@ include file="EvaluateIntExpressionTemplate.tt" once="true" #> -<#@ include file="EvaluateListExpressionTemplate.tt" once="true" #> -<#@ include file="EvaluateRecordExpressionTemplate.tt" once="true" #> -<#@ include file="EvaluateStringExpressionTemplate.tt" once="true" #> -<#@ include file="EvaluateValueExpressionTemplate.tt" once="true" #> -<#@ include file="FormatMessageTemplate.tt" once="true" #> diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs index ab7794f1b8..c4808f9311 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs @@ -13,12 +13,12 @@ namespace Microsoft.Agents.AI.Workflows.Declarative; /// /// Configuration options for workflow execution. /// -public sealed class DeclarativeWorkflowOptions(WorkflowAgentProvider agentProvider) +public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvider) { /// /// Defines the agent provider. /// - public WorkflowAgentProvider AgentProvider { get; } = Throw.IfNull(agentProvider); + public ResponseAgentProvider AgentProvider { get; } = Throw.IfNull(agentProvider); /// /// Defines the configuration settings for the workflow. diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs index 19dd4aae75..714ce4747d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs @@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions; internal static class AgentProviderExtensions { public static async ValueTask InvokeAgentAsync( - this WorkflowAgentProvider agentProvider, + this ResponseAgentProvider agentProvider, string executorId, IWorkflowContext context, string agentName, diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index a90b1bd9c9..3ecb91ea3a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -201,7 +201,7 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor { // Transition to end of inner actions string endActionsId = ForeachExecutor.Steps.End(action.Id); - this.ContinueWith(new DelegateActionExecutor(endActionsId, this._workflowState, action.ResetAsync), action.Id); + this.ContinueWith(new DelegateActionExecutor(endActionsId, this._workflowState, action.CompleteAsync), action.Id); // Transition to select the next item this._workflowModel.AddLink(endActionsId, loopId); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs index 065f04aaac..56901762f4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs @@ -163,7 +163,7 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor { // Transition to end of inner actions string endActionsId = ForeachExecutor.Steps.End(action.Id); // Loop continuation - this.ContinueWith(new EmptyTemplate(endActionsId, this._rootId, $"{action.Id.FormatName()}.{nameof(ForeachExecutor.ResetAsync)}"), action.Id); + this.ContinueWith(new EmptyTemplate(endActionsId, this._rootId, $"{action.Id.FormatName()}.{nameof(ForeachExecutor.CompleteAsync)}"), action.Id); // Transition to select the next item this._workflowModel.AddLink(endActionsId, loopId); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs index 9545b9d1ff..aea534a258 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs @@ -14,10 +14,10 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Kit; /// The executor id /// Session to support formula expressions. /// Provider for accessing and manipulating agents and conversations. -public abstract class AgentExecutor(string id, FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id, session) +public abstract class AgentExecutor(string id, FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id, session) { /// - /// Invokes an agent using the provided . + /// Invokes an agent using the provided . /// /// The workflow execution context providing messaging and state services. /// The name or identifier of the agent. diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs index 4439207701..641ecc78a0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs @@ -18,7 +18,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Kit; public abstract class RootExecutor : Executor, IResettableExecutor where TInput : notnull { private readonly IConfiguration? _configuration; - private readonly WorkflowAgentProvider _agentProvider; + private readonly ResponseAgentProvider _agentProvider; private readonly WorkflowFormulaState _state; private readonly Func? _inputTransform; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs index e1514f2240..21c14de546 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/AddConversationMessageExecutor.cs @@ -12,12 +12,14 @@ using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; -internal sealed class AddConversationMessageExecutor(AddConversationMessage model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) : +internal sealed class AddConversationMessageExecutor(AddConversationMessage model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) : DeclarativeActionExecutor(model, state) { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { + Throw.IfNull(this.Model.Message); Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}"); + string conversationId = this.Evaluator.GetValue(this.Model.ConversationId).Value; bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? _); @@ -26,7 +28,7 @@ internal sealed class AddConversationMessageExecutor(AddConversationMessage mode // Capture the created message, which includes the assigned ID. newMessage = await agentProvider.CreateMessageAsync(conversationId, newMessage, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(this.Model.Message?.Path, newMessage.ToRecord(), context).ConfigureAwait(false); + await this.AssignAsync(this.Model.Message.Path, newMessage.ToRecord(), context).ConfigureAwait(false); if (isWorkflowConversation) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs index 834497bbce..a0171488e0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs @@ -23,7 +23,7 @@ internal sealed class ClearAllVariablesExecutor(ClearAllVariables model, Workflo VariablesToClear.ConversationScopedVariables => WorkflowFormulaState.DefaultScopeName, VariablesToClear.ConversationHistory => null, VariablesToClear.UserScopedVariables => null, - _ => null + _ => null, }; if (scope is not null) 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..381abcb84d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs @@ -13,7 +13,7 @@ using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; -internal sealed class CopyConversationMessagesExecutor(CopyConversationMessages model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) : +internal sealed class CopyConversationMessagesExecutor(CopyConversationMessages model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) : DeclarativeActionExecutor(model, state) { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) @@ -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/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CreateConversationExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CreateConversationExecutor.cs index 5b1459d323..f4af4d447e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CreateConversationExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CreateConversationExecutor.cs @@ -7,16 +7,19 @@ using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; -internal sealed class CreateConversationExecutor(CreateConversation model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) : +internal sealed class CreateConversationExecutor(CreateConversation model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) : DeclarativeActionExecutor(model, state) { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { + Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}"); + string conversationId = await agentProvider.CreateConversationAsync(cancellationToken).ConfigureAwait(false); - await this.AssignAsync(this.Model.ConversationId?.Path, FormulaValue.New(conversationId), context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ConversationId.Path, FormulaValue.New(conversationId), context).ConfigureAwait(false); await context.QueueConversationUpdateAsync(conversationId, cancellationToken).ConfigureAwait(false); return default; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs index fff160c97b..b06a5ebd36 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs @@ -18,12 +18,12 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { - PropertyPath variablePath = Throw.IfNull(this.Model.ItemsVariable?.Path, $"{nameof(this.Model)}.{nameof(this.Model.ItemsVariable)}"); + Throw.IfNull(this.Model.ItemsVariable, $"{nameof(this.Model)}.{nameof(this.Model.ItemsVariable)}"); - FormulaValue table = context.ReadState(variablePath); + FormulaValue table = context.ReadState(this.Model.ItemsVariable); if (table is not TableValue tableValue) { - throw this.Exception($"Require '{variablePath}' to be a table, not: '{table.GetType().Name}'."); + throw this.Exception($"Require '{this.Model.ItemsVariable.Path}' to be a table, not: '{table.GetType().Name}'."); } EditTableOperation? changeType = this.Model.ChangeType; @@ -33,12 +33,12 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat EvaluationResult expressionResult = this.Evaluator.GetValue(addItemValue); RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), expressionResult.Value.ToFormula()); await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, newRecord, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, newRecord, context).ConfigureAwait(false); } else if (changeType is ClearItemsOperation) { await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, FormulaValue.NewBlank(), context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, FormulaValue.NewBlank(), context).ConfigureAwait(false); } else if (changeType is RemoveItemOperation removeItemOperation) { @@ -46,8 +46,8 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat EvaluationResult expressionResult = this.Evaluator.GetValue(removeItemValue); if (expressionResult.Value.ToFormula() is TableValue removeItemTable) { - await tableValue.RemoveAsync(removeItemTable?.Rows.Select(row => row.Value), all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, FormulaValue.NewBlank(), context).ConfigureAwait(false); + await tableValue.RemoveAsync(removeItemTable.Rows.Select(row => row.Value), all: true, cancellationToken).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, FormulaValue.NewBlank(), context).ConfigureAwait(false); } } else if (changeType is TakeLastItemOperation) @@ -56,7 +56,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat if (lastRow is not null) { await tableValue.RemoveAsync([lastRow], all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, lastRow, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, lastRow, context).ConfigureAwait(false); } } else if (changeType is TakeFirstItemOperation) @@ -65,7 +65,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat if (firstRow is not null) { await tableValue.RemoveAsync([firstRow], all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, firstRow, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, firstRow, context).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs index 2b4c21c86b..258f3c413b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs @@ -37,27 +37,21 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { + Throw.IfNull(this.Model.Items, $"{nameof(this.Model)}.{nameof(this.Model.Items)}"); + this._index = 0; - if (this.Model.Items is null) + EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Items); + if (expressionResult.Value is TableDataValue tableValue) { - this._values = []; - this.HasValue = false; + this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormula())]; } else { - EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Items); - if (expressionResult.Value is TableDataValue tableValue) - { - this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormula())]; - } - else - { - this._values = [expressionResult.Value.ToFormula()]; - } + this._values = [expressionResult.Value.ToFormula()]; } - await this.ResetAsync(context, null, cancellationToken).ConfigureAwait(false); + await this.ResetStateAsync(context, cancellationToken).ConfigureAwait(false); return default; } @@ -79,19 +73,24 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor } } - public async ValueTask ResetAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + public async ValueTask CompleteAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) { try { - await context.QueueStateResetAsync(Throw.IfNull(this.Model.Value), cancellationToken).ConfigureAwait(false); - if (this.Model.Index is not null) - { - await context.QueueStateResetAsync(this.Model.Index, cancellationToken).ConfigureAwait(false); - } + await this.ResetStateAsync(context, cancellationToken).ConfigureAwait(false); } finally { await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); } } + + private async Task ResetStateAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + await context.QueueStateResetAsync(Throw.IfNull(this.Model.Value), cancellationToken).ConfigureAwait(false); + if (this.Model.Index is not null) + { + await context.QueueStateResetAsync(this.Model.Index, cancellationToken).ConfigureAwait(false); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index 0e03b45bc1..0a02771c3e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -17,7 +17,7 @@ using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; -internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) : +internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) : DeclarativeActionExecutor(model, state) { public static class Steps diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs index fa201527eb..57fe319aaf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs @@ -19,24 +19,18 @@ internal sealed class ParseValueExecutor(ParseValue model, WorkflowFormulaState { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { - PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); + Throw.IfNull(this.Model.ValueType, $"{nameof(this.Model)}.{nameof(model.ValueType)}"); + Throw.IfNull(this.Model.Variable, $"{nameof(this.Model)}.{nameof(model.Variable)}"); ValueExpression valueExpression = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); EvaluationResult expressionResult = this.Evaluator.GetValue(valueExpression); FormulaValue parsedValue; - if (this.Model.ValueType is not null) - { - VariableType targetType = new(this.Model.ValueType); - object? parsedResult = expressionResult.Value.ToObject().ConvertType(targetType); - parsedValue = parsedResult.ToFormula(); - } - else - { - parsedValue = expressionResult.Value.ToFormula(); - } + VariableType targetType = new(this.Model.ValueType); + object? parsedResult = expressionResult.Value.ToObject().ConvertType(targetType); + parsedValue = parsedResult.ToFormula(); - await this.AssignAsync(variablePath, parsedValue, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.Variable.Path, parsedValue, context).ConfigureAwait(false); return default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs index 5592b7e7fe..07013e6570 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs @@ -16,7 +16,7 @@ using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; -internal sealed class QuestionExecutor(Question model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) : +internal sealed class QuestionExecutor(Question model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) : DeclarativeActionExecutor(model, state) { public static class Steps diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RequestExternalInputExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RequestExternalInputExecutor.cs index 16635149c6..a0a39790bc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RequestExternalInputExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RequestExternalInputExecutor.cs @@ -12,7 +12,7 @@ using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; -internal sealed class RequestExternalInputExecutor(RequestExternalInput model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) +internal sealed class RequestExternalInputExecutor(RequestExternalInput model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) : DeclarativeActionExecutor(model, state) { public static class Steps diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs index a2b9ee22af..4c6b4e340b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs @@ -17,6 +17,7 @@ internal sealed class ResetVariableExecutor(ResetVariable model, WorkflowFormula protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { Throw.IfNull(this.Model.Variable, $"{nameof(this.Model)}.{nameof(model.Variable)}"); + await context.QueueStateResetAsync(this.Model.Variable, cancellationToken).ConfigureAwait(false); Debug.WriteLine( $""" diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RetrieveConversationMessageExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RetrieveConversationMessageExecutor.cs index 466e3f2ff4..05b950f14c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RetrieveConversationMessageExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RetrieveConversationMessageExecutor.cs @@ -11,18 +11,20 @@ using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; -internal sealed class RetrieveConversationMessageExecutor(RetrieveConversationMessage model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) : +internal sealed class RetrieveConversationMessageExecutor(RetrieveConversationMessage model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) : DeclarativeActionExecutor(model, state) { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { + Throw.IfNull(this.Model.Message); Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}"); + string conversationId = this.Evaluator.GetValue(this.Model.ConversationId).Value; string messageId = this.Evaluator.GetValue(Throw.IfNull(this.Model.MessageId, $"{nameof(this.Model)}.{nameof(this.Model.MessageId)}")).Value; ChatMessage message = await agentProvider.GetMessageAsync(conversationId, messageId, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(this.Model.Message?.Path, message.ToRecord(), context).ConfigureAwait(false); + await this.AssignAsync(this.Model.Message.Path, message.ToRecord(), context).ConfigureAwait(false); return default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RetrieveConversationMessagesExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RetrieveConversationMessagesExecutor.cs index 8b41fd451b..a790e78f63 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RetrieveConversationMessagesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RetrieveConversationMessagesExecutor.cs @@ -13,16 +13,18 @@ using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; -internal sealed class RetrieveConversationMessagesExecutor(RetrieveConversationMessages model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) : +internal sealed class RetrieveConversationMessagesExecutor(RetrieveConversationMessages model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) : DeclarativeActionExecutor(model, state) { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { + Throw.IfNull(this.Model.Messages); Throw.IfNull(this.Model.ConversationId, $"{nameof(this.Model)}.{nameof(this.Model.ConversationId)}"); + string conversationId = this.Evaluator.GetValue(this.Model.ConversationId).Value; List messages = []; - await foreach (var m in agentProvider.GetMessagesAsync( + await foreach (ChatMessage message in agentProvider.GetMessagesAsync( conversationId, limit: this.GetLimit(), after: this.GetMessage(this.Model.MessageAfter), @@ -30,21 +32,16 @@ internal sealed class RetrieveConversationMessagesExecutor(RetrieveConversationM newestFirst: this.IsDescending(), cancellationToken).ConfigureAwait(false)) { - messages.Add(m); + messages.Add(message); } - await this.AssignAsync(this.Model.Messages?.Path, messages.ToTable(), context).ConfigureAwait(false); + await this.AssignAsync(this.Model.Messages.Path, messages.ToTable(), context).ConfigureAwait(false); return default; } private int? GetLimit() { - if (this.Model.Limit is null) - { - return null; - } - long limit = this.Evaluator.GetValue(this.Model.Limit).Value; return Convert.ToInt32(Math.Min(limit, 100)); } @@ -61,11 +58,6 @@ internal sealed class RetrieveConversationMessagesExecutor(RetrieveConversationM private bool IsDescending() { - if (this.Model.SortOrder is null) - { - return false; - } - AgentMessageSortOrderWrapper sortOrderWrapper = this.Evaluator.GetValue(this.Model.SortOrder).Value; return sortOrderWrapper.Value == AgentMessageSortOrder.NewestFirst; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs index 44cd9baf16..37b8d43e8a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs @@ -7,6 +7,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; @@ -15,16 +16,12 @@ internal sealed class SetTextVariableExecutor(SetTextVariable model, WorkflowFor { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { - if (this.Model.Value is null) - { - await this.AssignAsync(this.Model.Variable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); - } - else - { - FormulaValue expressionResult = FormulaValue.New(this.Engine.Format(this.Model.Value)); + Throw.IfNull(this.Model.Variable); + Throw.IfNull(this.Model.Value); - await this.AssignAsync(this.Model.Variable?.Path, expressionResult, context).ConfigureAwait(false); - } + FormulaValue expressionResult = FormulaValue.New(this.Engine.Format(this.Model.Value)); + + await this.AssignAsync(this.Model.Variable.Path, expressionResult, context).ConfigureAwait(false); return default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs index 64d01de0eb..6fd4002df5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs @@ -7,7 +7,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; using Microsoft.Agents.ObjectModel.Abstractions; -using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; @@ -16,16 +16,12 @@ internal sealed class SetVariableExecutor(SetVariable model, WorkflowFormulaStat { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { - if (this.Model.Value is null) - { - await this.AssignAsync(this.Model.Variable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); - } - else - { - EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Value); + Throw.IfNull(this.Model.Variable); + Throw.IfNull(this.Model.Value); - await this.AssignAsync(this.Model.Variable?.Path, expressionResult.Value.ToFormula(), context).ConfigureAwait(false); - } + EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Value); + + await this.AssignAsync(this.Model.Variable.Path, expressionResult.Value.ToFormula(), context).ConfigureAwait(false); return default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ResponseAgentProvider.cs similarity index 92% rename from dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ResponseAgentProvider.cs index cfd75d1ca4..b2800ae7ff 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ResponseAgentProvider.cs @@ -11,9 +11,15 @@ using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Declarative; /// -/// Base class for workflow agent providers. +/// Defines contract used by declarative workflow actions to invoke and manipulate agents and conversations. /// -public abstract class WorkflowAgentProvider +/// +/// The shape of this provider contract is very much opinionated around patterns that exist in the Open AI Responses API. +/// In addition to direct usage of the Responses API, Foundry V2 agents are supported as they are fundamentally based on +/// the Open AI Responses API. Using other or patterns that are not +/// based on the Response API is currently not supported. +/// +public abstract class ResponseAgentProvider { /// /// Gets or sets a collection of additional tools an agent is able to automatically invoke. diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs index a6a69f258f..1a6a55dd3e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs @@ -13,7 +13,13 @@ internal sealed class JsonMarshaller : IWireMarshaller public JsonMarshaller(JsonSerializerOptions? serializerOptions = null) { - this._internalOptions = new JsonSerializerOptions(WorkflowsJsonUtilities.DefaultOptions); + this._internalOptions = new JsonSerializerOptions(WorkflowsJsonUtilities.DefaultOptions) + { + // Propagate from the user-provided options if set; enables support for databases + // like PostgreSQL jsonb that do not preserve property order. + AllowOutOfOrderMetadataProperties = serializerOptions?.AllowOutOfOrderMetadataProperties is true, + }; + this._internalOptions.Converters.Add(new PortableValueConverter(this)); this._internalOptions.Converters.Add(new ExecutorIdentityConverter()); this._internalOptions.Converters.Add(new ScopeKeyConverter()); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs index 97c493d045..6ec9c4dccb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs @@ -101,7 +101,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { - JsonElement? sessionState = this._session is not null ? this._agent.SerializeSession(this._session) : null; + JsonElement? sessionState = this._session is not null ? await this._agent.SerializeSessionAsync(this._session, cancellationToken: cancellationToken).ConfigureAwait(false) : null; AIAgentHostState state = new(sessionState, this._currentTurnEmitEvents); Task coreStateTask = context.QueueStateUpdateAsync(AIAgentHostStateKey, state, cancellationToken: cancellationToken).AsTask(); Task userInputRequestsTask = this._userInputHandler?.OnCheckpointingAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs index 66f71a219a..c08ba5c3f4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs @@ -68,7 +68,7 @@ internal sealed class WorkflowHostAgent : AIAgent protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => new(new WorkflowSession(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails, this._includeWorkflowOutputsInResponse)); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { _ = Throw.IfNull(session); @@ -77,7 +77,7 @@ internal sealed class WorkflowHostAgent : AIAgent throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized."); } - return workflowSession.Serialize(jsonSerializerOptions); + return new(workflowSession.Serialize(jsonSerializerOptions)); } protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 5878d877b2..dc462cf501 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -226,7 +226,7 @@ public sealed partial class ChatClientAgent : AIAgent try { // Using the enumerator to ensure we consider the case where no updates are returned for notification. - responseUpdatesEnumerator = chatClient.GetStreamingResponseAsync(inputMessagesForProviders, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken); + responseUpdatesEnumerator = chatClient.GetStreamingResponseAsync(inputMessagesForChatClient, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken); } catch (Exception ex) { @@ -385,7 +385,7 @@ public sealed partial class ChatClientAgent : AIAgent } /// - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { _ = Throw.IfNull(session); @@ -394,7 +394,7 @@ public sealed partial class ChatClientAgent : AIAgent throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized."); } - return typedSession.Serialize(jsonSerializerOptions); + return new(typedSession.Serialize(jsonSerializerOptions)); } /// diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs index c63e8ac682..b8e495152c 100644 --- a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs @@ -189,7 +189,7 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable { // Get the text from the current request messages var requestText = string.Join("\n", context.RequestMessages - .Where(m => m.GetAgentRequestMessageSource() == AgentRequestMessageSourceType.External) + .Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External) .Where(m => m != null && !string.IsNullOrWhiteSpace(m.Text)) .Select(m => m.Text)); @@ -245,7 +245,7 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable var collection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false); List> itemsToStore = context.RequestMessages - .Where(m => m.GetAgentRequestMessageSource() == AgentRequestMessageSourceType.External) + .Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External) .Concat(context.ResponseMessages ?? []) .Select(message => new Dictionary { diff --git a/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs b/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs index ee87d4f00c..f29aadf808 100644 --- a/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs @@ -118,7 +118,7 @@ public sealed class TextSearchProvider : AIContextProvider // Aggregate text from memory + current request messages. var sbInput = new StringBuilder(); var requestMessagesText = context.RequestMessages - .Where(m => m.GetAgentRequestMessageSource() == AgentRequestMessageSourceType.External) + .Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External) .Where(x => !string.IsNullOrWhiteSpace(x?.Text)).Select(x => x.Text); foreach (var messageText in this._recentMessagesText.Concat(requestMessagesText)) { @@ -182,7 +182,7 @@ public sealed class TextSearchProvider : AIContextProvider } var messagesText = context.RequestMessages - .Where(m => m.GetAgentRequestMessageSource() == AgentRequestMessageSourceType.External) + .Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External) .Concat(context.ResponseMessages ?? []) .Where(m => this._recentMessageRolesIncluded.Contains(m.Role) && diff --git a/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs b/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs index 08a39afe5e..1f1570312a 100644 --- a/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs +++ b/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs @@ -24,7 +24,7 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint) /// /// Create the workflow from the declarative YAML. Includes definition of the - /// and the associated . + /// and the associated . /// public Workflow CreateWorkflow() { 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.AGUI.UnitTests/AGUIChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs index 42c64dfeec..ede2c07d37 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs @@ -251,7 +251,7 @@ public sealed class AGUIAgentTests var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: []); AgentSession originalSession = await agent.CreateSessionAsync(); - JsonElement serialized = agent.SerializeSession(originalSession); + JsonElement serialized = await agent.SerializeSessionAsync(originalSession); // Act AgentSession deserialized = await agent.DeserializeSessionAsync(serialized); diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs index e964805b3f..2f2f9175d4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentTests.cs @@ -576,7 +576,7 @@ public class AIAgentTests protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) @@ -617,7 +617,7 @@ public class AIAgentTests protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override Task RunCoreAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIContextProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIContextProviderTests.cs index 44d1be2e74..aa41a03efc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIContextProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIContextProviderTests.cs @@ -19,7 +19,7 @@ public class AIContextProviderTests #region InvokingAsync Message Stamping Tests [Fact] - public async Task InvokingAsync_StampsMessagesWithSourceTypeAndSourceAsync() + public async Task InvokingAsync_StampsMessagesWithSourceTypeAndSourceIdAsync() { // Arrange var provider = new TestAIContextProviderWithMessages(); @@ -32,18 +32,18 @@ public class AIContextProviderTests Assert.NotNull(aiContext.Messages); ChatMessage message = aiContext.Messages.Single(); Assert.NotNull(message.AdditionalProperties); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out object? sourceType)); - Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, sourceType); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out object? source)); - Assert.Equal(typeof(TestAIContextProviderWithMessages).FullName, source); + Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out object? attribution)); + var typedAttribution = Assert.IsType(attribution); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, typedAttribution.SourceType); + Assert.Equal(typeof(TestAIContextProviderWithMessages).FullName, typedAttribution.SourceId); } [Fact] - public async Task InvokingAsync_WithCustomSourceName_StampsMessagesWithCustomSourceAsync() + public async Task InvokingAsync_WithCustomSourceId_StampsMessagesWithCustomSourceIdAsync() { // Arrange - const string CustomSourceName = "CustomContextSource"; - var provider = new TestAIContextProviderWithCustomSource(CustomSourceName); + const string CustomSourceId = "CustomContextSource"; + var provider = new TestAIContextProviderWithCustomSource(CustomSourceId); var context = new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Request")]); // Act @@ -53,10 +53,10 @@ public class AIContextProviderTests Assert.NotNull(aiContext.Messages); ChatMessage message = aiContext.Messages.Single(); Assert.NotNull(message.AdditionalProperties); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out object? sourceType)); - Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, sourceType); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out object? source)); - Assert.Equal(CustomSourceName, source); + Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out object? attribution)); + var typedAttribution = Assert.IsType(attribution); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, typedAttribution.SourceType); + Assert.Equal(CustomSourceId, typedAttribution.SourceId); } [Fact] @@ -73,10 +73,10 @@ public class AIContextProviderTests Assert.NotNull(aiContext.Messages); ChatMessage message = aiContext.Messages.Single(); Assert.NotNull(message.AdditionalProperties); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out object? sourceType)); - Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, sourceType); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out object? source)); - Assert.Equal(typeof(TestAIContextProviderWithPreStampedMessages).FullName, source); + Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out object? attribution)); + var typedAttribution = Assert.IsType(attribution); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, typedAttribution.SourceType); + Assert.Equal(typeof(TestAIContextProviderWithPreStampedMessages).FullName, typedAttribution.SourceId); } [Fact] @@ -97,10 +97,10 @@ public class AIContextProviderTests foreach (ChatMessage message in messageList) { Assert.NotNull(message.AdditionalProperties); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out object? sourceType)); - Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, sourceType); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out object? source)); - Assert.Equal(typeof(TestAIContextProviderWithMultipleMessages).FullName, source); + Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out object? attribution)); + var typedAttribution = Assert.IsType(attribution); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, typedAttribution.SourceType); + Assert.Equal(typeof(TestAIContextProviderWithMultipleMessages).FullName, typedAttribution.SourceId); } } @@ -486,7 +486,7 @@ public class AIContextProviderTests private sealed class TestAIContextProviderWithCustomSource : AIContextProvider { - public TestAIContextProviderWithCustomSource(string sourceName) : base(sourceName) + public TestAIContextProviderWithCustomSource(string sourceId) : base(sourceId) { } @@ -504,8 +504,7 @@ public class AIContextProviderTests var message = new ChatMessage(ChatRole.System, "Pre-stamped Message"); message.AdditionalProperties = new AdditionalPropertiesDictionary { - [AgentRequestMessageSourceType.AdditionalPropertiesKey] = AgentRequestMessageSourceType.AIContextProvider, - [AgentRequestMessageSource.AdditionalPropertiesKey] = this.GetType().FullName! + [AgentRequestMessageSourceAttribution.AdditionalPropertiesKey] = new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, this.GetType().FullName!) }; return new(new AIContext { diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRequestMessageSourceAttributionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRequestMessageSourceAttributionTests.cs new file mode 100644 index 0000000000..5aee121097 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRequestMessageSourceAttributionTests.cs @@ -0,0 +1,466 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Abstractions.UnitTests; + +/// +/// Contains tests for the struct. +/// +public sealed class AgentRequestMessageSourceAttributionTests +{ + #region Constructor Tests + + [Fact] + public void Constructor_SetsSourceTypeAndSourceId() + { + // Arrange + AgentRequestMessageSourceType expectedType = AgentRequestMessageSourceType.AIContextProvider; + const string ExpectedId = "MyProvider"; + + // Act + AgentRequestMessageSourceAttribution attribution = new(expectedType, ExpectedId); + + // Assert + Assert.Equal(expectedType, attribution.SourceType); + Assert.Equal(ExpectedId, attribution.SourceId); + } + + [Fact] + public void Constructor_WithNullSourceId_SetsNullSourceId() + { + // Arrange + AgentRequestMessageSourceType sourceType = AgentRequestMessageSourceType.ChatHistory; + + // Act + AgentRequestMessageSourceAttribution attribution = new(sourceType, null); + + // Assert + Assert.Equal(sourceType, attribution.SourceType); + Assert.Null(attribution.SourceId); + } + + #endregion + + #region AdditionalPropertiesKey Tests + + [Fact] + public void AdditionalPropertiesKey_IsAttribution() + { + // Assert + Assert.Equal("_attribution", AgentRequestMessageSourceAttribution.AdditionalPropertiesKey); + } + + #endregion + + #region Default Value Tests + + [Fact] + public void Default_HasDefaultSourceTypeAndNullSourceId() + { + // Arrange & Act + AgentRequestMessageSourceAttribution attribution = default; + + // Assert + Assert.Equal(default, attribution.SourceType); + Assert.Null(attribution.SourceId); + } + + #endregion + + #region Equals (IEquatable) Tests + + [Fact] + public void Equals_WithSameSourceTypeAndSourceId_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.True(result); + } + + [Fact] + public void Equals_WithDifferentSourceType_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.ChatHistory, "Provider1"); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.False(result); + } + + [Fact] + public void Equals_WithDifferentSourceId_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider2"); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.False(result); + } + + [Fact] + public void Equals_WithDifferentSourceTypeAndSourceId_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.ChatHistory, "Provider2"); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.False(result); + } + + [Fact] + public void Equals_WithDifferentCaseSourceId_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "provider"); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.False(result); + } + + [Fact] + public void Equals_BothDefaultValues_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = default; + AgentRequestMessageSourceAttribution attribution2 = default; + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.True(result); + } + + [Fact] + public void Equals_WithBothNullSourceIds_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.External, null!); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.External, null!); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.True(result); + } + + [Fact] + public void Equals_WithOneNullSourceId_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.External, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.External, null!); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.False(result); + } + + #endregion + + #region Object.Equals Tests + + [Fact] + public void ObjectEquals_WithEqualAttribution_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.ChatHistory, "Provider"); + object attribution2 = new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "Provider"); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.True(result); + } + + [Fact] + public void ObjectEquals_WithDifferentType_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution = new(AgentRequestMessageSourceType.ChatHistory, "Provider"); + object other = "NotAnAttribution"; + + // Act + bool result = attribution.Equals(other); + + // Assert + Assert.False(result); + } + + [Fact] + public void ObjectEquals_WithNullObject_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution = new(AgentRequestMessageSourceType.ChatHistory, "Provider"); + object? other = null; + + // Act + bool result = attribution.Equals(other); + + // Assert + Assert.False(result); + } + + [Fact] + public void ObjectEquals_WithBoxedDifferentAttribution_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.ChatHistory, "Provider1"); + object attribution2 = new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "Provider2"); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.False(result); + } + + #endregion + + #region GetHashCode Tests + + [Fact] + public void GetHashCode_WithSameValues_ReturnsSameHashCode() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + + // Act + int hashCode1 = attribution1.GetHashCode(); + int hashCode2 = attribution2.GetHashCode(); + + // Assert + Assert.Equal(hashCode1, hashCode2); + } + + [Fact] + public void GetHashCode_WithDifferentSourceType_ReturnsDifferentHashCode() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.ChatHistory, "Provider"); + + // Act + int hashCode1 = attribution1.GetHashCode(); + int hashCode2 = attribution2.GetHashCode(); + + // Assert + Assert.NotEqual(hashCode1, hashCode2); + } + + [Fact] + public void GetHashCode_WithDifferentSourceId_ReturnsDifferentHashCode() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider2"); + + // Act + int hashCode1 = attribution1.GetHashCode(); + int hashCode2 = attribution2.GetHashCode(); + + // Assert + Assert.NotEqual(hashCode1, hashCode2); + } + + [Fact] + public void GetHashCode_ConsistentWithEquals() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.External, "Provider"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.External, "Provider"); + + // Act & Assert + Assert.True(attribution1.Equals(attribution2)); + Assert.Equal(attribution1.GetHashCode(), attribution2.GetHashCode()); + } + + [Fact] + public void GetHashCode_WithNullSourceId_DoesNotThrow() + { + // Arrange + AgentRequestMessageSourceAttribution attribution = new(AgentRequestMessageSourceType.External, null!); + + // Act + int hashCode = attribution.GetHashCode(); + + // Assert + Assert.IsType(hashCode); + } + + #endregion + + #region Equality Operator Tests + + [Fact] + public void EqualityOperator_WithEqualValues_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + + // Act + bool result = attribution1 == attribution2; + + // Assert + Assert.True(result); + } + + [Fact] + public void EqualityOperator_WithDifferentValues_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.ChatHistory, "Provider2"); + + // Act + bool result = attribution1 == attribution2; + + // Assert + Assert.False(result); + } + + [Fact] + public void EqualityOperator_WithBothDefault_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = default; + AgentRequestMessageSourceAttribution attribution2 = default; + + // Act + bool result = attribution1 == attribution2; + + // Assert + Assert.True(result); + } + + [Fact] + public void EqualityOperator_WithDifferentSourceTypeOnly_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.External, "Provider"); + + // Act + bool result = attribution1 == attribution2; + + // Assert + Assert.False(result); + } + + [Fact] + public void EqualityOperator_WithDifferentSourceIdOnly_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider2"); + + // Act + bool result = attribution1 == attribution2; + + // Assert + Assert.False(result); + } + + #endregion + + #region Inequality Operator Tests + + [Fact] + public void InequalityOperator_WithEqualValues_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + + // Act + bool result = attribution1 != attribution2; + + // Assert + Assert.False(result); + } + + [Fact] + public void InequalityOperator_WithDifferentValues_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.ChatHistory, "Provider2"); + + // Act + bool result = attribution1 != attribution2; + + // Assert + Assert.True(result); + } + + [Fact] + public void InequalityOperator_WithBothDefault_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = default; + AgentRequestMessageSourceAttribution attribution2 = default; + + // Act + bool result = attribution1 != attribution2; + + // Assert + Assert.False(result); + } + + [Fact] + public void InequalityOperator_WithDifferentSourceTypeOnly_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.External, "Provider"); + + // Act + bool result = attribution1 != attribution2; + + // Assert + Assert.True(result); + } + + [Fact] + public void InequalityOperator_WithDifferentSourceIdOnly_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider2"); + + // Act + bool result = attribution1 != attribution2; + + // Assert + Assert.True(result); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRequestMessageSourceTypeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRequestMessageSourceTypeTests.cs index f6149092f3..000505fe32 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRequestMessageSourceTypeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRequestMessageSourceTypeTests.cs @@ -5,7 +5,7 @@ using System; namespace Microsoft.Agents.AI.Abstractions.UnitTests; /// -/// Contains tests for the class. +/// Contains tests for the struct. /// public sealed class AgentRequestMessageSourceTypeTests { @@ -38,6 +38,16 @@ public sealed class AgentRequestMessageSourceTypeTests Assert.Throws(() => new AgentRequestMessageSourceType(string.Empty)); } + [Fact] + public void Default_DefaultsToExternal() + { + // Act + AgentRequestMessageSourceType defaultSource = default; + + // Assert + Assert.Equal(AgentRequestMessageSourceType.External, defaultSource); + } + #endregion #region Static Properties Tests @@ -49,7 +59,6 @@ public sealed class AgentRequestMessageSourceTypeTests AgentRequestMessageSourceType source = AgentRequestMessageSourceType.External; // Assert - Assert.NotNull(source); Assert.Equal("External", source.Value); } @@ -60,7 +69,6 @@ public sealed class AgentRequestMessageSourceTypeTests AgentRequestMessageSourceType source = AgentRequestMessageSourceType.AIContextProvider; // Assert - Assert.NotNull(source); Assert.Equal("AIContextProvider", source.Value); } @@ -71,22 +79,11 @@ public sealed class AgentRequestMessageSourceTypeTests AgentRequestMessageSourceType source = AgentRequestMessageSourceType.ChatHistory; // Assert - Assert.NotNull(source); Assert.Equal("ChatHistory", source.Value); } [Fact] - public void AdditionalPropertiesKey_ReturnsExpectedValue() - { - // Arrange & Act - string key = AgentRequestMessageSourceType.AdditionalPropertiesKey; - - // Assert - Assert.Equal("Agent.RequestMessageSourceType", key); - } - - [Fact] - public void StaticProperties_ReturnSameInstanceOnMultipleCalls() + public void StaticProperties_ReturnEqualValuesOnMultipleCalls() { // Arrange & Act AgentRequestMessageSourceType external1 = AgentRequestMessageSourceType.External; @@ -97,9 +94,9 @@ public sealed class AgentRequestMessageSourceTypeTests AgentRequestMessageSourceType chatHistory2 = AgentRequestMessageSourceType.ChatHistory; // Assert - Assert.Same(external1, external2); - Assert.Same(aiContextProvider1, aiContextProvider2); - Assert.Same(chatHistory1, chatHistory2); + Assert.Equal(external1, external2); + Assert.Equal(aiContextProvider1, aiContextProvider2); + Assert.Equal(chatHistory1, chatHistory2); } #endregion @@ -148,7 +145,7 @@ public sealed class AgentRequestMessageSourceTypeTests } [Fact] - public void Equals_WithNull_ReturnsFalse() + public void Equals_WithNullObject_ReturnsFalse() { // Arrange AgentRequestMessageSourceType source = new("Test"); @@ -314,11 +311,11 @@ public sealed class AgentRequestMessageSourceTypeTests } [Fact] - public void EqualityOperator_WithBothNull_ReturnsTrue() + public void EqualityOperator_WithDefaultValues_ReturnsTrue() { // Arrange - AgentRequestMessageSourceType? source1 = null; - AgentRequestMessageSourceType? source2 = null; + AgentRequestMessageSourceType source1 = default; + AgentRequestMessageSourceType source2 = default; // Act bool result = source1 == source2; @@ -327,34 +324,6 @@ public sealed class AgentRequestMessageSourceTypeTests Assert.True(result); } - [Fact] - public void EqualityOperator_WithLeftNull_ReturnsFalse() - { - // Arrange - AgentRequestMessageSourceType? source1 = null; - AgentRequestMessageSourceType source2 = new("Test"); - - // Act - bool result = source1 == source2; - - // Assert - Assert.False(result); - } - - [Fact] - public void EqualityOperator_WithRightNull_ReturnsFalse() - { - // Arrange - AgentRequestMessageSourceType source1 = new("Test"); - AgentRequestMessageSourceType? source2 = null; - - // Act - bool result = source1 == source2; - - // Assert - Assert.False(result); - } - [Fact] public void EqualityOperator_WithStaticInstances_ReturnsTrue() { @@ -416,11 +385,11 @@ public sealed class AgentRequestMessageSourceTypeTests } [Fact] - public void InequalityOperator_WithBothNull_ReturnsFalse() + public void InequalityOperator_WithBothDefault_ReturnsFalse() { // Arrange - AgentRequestMessageSourceType? source1 = null; - AgentRequestMessageSourceType? source2 = null; + AgentRequestMessageSourceType source1 = default; + AgentRequestMessageSourceType source2 = default; // Act bool result = source1 != source2; @@ -429,34 +398,6 @@ public sealed class AgentRequestMessageSourceTypeTests Assert.False(result); } - [Fact] - public void InequalityOperator_WithLeftNull_ReturnsTrue() - { - // Arrange - AgentRequestMessageSourceType? source1 = null; - AgentRequestMessageSourceType source2 = new("Test"); - - // Act - bool result = source1 != source2; - - // Assert - Assert.True(result); - } - - [Fact] - public void InequalityOperator_WithRightNull_ReturnsTrue() - { - // Arrange - AgentRequestMessageSourceType source1 = new("Test"); - AgentRequestMessageSourceType? source2 = null; - - // Act - bool result = source1 != source2; - - // Assert - Assert.True(result); - } - [Fact] public void InequalityOperator_DifferentStaticInstances_ReturnsTrue() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunContextTests.cs index 693f1ea0a4..017e5fc3b2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunContextTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunContextTests.cs @@ -211,7 +211,7 @@ public sealed class AgentRunContextTests protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override Task RunCoreAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderExtensionsTests.cs index 1244209a97..1fcbe37e25 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderExtensionsTests.cs @@ -64,7 +64,7 @@ public sealed class ChatHistoryProviderExtensionsTests Mock providerMock = new(); List requestMessages = [ - new(ChatRole.System, "System") { AdditionalProperties = new() { { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.ChatHistory } } }, + new(ChatRole.System, "System") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "TestSource") } } }, new(ChatRole.User, "Hello") ]; ChatHistoryProvider.InvokedContext context = new(s_mockAgent, s_mockSession, requestMessages) @@ -114,9 +114,9 @@ public sealed class ChatHistoryProviderExtensionsTests Mock providerMock = new(); List requestMessages = [ - new(ChatRole.System, "System") { AdditionalProperties = new() { { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.ChatHistory } } }, + new(ChatRole.System, "System") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "TestSource") } } }, new(ChatRole.User, "Hello"), - new(ChatRole.System, "Context") { AdditionalProperties = new() { { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.AIContextProvider } } } + new(ChatRole.System, "Context") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, "TestContextSource") } } } ]; ChatHistoryProvider.InvokedContext context = new(s_mockAgent, s_mockSession, requestMessages); diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderMessageFilterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderMessageFilterTests.cs index 5b48d025be..75d8f554c5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderMessageFilterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderMessageFilterTests.cs @@ -157,7 +157,7 @@ public sealed class ChatHistoryProviderMessageFilterTests var innerProviderMock = new Mock(); List requestMessages = [ - new(ChatRole.System, "System") { AdditionalProperties = new() { { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.ChatHistory } } }, + new(ChatRole.System, "System") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "TestSource") } } }, new(ChatRole.User, "Hello"), ]; var responseMessages = new List { new(ChatRole.Assistant, "Response") }; @@ -176,7 +176,7 @@ public sealed class ChatHistoryProviderMessageFilterTests // Filter that modifies the context ChatHistoryProvider.InvokedContext InvokedFilter(ChatHistoryProvider.InvokedContext ctx) { - var modifiedRequestMessages = ctx.RequestMessages.Where(x => x.GetAgentRequestMessageSource() == AgentRequestMessageSourceType.External).Select(m => new ChatMessage(m.Role, $"[FILTERED] {m.Text}")).ToList(); + var modifiedRequestMessages = ctx.RequestMessages.Where(x => x.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External).Select(m => new ChatMessage(m.Role, $"[FILTERED] {m.Text}")).ToList(); return new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, modifiedRequestMessages) { ResponseMessages = ctx.ResponseMessages, diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderTests.cs index e158b159ca..8b07366b03 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderTests.cs @@ -22,7 +22,7 @@ public class ChatHistoryProviderTests #region InvokingAsync Message Stamping Tests [Fact] - public async Task InvokingAsync_StampsMessagesWithSourceTypeAndSourceAsync() + public async Task InvokingAsync_StampsMessagesWithSourceTypeAndSourceIdAsync() { // Arrange var provider = new TestChatHistoryProvider(); @@ -34,18 +34,18 @@ public class ChatHistoryProviderTests // Assert ChatMessage message = messages.Single(); Assert.NotNull(message.AdditionalProperties); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out object? sourceType)); - Assert.Equal(AgentRequestMessageSourceType.ChatHistory, sourceType); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out object? source)); - Assert.Equal(typeof(TestChatHistoryProvider).FullName, source); + Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out object? attribution)); + var typedAttribution = Assert.IsType(attribution); + Assert.Equal(AgentRequestMessageSourceType.ChatHistory, typedAttribution.SourceType); + Assert.Equal(typeof(TestChatHistoryProvider).FullName, typedAttribution.SourceId); } [Fact] - public async Task InvokingAsync_WithCustomSourceName_StampsMessagesWithCustomSourceAsync() + public async Task InvokingAsync_WithCustomSourceId_StampsMessagesWithCustomSourceIdAsync() { // Arrange - const string CustomSourceName = "CustomHistorySource"; - var provider = new TestChatHistoryProviderWithCustomSource(CustomSourceName); + const string CustomSourceId = "CustomHistorySource"; + var provider = new TestChatHistoryProviderWithCustomSource(CustomSourceId); var context = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Request")]); // Act @@ -54,10 +54,10 @@ public class ChatHistoryProviderTests // Assert ChatMessage message = messages.Single(); Assert.NotNull(message.AdditionalProperties); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out object? sourceType)); - Assert.Equal(AgentRequestMessageSourceType.ChatHistory, sourceType); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out object? source)); - Assert.Equal(CustomSourceName, source); + Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out object? attribution)); + var typedAttribution = Assert.IsType(attribution); + Assert.Equal(AgentRequestMessageSourceType.ChatHistory, typedAttribution.SourceType); + Assert.Equal(CustomSourceId, typedAttribution.SourceId); } [Fact] @@ -73,10 +73,10 @@ public class ChatHistoryProviderTests // Assert ChatMessage message = messages.Single(); Assert.NotNull(message.AdditionalProperties); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out object? sourceType)); - Assert.Equal(AgentRequestMessageSourceType.ChatHistory, sourceType); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out object? source)); - Assert.Equal(typeof(TestChatHistoryProviderWithPreStampedMessages).FullName, source); + Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out object? attribution)); + var typedAttribution = Assert.IsType(attribution); + Assert.Equal(AgentRequestMessageSourceType.ChatHistory, typedAttribution.SourceType); + Assert.Equal(typeof(TestChatHistoryProviderWithPreStampedMessages).FullName, typedAttribution.SourceId); } [Fact] @@ -96,10 +96,10 @@ public class ChatHistoryProviderTests foreach (ChatMessage message in messageList) { Assert.NotNull(message.AdditionalProperties); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out object? sourceType)); - Assert.Equal(AgentRequestMessageSourceType.ChatHistory, sourceType); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out object? source)); - Assert.Equal(typeof(TestChatHistoryProviderWithMultipleMessages).FullName, source); + Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out object? attribution)); + var typedAttribution = Assert.IsType(attribution); + Assert.Equal(AgentRequestMessageSourceType.ChatHistory, typedAttribution.SourceType); + Assert.Equal(typeof(TestChatHistoryProviderWithMultipleMessages).FullName, typedAttribution.SourceId); } } @@ -383,7 +383,7 @@ public class ChatHistoryProviderTests private sealed class TestChatHistoryProviderWithCustomSource : ChatHistoryProvider { - public TestChatHistoryProviderWithCustomSource(string sourceName) : base(sourceName) + public TestChatHistoryProviderWithCustomSource(string sourceId) : base(sourceId) { } @@ -404,8 +404,7 @@ public class ChatHistoryProviderTests var message = new ChatMessage(ChatRole.User, "Pre-stamped Message"); message.AdditionalProperties = new AdditionalPropertiesDictionary { - [AgentRequestMessageSourceType.AdditionalPropertiesKey] = AgentRequestMessageSourceType.ChatHistory, - [AgentRequestMessageSource.AdditionalPropertiesKey] = this.GetType().FullName! + [AgentRequestMessageSourceAttribution.AdditionalPropertiesKey] = new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, this.GetType().FullName!) }; return new([message]); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageExtensionsTests.cs index f389c567d2..97050c3071 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageExtensionsTests.cs @@ -9,23 +9,23 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests; /// public sealed class ChatMessageExtensionsTests { - #region GetAgentRequestMessageSource Tests + #region GetAgentRequestMessageSourceType Tests [Fact] - public void GetAgentRequestMessageSource_WithNoAdditionalProperties_ReturnsExternal() + public void GetAgentRequestMessageSourceType_WithNoAdditionalProperties_ReturnsExternal() { // Arrange ChatMessage message = new(ChatRole.User, "Hello"); // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.External, result); } [Fact] - public void GetAgentRequestMessageSource_WithNullAdditionalProperties_ReturnsExternal() + public void GetAgentRequestMessageSourceType_WithNullAdditionalProperties_ReturnsExternal() { // Arrange ChatMessage message = new(ChatRole.User, "Hello") @@ -34,14 +34,14 @@ public sealed class ChatMessageExtensionsTests }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.External, result); } [Fact] - public void GetAgentRequestMessageSource_WithEmptyAdditionalProperties_ReturnsExternal() + public void GetAgentRequestMessageSourceType_WithEmptyAdditionalProperties_ReturnsExternal() { // Arrange ChatMessage message = new(ChatRole.User, "Hello") @@ -50,130 +50,130 @@ public sealed class ChatMessageExtensionsTests }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.External, result); } [Fact] - public void GetAgentRequestMessageSource_WithExternalSource_ReturnsExternal() + public void GetAgentRequestMessageSourceType_WithExternalSourceType_ReturnsExternal() { // Arrange ChatMessage message = new(ChatRole.User, "Hello") { AdditionalProperties = new AdditionalPropertiesDictionary { - { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.External } + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.External, "TestSourceId") } } }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.External, result); } [Fact] - public void GetAgentRequestMessageSource_WithAIContextProviderSource_ReturnsAIContextProvider() + public void GetAgentRequestMessageSourceType_WithAIContextProviderSourceType_ReturnsAIContextProvider() { // Arrange ChatMessage message = new(ChatRole.User, "Hello") { AdditionalProperties = new AdditionalPropertiesDictionary { - { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.AIContextProvider } + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, "TestSourceId") } } }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, result); } [Fact] - public void GetAgentRequestMessageSource_WithChatHistorySource_ReturnsChatHistory() + public void GetAgentRequestMessageSourceType_WithChatHistorySourceType_ReturnsChatHistory() { // Arrange ChatMessage message = new(ChatRole.User, "Hello") { AdditionalProperties = new AdditionalPropertiesDictionary { - { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.ChatHistory } + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "TestSourceId") } } }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.ChatHistory, result); } [Fact] - public void GetAgentRequestMessageSource_WithCustomSource_ReturnsCustomSource() + public void GetAgentRequestMessageSourceType_WithCustomSourceType_ReturnsCustomSourceType() { // Arrange - AgentRequestMessageSourceType customSource = new("CustomSource"); + AgentRequestMessageSourceType customSourceType = new("CustomSourceType"); ChatMessage message = new(ChatRole.User, "Hello") { AdditionalProperties = new AdditionalPropertiesDictionary { - { AgentRequestMessageSourceType.AdditionalPropertiesKey, customSource } + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(customSourceType, "TestSourceId") } } }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert - Assert.Equal(customSource, result); - Assert.Equal("CustomSource", result.Value); + Assert.Equal(customSourceType, result); + Assert.Equal("CustomSourceType", result.Value); } [Fact] - public void GetAgentRequestMessageSource_WithWrongKeyType_ReturnsExternal() + public void GetAgentRequestMessageSourceType_WithWrongAttributionType_ReturnsExternal() { // Arrange ChatMessage message = new(ChatRole.User, "Hello") { AdditionalProperties = new AdditionalPropertiesDictionary { - { AgentRequestMessageSourceType.AdditionalPropertiesKey, "NotAnAgentRequestMessageSource" } + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, "NotAnAgentRequestMessageSourceAttribution" } } }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.External, result); } [Fact] - public void GetAgentRequestMessageSource_WithNullValue_ReturnsExternal() + public void GetAgentRequestMessageSourceType_WithNullAttributionValue_ReturnsExternal() { // Arrange ChatMessage message = new(ChatRole.User, "Hello") { AdditionalProperties = new AdditionalPropertiesDictionary { - { AgentRequestMessageSourceType.AdditionalPropertiesKey, null! } + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, null! } } }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.External, result); } [Fact] - public void GetAgentRequestMessageSource_WithMultipleProperties_ReturnsCorrectSource() + public void GetAgentRequestMessageSourceType_WithMultipleProperties_ReturnsCorrectSourceType() { // Arrange ChatMessage message = new(ChatRole.User, "Hello") @@ -181,17 +181,345 @@ public sealed class ChatMessageExtensionsTests AdditionalProperties = new AdditionalPropertiesDictionary { { "OtherProperty", "SomeValue" }, - { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.ChatHistory }, + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "TestSourceId") }, { "AnotherProperty", 123 } } }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.ChatHistory, result); } #endregion + + #region GetAgentRequestMessageSourceId Tests + + [Fact] + public void GetAgentRequestMessageSourceId_WithNoAdditionalProperties_ReturnsNull() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello"); + + // Act + string? result = message.GetAgentRequestMessageSourceId(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetAgentRequestMessageSourceId_WithNullAdditionalProperties_ReturnsNull() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = null + }; + + // Act + string? result = message.GetAgentRequestMessageSourceId(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetAgentRequestMessageSourceId_WithEmptyAdditionalProperties_ReturnsNull() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary() + }; + + // Act + string? result = message.GetAgentRequestMessageSourceId(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetAgentRequestMessageSourceId_WithAttribution_ReturnsSourceId() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, "MyProvider.FullName") } + } + }; + + // Act + string? result = message.GetAgentRequestMessageSourceId(); + + // Assert + Assert.Equal("MyProvider.FullName", result); + } + + [Fact] + public void GetAgentRequestMessageSourceId_WithDifferentSourceIds_ReturnsCorrectSourceId() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "CustomHistorySourceId") } + } + }; + + // Act + string? result = message.GetAgentRequestMessageSourceId(); + + // Assert + Assert.Equal("CustomHistorySourceId", result); + } + + [Fact] + public void GetAgentRequestMessageSourceId_WithWrongAttributionType_ReturnsNull() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, "NotAnAgentRequestMessageSourceAttribution" } + } + }; + + // Act + string? result = message.GetAgentRequestMessageSourceId(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetAgentRequestMessageSourceId_WithNullAttributionValue_ReturnsNull() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, null! } + } + }; + + // Act + string? result = message.GetAgentRequestMessageSourceId(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetAgentRequestMessageSourceId_WithMultipleProperties_ReturnsCorrectSourceId() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { "OtherProperty", "SomeValue" }, + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.External, "ExpectedSourceId") }, + { "AnotherProperty", 123 } + } + }; + + // Act + string? result = message.GetAgentRequestMessageSourceId(); + + // Assert + Assert.Equal("ExpectedSourceId", result); + } + + #endregion + + #region AsAgentRequestMessageSourcedMessage Tests + + [Fact] + public void AsAgentRequestMessageSourcedMessage_WithNoAdditionalProperties_ReturnsClonesMessageWithAttribution() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello"); + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.External, "TestSourceId"); + + // Assert + Assert.NotSame(message, result); + Assert.Equal(AgentRequestMessageSourceType.External, result.GetAgentRequestMessageSourceType()); + Assert.Equal("TestSourceId", result.GetAgentRequestMessageSourceId()); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_WithNullAdditionalProperties_ReturnsClonesMessageWithAttribution() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = null + }; + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.AIContextProvider, "ProviderSourceId"); + + // Assert + Assert.NotSame(message, result); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, result.GetAgentRequestMessageSourceType()); + Assert.Equal("ProviderSourceId", result.GetAgentRequestMessageSourceId()); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_WithMatchingSourceTypeAndSourceId_ReturnsSameInstance() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "HistoryId") } + } + }; + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.ChatHistory, "HistoryId"); + + // Assert + Assert.Same(message, result); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_WithDifferentSourceType_ReturnsClonesMessageWithNewAttribution() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.External, "SourceId") } + } + }; + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.AIContextProvider, "SourceId"); + + // Assert + Assert.NotSame(message, result); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, result.GetAgentRequestMessageSourceType()); + Assert.Equal("SourceId", result.GetAgentRequestMessageSourceId()); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_WithDifferentSourceId_ReturnsClonesMessageWithNewAttribution() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.External, "OriginalId") } + } + }; + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.External, "NewId"); + + // Assert + Assert.NotSame(message, result); + Assert.Equal(AgentRequestMessageSourceType.External, result.GetAgentRequestMessageSourceType()); + Assert.Equal("NewId", result.GetAgentRequestMessageSourceId()); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_WithDefaultNullSourceId_ReturnsClonesMessageWithNullSourceId() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello"); + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.ChatHistory); + + // Assert + Assert.NotSame(message, result); + Assert.Equal(AgentRequestMessageSourceType.ChatHistory, result.GetAgentRequestMessageSourceType()); + Assert.Null(result.GetAgentRequestMessageSourceId()); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_WithMatchingSourceTypeAndNullSourceId_ReturnsSameInstance() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.External, null) } + } + }; + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.External); + + // Assert + Assert.Same(message, result); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_DoesNotModifyOriginalMessage() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello"); + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.AIContextProvider, "ProviderId"); + + // Assert + Assert.Null(message.AdditionalProperties); + Assert.NotNull(result.AdditionalProperties); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, result.GetAgentRequestMessageSourceType()); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_WithWrongAttributionType_ReturnsClonesMessageWithNewAttribution() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, "NotAnAttribution" } + } + }; + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.External, "SourceId"); + + // Assert + Assert.NotSame(message, result); + Assert.Equal(AgentRequestMessageSourceType.External, result.GetAgentRequestMessageSourceType()); + Assert.Equal("SourceId", result.GetAgentRequestMessageSourceId()); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_PreservesMessageContent() + { + // Arrange + ChatMessage message = new(ChatRole.Assistant, "Test content"); + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.ChatHistory, "HistoryId"); + + // Assert + Assert.Equal(ChatRole.Assistant, result.Role); + Assert.Equal("Test content", result.Text); + } + + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatHistoryProviderTests.cs index 75232073a6..cecce5bea1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatHistoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatHistoryProviderTests.cs @@ -55,7 +55,7 @@ public class InMemoryChatHistoryProviderTests var requestMessages = new List { new(ChatRole.User, "Hello"), - new(ChatRole.System, "additional context") { AdditionalProperties = new() { { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.ChatHistory } } }, + new(ChatRole.System, "additional context") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "TestSource") } } }, }; var responseMessages = new List { diff --git a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatHistoryProviderTests.cs index e1d3c612c8..e7276f70b1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatHistoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatHistoryProviderTests.cs @@ -286,7 +286,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable new ChatMessage(ChatRole.User, "First message"), new ChatMessage(ChatRole.Assistant, "Second message"), new ChatMessage(ChatRole.User, "Third message"), - new ChatMessage(ChatRole.System, "System context message") { AdditionalProperties = new() { { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.AIContextProvider } } } + new ChatMessage(ChatRole.System, "System context message") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, "TestSource") } } } }; var responseMessages = new[] { diff --git a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AggregatorPromptAgentFactoryTests.cs b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AggregatorPromptAgentFactoryTests.cs index ac0db2068d..f53788baf8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AggregatorPromptAgentFactoryTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Declarative.UnitTests/AggregatorPromptAgentFactoryTests.cs @@ -71,7 +71,7 @@ public sealed class AggregatorPromptAgentFactoryTests throw new NotImplementedException(); } - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { throw new NotImplementedException(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs index e80990de1b..8a4d3c1068 100644 --- a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs @@ -100,4 +100,125 @@ public sealed class GitHubCopilotAgentTests Assert.NotNull(agent); Assert.NotNull(agent.Id); } + + [Fact] + public void CopySessionConfig_CopiesAllProperties() + { + // Arrange + List tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")]; + var hooks = new SessionHooks(); + var infiniteSessions = new InfiniteSessionConfig(); + var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" }; + PermissionHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult()); + UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" }); + var mcpServers = new Dictionary { ["server1"] = new McpLocalServerConfig() }; + + var source = new SessionConfig + { + Model = "gpt-4o", + ReasoningEffort = "high", + Tools = tools, + SystemMessage = systemMessage, + AvailableTools = ["tool1", "tool2"], + ExcludedTools = ["tool3"], + WorkingDirectory = "/workspace", + ConfigDir = "/config", + Hooks = hooks, + InfiniteSessions = infiniteSessions, + OnPermissionRequest = permissionHandler, + OnUserInputRequest = userInputHandler, + McpServers = mcpServers, + DisabledSkills = ["skill1"], + }; + + // Act + SessionConfig result = GitHubCopilotAgent.CopySessionConfig(source); + + // Assert + Assert.Equal("gpt-4o", result.Model); + Assert.Equal("high", result.ReasoningEffort); + Assert.Same(tools, result.Tools); + Assert.Same(systemMessage, result.SystemMessage); + Assert.Equal(new List { "tool1", "tool2" }, result.AvailableTools); + Assert.Equal(new List { "tool3" }, result.ExcludedTools); + Assert.Equal("/workspace", result.WorkingDirectory); + Assert.Equal("/config", result.ConfigDir); + Assert.Same(hooks, result.Hooks); + Assert.Same(infiniteSessions, result.InfiniteSessions); + Assert.Same(permissionHandler, result.OnPermissionRequest); + Assert.Same(userInputHandler, result.OnUserInputRequest); + Assert.Same(mcpServers, result.McpServers); + Assert.Equal(new List { "skill1" }, result.DisabledSkills); + Assert.True(result.Streaming); + } + + [Fact] + public void CopyResumeSessionConfig_CopiesAllProperties() + { + // Arrange + List tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")]; + var hooks = new SessionHooks(); + var infiniteSessions = new InfiniteSessionConfig(); + var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" }; + PermissionHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult()); + UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" }); + var mcpServers = new Dictionary { ["server1"] = new McpLocalServerConfig() }; + + var source = new SessionConfig + { + Model = "gpt-4o", + ReasoningEffort = "high", + Tools = tools, + SystemMessage = systemMessage, + AvailableTools = ["tool1", "tool2"], + ExcludedTools = ["tool3"], + WorkingDirectory = "/workspace", + ConfigDir = "/config", + Hooks = hooks, + InfiniteSessions = infiniteSessions, + OnPermissionRequest = permissionHandler, + OnUserInputRequest = userInputHandler, + McpServers = mcpServers, + DisabledSkills = ["skill1"], + }; + + // Act + ResumeSessionConfig result = GitHubCopilotAgent.CopyResumeSessionConfig(source); + + // Assert + Assert.Equal("gpt-4o", result.Model); + Assert.Equal("high", result.ReasoningEffort); + Assert.Same(tools, result.Tools); + Assert.Same(systemMessage, result.SystemMessage); + Assert.Equal(new List { "tool1", "tool2" }, result.AvailableTools); + Assert.Equal(new List { "tool3" }, result.ExcludedTools); + Assert.Equal("/workspace", result.WorkingDirectory); + Assert.Equal("/config", result.ConfigDir); + Assert.Same(hooks, result.Hooks); + Assert.Same(infiniteSessions, result.InfiniteSessions); + Assert.Same(permissionHandler, result.OnPermissionRequest); + Assert.Same(userInputHandler, result.OnUserInputRequest); + Assert.Same(mcpServers, result.McpServers); + Assert.Equal(new List { "skill1" }, result.DisabledSkills); + Assert.True(result.Streaming); + } + + [Fact] + public void CopyResumeSessionConfig_WithNullSource_ReturnsDefaults() + { + // Act + ResumeSessionConfig result = GitHubCopilotAgent.CopyResumeSessionConfig(null); + + // Assert + Assert.Null(result.Model); + Assert.Null(result.ReasoningEffort); + Assert.Null(result.Tools); + Assert.Null(result.SystemMessage); + Assert.Null(result.OnPermissionRequest); + Assert.Null(result.OnUserInputRequest); + Assert.Null(result.Hooks); + Assert.Null(result.WorkingDirectory); + Assert.Null(result.ConfigDir); + Assert.True(result.Streaming); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/BasicStreamingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/BasicStreamingTests.cs index a6e7aab212..03dfe63d99 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/BasicStreamingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/BasicStreamingTests.cs @@ -286,7 +286,7 @@ internal sealed class FakeChatClientAgent : AIAgent protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => new(new FakeInMemoryAgentSession(serializedState, jsonSerializerOptions)); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override async Task RunCoreAsync( @@ -353,14 +353,14 @@ internal sealed class FakeMultiMessageAgent : AIAgent protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => new(new FakeInMemoryAgentSession(serializedState, jsonSerializerOptions)); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { if (session is not FakeInMemoryAgentSession fakeSession) { throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized."); } - return fakeSession.Serialize(jsonSerializerOptions); + return new(fakeSession.Serialize(jsonSerializerOptions)); } protected override async Task RunCoreAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs index afd3db44b3..67108676ac 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs @@ -340,14 +340,14 @@ internal sealed class FakeForwardedPropsAgent : AIAgent protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => new(new FakeInMemoryAgentSession(serializedState, jsonSerializerOptions)); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { if (session is not FakeInMemoryAgentSession fakeSession) { throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized."); } - return fakeSession.Serialize(jsonSerializerOptions); + return new(fakeSession.Serialize(jsonSerializerOptions)); } private sealed class FakeInMemoryAgentSession : InMemoryAgentSession diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs index b78ddd0e11..9ff3dde1a4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs @@ -423,14 +423,14 @@ internal sealed class FakeStateAgent : AIAgent protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => new(new FakeInMemoryAgentSession(serializedState, jsonSerializerOptions)); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { if (session is not FakeInMemoryAgentSession fakeSession) { throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized."); } - return fakeSession.Serialize(jsonSerializerOptions); + return new(fakeSession.Serialize(jsonSerializerOptions)); } private sealed class FakeInMemoryAgentSession : InMemoryAgentSession diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs index fecb9d421b..16efbd0e6e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs @@ -431,14 +431,14 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => new(new TestInMemoryAgentSession(serializedState, jsonSerializerOptions)); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { if (session is not TestInMemoryAgentSession testSession) { throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized."); } - return testSession.Serialize(jsonSerializerOptions); + return new(testSession.Serialize(jsonSerializerOptions)); } protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) @@ -534,14 +534,14 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => new(new TestInMemoryAgentSession(serializedState, jsonSerializerOptions)); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { if (session is not TestInMemoryAgentSession testSession) { throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized."); } - return testSession.Serialize(jsonSerializerOptions); + return new(testSession.Serialize(jsonSerializerOptions)); } protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/TestAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/TestAgent.cs index 10502c7edd..14b7248fde 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/TestAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/TestAgent.cs @@ -13,7 +13,7 @@ internal sealed class TestAgent(string name, string description) : AIAgent protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => new(new DummyAgentSession()); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override ValueTask DeserializeSessionCoreAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs index 0846decc2f..38abc903d3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs @@ -478,7 +478,7 @@ public sealed class PurviewClientTests : IDisposable private static ContentToProcess CreateValidContentToProcess() { var content = new PurviewTextContent("Test content"); - var metadata = new ProcessConversationMetadata(content, "msg-123", false, "Test message"); + var metadata = new ProcessConversationMetadata(content, "msg-123", false, "Test message", "test-correlation-id"); var activityMetadata = new ActivityMetadata(Activity.UploadText); var deviceMetadata = new DeviceMetadata { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentExtensionsTests.cs index 8ac1ab50da..19ba8c11e6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentExtensionsTests.cs @@ -385,7 +385,7 @@ public class AgentExtensionsTests protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 41fb29bfed..c23e8cffaf 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -1320,6 +1320,45 @@ public partial class ChatClientAgentTests mockFactory.Verify(f => f(It.IsAny(), It.IsAny()), Times.Once); } + /// + /// Verify that RunStreamingAsync includes chat history in messages sent to the chat client on subsequent calls. + /// + [Fact] + public async Task RunStreamingAsyncIncludesChatHistoryInMessagesToChatClientAsync() + { + // Arrange + List> capturedMessages = []; + Mock mockService = new(); + ChatResponseUpdate[] returnUpdates = + [ + new ChatResponseUpdate(role: ChatRole.Assistant, content: "response"), + ]; + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(ToAsyncEnumerableAsync(returnUpdates)) + .Callback, ChatOptions?, CancellationToken>((msgs, _, _) => capturedMessages.Add(msgs.ToList())); + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test instructions" }, + }); + + // Act + ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await agent.RunStreamingAsync([new(ChatRole.User, "first")], session).ToListAsync(); + await agent.RunStreamingAsync([new(ChatRole.User, "second")], session).ToListAsync(); + + // Assert - the second call should include chat history (first user message + first response) plus the new message + Assert.Equal(2, capturedMessages.Count); + var secondCallMessages = capturedMessages[1].ToList(); + Assert.Equal(3, secondCallMessages.Count); + Assert.Equal("first", secondCallMessages[0].Text); + Assert.Equal("response", secondCallMessages[1].Text); + Assert.Equal("second", secondCallMessages[2].Text); + } + /// /// Verify that RunStreamingAsync throws when a factory is provided and the chat client returns a conversation id. /// 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.UnitTests/TestAIAgent.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/TestAIAgent.cs index 0c91a75f13..7a9b3ec305 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/TestAIAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/TestAIAgent.cs @@ -24,7 +24,7 @@ internal sealed class TestAIAgent : AIAgent public override string? Description => this.DescriptionFunc?.Invoke() ?? base.Description; - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs index 20cc823553..151e9fc70c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs @@ -134,7 +134,7 @@ public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(o } } - public static async ValueTask MessagesAsync(string? conversationId, Testcase testcase, WorkflowAgentProvider agentProvider) + public static async ValueTask MessagesAsync(string? conversationId, Testcase testcase, ResponseAgentProvider agentProvider) { int minExpectedCount = testcase.Validation.MinMessageCount ?? testcase.Validation.MinResponseCount; int maxExpectedCount = testcase.Validation.MaxMessageCount ?? testcase.Validation.MaxResponseCount ?? minExpectedCount; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs index ff61f1191b..a208e10b2d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs @@ -21,42 +21,51 @@ public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(o { private const string WorkflowWithConversationFileName = "MediaInputConversation.yaml"; private const string WorkflowWithAutoSendFileName = "MediaInputAutoSend.yaml"; - private const string PdfReference = "https://sample-files.com/downloads/documents/pdf/basic-text.pdf"; private const string ImageReference = "https://sample-files.com/downloads/images/jpg/web_optimized_1200x800_97kb.jpg"; + private const string PdfReference = "https://sample-files.com/downloads/documents/pdf/basic-text.pdf"; [Theory] - [InlineData(ImageReference, "image/jpeg", true, Skip = "Failing due to agent service bug.")] - [InlineData(ImageReference, "image/jpeg", false, Skip = "Failing due to agent service bug.")] + [InlineData(ImageReference, "image/jpeg", true)] + [InlineData(ImageReference, "image/jpeg", false)] public async Task ValidateFileUrlAsync(string fileSource, string mediaType, bool useConversation) { - this.Output.WriteLine($"File: {ImageReference}"); + // Arrange + this.Output.WriteLine($"File: {fileSource}"); + + // Act & Assert await this.ValidateFileAsync(new UriContent(fileSource, mediaType), useConversation); } [Theory] [InlineData(ImageReference, "image/jpeg", true)] - [InlineData(ImageReference, "image/jpeg", false, Skip = "Failing due to agent service bug.")] + [InlineData(ImageReference, "image/jpeg", false)] [InlineData(PdfReference, "application/pdf", true)] [InlineData(PdfReference, "application/pdf", false)] public async Task ValidateFileDataAsync(string fileSource, string mediaType, bool useConversation) { + // Arrange byte[] fileData = await DownloadFileAsync(fileSource); string encodedData = Convert.ToBase64String(fileData); string fileUrl = $"data:{mediaType};base64,{encodedData}"; - this.Output.WriteLine($"Content: {fileUrl.Substring(0, 112)}..."); + this.Output.WriteLine($"Content: {fileUrl.Substring(0, Math.Min(112, fileUrl.Length))}..."); + + // Act & Assert await this.ValidateFileAsync(new DataContent(fileUrl), useConversation); } [Theory] - [InlineData(PdfReference, "doc.pdf", true, Skip = "Failing due to agent service bug.")] - [InlineData(PdfReference, "doc.pdf", false, Skip = "Failing due to agent service bug.")] + [InlineData(PdfReference, "doc.pdf", true)] + [InlineData(PdfReference, "doc.pdf", false)] public async Task ValidateFileUploadAsync(string fileSource, string documentName, bool useConversation) { + // Arrange byte[] fileData = await DownloadFileAsync(fileSource); AIProjectClient client = new(this.TestEndpoint, new AzureCliCredential()); using MemoryStream contentStream = new(fileData); OpenAIFileClient fileClient = client.GetProjectOpenAIClient().GetOpenAIFileClient(); OpenAIFile fileInfo = await fileClient.UploadFileAsync(contentStream, documentName, FileUploadPurpose.Assistants); + + // Act & Assert try { this.Output.WriteLine($"File: {fileInfo.Id}"); @@ -77,6 +86,7 @@ public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(o private async Task ValidateFileAsync(AIContent fileContent, bool useConversation) { + // Act AgentProvider agentProvider = AgentProvider.Create(this.Configuration, AgentProvider.Names.Vision); await agentProvider.CreateAgentsAsync().ConfigureAwait(false); @@ -93,6 +103,8 @@ public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(o WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowFileName)); WorkflowEvents workflowEvents = await harness.RunWorkflowAsync(inputMessage).ConfigureAwait(false); + + // Assert Assert.Equal(useConversation ? 1 : 2, workflowEvents.ConversationEvents.Count); this.Output.WriteLine("CONVERSATION: " + workflowEvents.ConversationEvents[0].ConversationId); AgentResponseEvent agentResponseEvent = Assert.Single(workflowEvents.AgentResponseEvents); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/InputArguments.json b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/InputArguments.json index f4962e1bc6..02289ddfa7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/InputArguments.json +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/InputArguments.json @@ -10,7 +10,6 @@ "conversation_count": 1, "min_action_count": 1, "min_response_count": 1, - "min_message_count": 2, "actions": { "start": [ "invoke_poem" diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/Marketing.json b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/Marketing.json index 6af29b49c5..68c40219d0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/Marketing.json +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/Marketing.json @@ -10,7 +10,6 @@ "conversation_count": 1, "min_action_count": 3, "min_response_count": 3, - "min_message_count": 6, "actions": { "start": [ "invoke_analyst", diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/ForeachTemplateTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/ForeachTemplateTest.cs index 9db4493c01..aaafa5bfb3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/ForeachTemplateTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/ForeachTemplateTest.cs @@ -54,7 +54,7 @@ public class ForeachTemplateTest(ITestOutputHelper output) : WorkflowActionTempl AssertGeneratedCode(template.Id, workflowCode); AssertAgentProvider(template.UseAgentProvider, workflowCode); AssertGeneratedMethod(nameof(ForeachExecutor.TakeNextAsync), workflowCode); - AssertGeneratedMethod(nameof(ForeachExecutor.ResetAsync), workflowCode); + AssertGeneratedMethod(nameof(ForeachExecutor.CompleteAsync), workflowCode); } private Foreach CreateModel( diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/WorkflowActionTemplateTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/WorkflowActionTemplateTest.cs index ba74712c32..2f6cedb6dd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/WorkflowActionTemplateTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/WorkflowActionTemplateTest.cs @@ -34,11 +34,11 @@ public abstract class WorkflowActionTemplateTest(ITestOutputHelper output) : Wor { if (expected) { - Assert.Contains(", WorkflowAgentProvider agentProvider", workflowCode); + Assert.Contains($", {nameof(ResponseAgentProvider)} agentProvider", workflowCode); } else { - Assert.DoesNotContain(", WorkflowAgentProvider agentProvider", workflowCode); + Assert.DoesNotContain($", {nameof(ResponseAgentProvider)} agentProvider", workflowCode); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs index 20d9a32878..d3fa36ffac 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs @@ -14,7 +14,7 @@ public class DeclarativeWorkflowContextTests public void InitializeDefaultValues() { // Act - Mock mockProvider = new(MockBehavior.Strict); + Mock mockProvider = new(MockBehavior.Strict); DeclarativeWorkflowOptions context = new(mockProvider.Object); // Assert @@ -34,7 +34,7 @@ public class DeclarativeWorkflowContextTests ILoggerFactory loggerFactory = LoggerFactory.Create(builder => { }); // Act - Mock mockProvider = new(MockBehavior.Strict); + Mock mockProvider = new(MockBehavior.Strict); DeclarativeWorkflowOptions context = new(mockProvider.Object) { MaximumCallDepth = MaxCallDepth, diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowOptionsTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowOptionsTest.cs index 73c4792e32..64e14d66ac 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowOptionsTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowOptionsTest.cs @@ -65,7 +65,7 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable public void ConfigureTelemetry_DefaultIsNull() { // Arrange - Mock mockProvider = CreateMockProvider(); + Mock mockProvider = CreateMockProvider(); // Act DeclarativeWorkflowOptions options = new(mockProvider.Object); @@ -78,7 +78,7 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable public void ConfigureTelemetry_CanBeSet() { // Arrange - Mock mockProvider = CreateMockProvider(); + Mock mockProvider = CreateMockProvider(); bool callbackInvoked = false; // Act @@ -103,7 +103,7 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable public void TelemetryActivitySource_DefaultIsNull() { // Arrange - Mock mockProvider = CreateMockProvider(); + Mock mockProvider = CreateMockProvider(); // Act DeclarativeWorkflowOptions options = new(mockProvider.Object); @@ -116,7 +116,7 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable public void TelemetryActivitySource_CanBeSet() { // Arrange - Mock mockProvider = CreateMockProvider(); + Mock mockProvider = CreateMockProvider(); // Act DeclarativeWorkflowOptions options = new(mockProvider.Object) @@ -133,7 +133,7 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable { // Arrange using Activity testActivity = new Activity("DefaultTelemetryTest").Start()!; - Mock mockProvider = CreateMockProvider(); + Mock mockProvider = CreateMockProvider(); DeclarativeWorkflowOptions options = new(mockProvider.Object) { ConfigureTelemetry = _ => { }, @@ -161,7 +161,7 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable { // Arrange using Activity testActivity = new Activity("TelemetryActivitySourceTest").Start()!; - Mock mockProvider = CreateMockProvider(); + Mock mockProvider = CreateMockProvider(); DeclarativeWorkflowOptions options = new(mockProvider.Object) { TelemetryActivitySource = this._activitySource, @@ -188,7 +188,7 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable { // Arrange using Activity testActivity = new Activity("ConfigureTelemetryTest").Start()!; - Mock mockProvider = CreateMockProvider(); + Mock mockProvider = CreateMockProvider(); bool configureInvoked = false; DeclarativeWorkflowOptions options = new(mockProvider.Object) { @@ -223,7 +223,7 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable { // Arrange using Activity testActivity = new Activity("NoTelemetryTest").Start()!; - Mock mockProvider = CreateMockProvider(); + Mock mockProvider = CreateMockProvider(); DeclarativeWorkflowOptions options = new(mockProvider.Object) { LoggerFactory = NullLoggerFactory.Instance @@ -245,9 +245,9 @@ public sealed class DeclarativeWorkflowOptionsTest : IDisposable Assert.Empty(capturedActivities); } - private static Mock CreateMockProvider() + private static Mock CreateMockProvider() { - Mock mockAgentProvider = new(MockBehavior.Strict); + Mock mockAgentProvider = new(MockBehavior.Strict); mockAgentProvider .Setup(provider => provider.CreateConversationAsync(It.IsAny())) .Returns(() => Task.FromResult(Guid.NewGuid().ToString("N"))); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index 9a2a84f7e7..392988b07b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -52,7 +52,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow { await this.RunWorkflowAsync("LoopBreak.yaml"); this.AssertExecutionCount(expectedCount: 6); - this.AssertExecuted("foreach_loop"); + this.AssertExecuted("foreach_loop", isDiscrete: false); this.AssertExecuted("break_loop_now"); this.AssertExecuted("end_all"); this.AssertNotExecuted("set_variable_inner"); @@ -64,7 +64,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow { await this.RunWorkflowAsync("LoopContinue.yaml"); this.AssertExecutionCount(expectedCount: 22); - this.AssertExecuted("foreach_loop"); + this.AssertExecuted("foreach_loop", isDiscrete: false); this.AssertExecuted("continue_loop_now"); this.AssertExecuted("end_all"); this.AssertNotExecuted("set_variable_inner"); @@ -103,7 +103,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow this.AssertExecuted("conditionGroup_test"); if (input % 2 == 0) { - this.AssertExecuted("conditionItem_even", isScope: true); + this.AssertExecuted("conditionItem_even", isAction: false); this.AssertExecuted("sendActivity_even"); this.AssertNotExecuted("conditionItem_odd"); this.AssertNotExecuted("sendActivity_odd"); @@ -111,7 +111,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow } else { - this.AssertExecuted("conditionItem_odd", isScope: true); + this.AssertExecuted("conditionItem_odd", isAction: false); this.AssertExecuted("sendActivity_odd"); this.AssertNotExecuted("conditionItem_even"); this.AssertNotExecuted("sendActivity_even"); @@ -131,13 +131,13 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow this.AssertExecuted("conditionGroup_test"); if (input % 2 == 0) { - this.AssertExecuted("sendActivity_else", isScope: true); + this.AssertExecuted("sendActivity_else", isAction: false); this.AssertNotExecuted("conditionItem_odd"); this.AssertNotExecuted("sendActivity_odd"); } else { - this.AssertExecuted("conditionItem_odd", isScope: true); + this.AssertExecuted("conditionItem_odd", isAction: false); this.AssertExecuted("sendActivity_odd"); this.AssertNotExecuted("sendActivity_else"); } @@ -152,7 +152,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow await this.RunWorkflowAsync("ConditionFallThrough.yaml", input); this.AssertExecutionCount(expectedActions); this.AssertExecuted("setVariable_test"); - this.AssertExecuted("conditionGroup_test", isScope: true); + this.AssertExecuted("conditionGroup_test", isAction: false); if (input % 2 == 0) { this.AssertNotExecuted("conditionItem_odd"); @@ -160,7 +160,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow } else { - this.AssertExecuted("conditionItem_odd", isScope: true); + this.AssertExecuted("conditionItem_odd", isAction: false); this.AssertExecuted("sendActivity_odd"); this.AssertMessage("ODD"); } @@ -239,7 +239,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow AdaptiveDialog dialog = dialogBuilder.Build(); WorkflowFormulaState state = new(RecalcEngineFactory.Create()); - Mock mockAgentProvider = CreateMockProvider("1"); + Mock mockAgentProvider = CreateMockProvider("1"); DeclarativeWorkflowOptions options = new(mockAgentProvider.Object); WorkflowActionVisitor visitor = new(new DeclarativeWorkflowExecutor(WorkflowActionVisitor.Steps.Root("anything"), options, state, (message) => DeclarativeWorkflowBuilder.DefaultTransform(message)), state, options); WorkflowElementWalker walker = new(visitor); @@ -307,14 +307,17 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow Assert.DoesNotContain(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); } - private void AssertExecuted(string executorId, bool isScope = false) + private void AssertExecuted(string executorId, bool isAction = true, bool isDiscrete = true) { Assert.Contains(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); Assert.Contains(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); - if (!isScope) + if (isAction) { Assert.Contains(this.WorkflowEvents.OfType(), e => e.ActionId == executorId); - Assert.Contains(this.WorkflowEvents.OfType(), e => e.ActionId == executorId); + if (isDiscrete) + { + Assert.Contains(this.WorkflowEvents.OfType(), e => e.ActionId == executorId); + } } } @@ -371,14 +374,14 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow private Workflow CreateWorkflow(string workflowPath, TInput workflowInput) where TInput : notnull { using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath)); - Mock mockAgentProvider = CreateMockProvider($"{workflowInput}"); + Mock mockAgentProvider = CreateMockProvider($"{workflowInput}"); DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output }; return DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); } - private static Mock CreateMockProvider(string input) + private static Mock CreateMockProvider(string input) { - Mock mockAgentProvider = new(MockBehavior.Strict); + Mock mockAgentProvider = new(MockBehavior.Strict); mockAgentProvider.Setup(provider => provider.CreateConversationAsync(It.IsAny())).Returns(() => Task.FromResult(Guid.NewGuid().ToString("N"))); mockAgentProvider.Setup(provider => provider.CreateMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.FromResult(new ChatMessage(ChatRole.Assistant, input))); return mockAgentProvider; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/DeclarativeWorkflowOptionsExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/DeclarativeWorkflowOptionsExtensionsTests.cs index 85fa389283..ce1b44ef02 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/DeclarativeWorkflowOptionsExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/DeclarativeWorkflowOptionsExtensionsTests.cs @@ -58,7 +58,7 @@ public sealed class DeclarativeWorkflowOptionsExtensionsTests int? maximumExpressionLength = null, int? maximumCallDepth = null) { - Mock providerMock = new(MockBehavior.Strict); + Mock providerMock = new(MockBehavior.Strict); return new(providerMock.Object) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/MockAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/MockAgentProvider.cs index 5a55dd297a..82987028fb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/MockAgentProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/MockAgentProvider.cs @@ -11,13 +11,13 @@ using Moq; namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests; /// -/// Mock implementation of for unit testing purposes. +/// Mock implementation of for unit testing purposes. /// -internal sealed class MockAgentProvider : Mock +internal sealed class MockAgentProvider : Mock { public IList ExistingConversationIds { get; } = []; - public List? TestMessages { get; set; } + public List TestMessages { get; set; } = []; public MockAgentProvider() { @@ -45,7 +45,7 @@ internal sealed class MockAgentProvider : Mock It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(testMessages.First())); + .Returns((conversationId, message, cancellationToken) => Task.FromResult(this.CaptureChatMessage(message))); } private string CreateConversationId() @@ -56,6 +56,13 @@ internal sealed class MockAgentProvider : Mock return newConversationId; } + private ChatMessage CaptureChatMessage(ChatMessage message) + { + this.TestMessages.Add(message); + + return message; + } + private List CreateMessages() { // Create test messages diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/AddConversationMessageExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/AddConversationMessageExecutorTest.cs index b5cdeadaeb..a7f2ba48f6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/AddConversationMessageExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/AddConversationMessageExecutorTest.cs @@ -1,11 +1,14 @@ // 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; @@ -28,20 +31,64 @@ public sealed class AddConversationMessageExecutorTest(ITestOutputHelper output) messageText: $"Hello from {role}"); } + [Theory] + [InlineData(AgentMessageRole.User)] + [InlineData(AgentMessageRole.Agent)] + public async Task AddMessageToWorkflowAsync(AgentMessageRole role) + { + // Arrange + this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System); + + // Act & Assert + await this.ExecuteTestAsync( + displayName: nameof(AddMessageToWorkflowAsync), + variableName: "TestMessage", + role: AgentMessageRoleWrapper.Get(role), + conversationId: "WorkflowConversationId", + messageText: $"Hello from {role}"); + } + + [Theory] + [InlineData(AgentMessageRole.User)] + [InlineData(AgentMessageRole.Agent)] + public async Task AddMessageWithMetadataAsync(AgentMessageRole role) + { + // Arrange + Dictionary metadataValues = + new() + { + ["Key1"] = "Value1", + ["Key2"] = "Value2", + }; + RecordDataValue metadataRecord = metadataValues.ToRecordValue(); + + // Act & Assert + await this.ExecuteTestAsync( + displayName: nameof(AddMessageWithMetadataAsync), + variableName: "TestMessage", + role: AgentMessageRoleWrapper.Get(role), + messageText: $"Hello from {role}", + metadata: metadataRecord); + } + private async Task ExecuteTestAsync( string displayName, string variableName, AgentMessageRoleWrapper role, - string messageText) + string messageText, + string? conversationId = null, + RecordDataValue? metadata = null) { // Arrange MockAgentProvider mockAgentProvider = new(); - AddConversationMessage model = this.CreateModel( - this.FormatDisplayName(displayName), - FormatVariablePath(variableName), - "TestConversationId", - role, - messageText); + AddConversationMessage model = + this.CreateModel( + this.FormatDisplayName(displayName), + FormatVariablePath(variableName), + conversationId ?? "TestConversationId", + role, + messageText, + metadata); AddConversationMessageExecutor action = new(model, mockAgentProvider.Object, this.State); @@ -49,10 +96,15 @@ public sealed class AddConversationMessageExecutorTest(ITestOutputHelper output) await this.ExecuteAsync(action); // Assert - ChatMessage? testMessage = mockAgentProvider.TestMessages?.FirstOrDefault(); + ChatMessage? testMessage = mockAgentProvider.TestMessages?.LastOrDefault(); Assert.NotNull(testMessage); VerifyModel(model, action); this.VerifyState(variableName, testMessage.ToRecord()); + if (metadata is not null) + { + Assert.NotNull(testMessage.AdditionalProperties); + Assert.NotEmpty(testMessage.AdditionalProperties); + } } private AddConversationMessage CreateModel( @@ -60,8 +112,15 @@ public sealed class AddConversationMessageExecutorTest(ITestOutputHelper output) string messageVariable, string conversationId, AgentMessageRoleWrapper role, - string messageText) + string messageText, + RecordDataValue? metadata) { + ObjectExpression.Builder? metadataExpression = null; + if (metadata is not null) + { + metadataExpression = ObjectExpression.Literal(metadata).ToBuilder(); + } + AddConversationMessage.Builder actionBuilder = new() { @@ -70,6 +129,7 @@ public sealed class AddConversationMessageExecutorTest(ITestOutputHelper output) Message = PropertyPath.Create(messageVariable), ConversationId = StringExpression.Literal(conversationId), Role = role, + Metadata = metadataExpression, }; actionBuilder.Content.Add(new AddConversationMessageContent.Builder diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ClearAllVariablesExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ClearAllVariablesExecutorTest.cs index 5ccea000e1..70e4ac0a02 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ClearAllVariablesExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ClearAllVariablesExecutorTest.cs @@ -13,47 +13,91 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; /// public sealed class ClearAllVariablesExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) { + [Fact] + public async Task ClearGlobalScopeAsync() + { + // Arrange + this.State.Set("GlobalVar", FormulaValue.New("Old value"), VariableScopeNames.Global); + + // Act & Assert + await this.ExecuteTestAsync( + this.FormatDisplayName(nameof(ClearGlobalScopeAsync)), + VariablesToClear.AllGlobalVariables, + "GlobalVar", + VariableScopeNames.Global); + } + [Fact] public async Task ClearWorkflowScopeAsync() { // Arrange - this.State.Set("NoVar", FormulaValue.New("Old value")); + this.State.Set("LocalVar", FormulaValue.New("Old value")); + + // Act & Assert + await this.ExecuteTestAsync( + this.FormatDisplayName(nameof(ClearWorkflowScopeAsync)), + VariablesToClear.ConversationScopedVariables, + "LocalVar"); + } + + [Fact] + public async Task ClearUserScopeAsync() + { + // Arrange + this.State.Set("LocalVar", FormulaValue.New("Old value")); + + // Act & Assert + await this.ExecuteTestAsync( + this.FormatDisplayName(nameof(ClearUserScopeAsync)), + VariablesToClear.UserScopedVariables, + "LocalVar", + expectedValue: FormulaValue.New("Old value")); + } + + [Fact] + public async Task ClearWorkflowHistoryAsync() + { + // Arrange + this.State.Set("LocalVar", FormulaValue.New("Old value")); + + // Act & Assert + await this.ExecuteTestAsync( + this.FormatDisplayName(nameof(ClearWorkflowHistoryAsync)), + VariablesToClear.ConversationHistory, + "LocalVar", + expectedValue: FormulaValue.New("Old value")); + } + + private async Task ExecuteTestAsync( + string displayName, + VariablesToClear scope, + string variableName, + string variableScope = VariableScopeNames.Local, + FormulaValue? expectedValue = null) + { + // Arrange + ClearAllVariables model = this.CreateModel( + this.FormatDisplayName(displayName), + scope); + + ClearAllVariablesExecutor action = new(model, this.State); + this.State.Bind(); - ClearAllVariables model = - this.CreateModel( - this.FormatDisplayName(nameof(ClearWorkflowScopeAsync)), - VariablesToClear.ConversationScopedVariables); - // Act - ClearAllVariablesExecutor action = new(model, this.State); await this.ExecuteAsync(action); // Assert VerifyModel(model, action); this.VerifyUndefined("NoVar"); - } - - [Fact] - public async Task ClearUndefinedScopeAsync() - { - // Arrange - this.State.Set("NoVar", FormulaValue.New("Old value")); - this.State.Bind(); - - // Arrange - ClearAllVariables model = - this.CreateModel( - this.FormatDisplayName(nameof(ClearUndefinedScopeAsync)), - VariablesToClear.UserScopedVariables); - - // Act - ClearAllVariablesExecutor action = new(model, this.State); - await this.ExecuteAsync(action); - - // Assert - VerifyModel(model, action); - this.VerifyState("NoVar", FormulaValue.New("Old value")); + if (expectedValue is null) + { + this.VerifyUndefined(variableName, variableScope); + } + else + { + this.VerifyState(variableName, variableScope, expectedValue); + } } private ClearAllVariables CreateModel(string displayName, VariablesToClear variableTarget) 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/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/EditTableV2ExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/EditTableV2ExecutorTest.cs new file mode 100644 index 0000000000..5eb723ae0e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/EditTableV2ExecutorTest.cs @@ -0,0 +1,345 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +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 EditTableV2ExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public void InvalidModelNullItemsVariable() + { + // Arrange + EditTableV2 model = new EditTableV2.Builder + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(nameof(InvalidModelNullItemsVariable)), + ItemsVariable = null, + ChangeType = new AddItemOperation.Builder + { + Value = new ValueExpression.Builder(ValueExpression.Literal(new StringDataValue("test"))) + }.Build() + }.Build(); + + // Act, Assert + DeclarativeModelException exception = Assert.Throws(() => new EditTableV2Executor(model, this.State)); + Assert.Contains("required", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task InvalidModelVariableNotTableAsync() + { + // Arrange + this.State.Set("NotATable", FormulaValue.New("I am a string")); + + EditTableV2 model = this.CreateModel( + nameof(InvalidModelVariableNotTableAsync), + "NotATable", + new AddItemOperation.Builder + { + Value = new ValueExpression.Builder(ValueExpression.Literal(new StringDataValue("test"))) + }.Build()); + + EditTableV2Executor action = new(model, this.State); + + // Act & Assert + await Assert.ThrowsAsync(async () => await this.ExecuteAsync(action)); + } + + [Fact] + public async Task InvalidModelAddItemOperationNullValueAsync() + { + // Arrange + EditTableV2 model = new EditTableV2.Builder + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(nameof(InvalidModelAddItemOperationNullValueAsync)), + ItemsVariable = PropertyPath.Create(FormatVariablePath("TestTable")), + ChangeType = new AddItemOperation.Builder + { + Value = null + }.Build() + }.Build(); + + RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); + TableValue tableValue = FormulaValue.NewTable(recordType); + this.State.Set("TestTable", tableValue); + + // Act, Assert + EditTableV2Executor action = new(model, this.State); + await Assert.ThrowsAsync(async () => await this.ExecuteAsync(action)); + } + + [Fact] + public async Task InvalidModelRemoveItemOperationNullValueAsync() + { + // Arrange + EditTableV2 model = new EditTableV2.Builder + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(nameof(InvalidModelRemoveItemOperationNullValueAsync)), + ItemsVariable = PropertyPath.Create(FormatVariablePath("TestTable")), + ChangeType = new RemoveItemOperation.Builder + { + Value = null + }.Build() + }.Build(); + + RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); + TableValue tableValue = FormulaValue.NewTable(recordType); + this.State.Set("TestTable", tableValue); + + // Act, Assert + EditTableV2Executor action = new(model, this.State); + await Assert.ThrowsAsync(async () => await this.ExecuteAsync(action)); + } + + [Fact] + public async Task RemoveItemOperationNonTableValueAsync() + { + // Arrange + RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); + RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1"))); + TableValue tableValue = FormulaValue.NewTable(recordType, record1); + this.State.Set("TestTable", tableValue); + + // Set a string value instead of a table for removal + this.State.Set("RemoveItems", FormulaValue.New("NotATable")); + + EditTableV2 model = new EditTableV2.Builder + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(nameof(RemoveItemOperationNonTableValueAsync)), + ItemsVariable = PropertyPath.Create(FormatVariablePath("TestTable")), + ChangeType = new RemoveItemOperation.Builder + { + Value = new ValueExpression.Builder(ValueExpression.Variable(PropertyPath.TopicVariable("RemoveItems"))) + }.Build() + }.Build(); + + // Act + EditTableV2Executor action = new(model, this.State); + await this.ExecuteAsync(action); + + // Assert: When the remove value is not a table, no removal occurs, so the table should be unchanged + FormulaValue value = this.State.Get("TestTable"); + Assert.IsAssignableFrom(value); + TableValue resultTable = (TableValue)value; + Assert.Single(resultTable.Rows); + } + + [Fact] + public async Task AddItemOperationWithSingleFieldRecordAsync() + { + // Arrange: Create an empty table with single field + RecordType recordType = RecordType.Empty().Add("Name", FormulaType.String); + TableValue tableValue = FormulaValue.NewTable(recordType); + this.State.Set("TestTable", tableValue); + + // Arrange, Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(AddItemOperationWithSingleFieldRecordAsync), + variableName: "TestTable", + changeType: this.CreateAddItemOperation(new RecordDataValue.Builder + { + Properties = + { + ["Name"] = new StringDataValue("John") + } + }.Build()), + verifyAction: (variableName, recordValue) => + Assert.Equal("John", recordValue.GetField("Name").ToObject()) + ); + } + + [Fact] + public async Task AddItemOperationWithScalarValueAsync() + { + // Arrange: Create an empty table with single field + RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); + TableValue tableValue = FormulaValue.NewTable(recordType); + this.State.Set("TestTable", tableValue); + + // Act & Assert + await this.ExecuteTestAsync( + displayName: nameof(AddItemOperationWithScalarValueAsync), + variableName: "TestTable", + changeType: this.CreateAddItemOperation(new StringDataValue("TestValue")), + verifyAction: (variableName, recordValue) => + Assert.Equal("TestValue", recordValue.GetField("Value").ToObject()) + ); + } + + [Fact] + public async Task ClearItemsOperationAsync() + { + // Arrange: Create a table with some items + RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); + RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1"))); + RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2"))); + TableValue tableValue = FormulaValue.NewTable(recordType, record1, record2); + this.State.Set("TestTable", tableValue); + + // Act & Assert + await this.ExecuteTestAsync( + displayName: nameof(ClearItemsOperationAsync), + variableName: "TestTable", + changeType: new ClearItemsOperation.Builder().Build()); + } + + [Fact] + public async Task RemoveItemOperationAsync() + { + // Arrange: Create a table with some items + RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); + RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1"))); + RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2"))); + TableValue tableValue = FormulaValue.NewTable(recordType, record1, record2); + this.State.Set("TestTable", tableValue); + + // Act & Assert + await this.ExecuteTestAsync( + displayName: nameof(RemoveItemOperationAsync), + variableName: "TestTable", + changeType: this.CreateRemoveItemOperation("Item1")); + } + + [Fact] + public async Task TakeLastItemOperationWithItemsAsync() + { + // Arrange: Create a table with some items + RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); + RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1"))); + RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2"))); + RecordValue record3 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item3"))); + TableValue tableValue = FormulaValue.NewTable(recordType, record1, record2, record3); + this.State.Set("TestTable", tableValue); + + // Arrange, Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(TakeLastItemOperationWithItemsAsync), + variableName: "TestTable", + changeType: new TakeLastItemOperation.Builder().Build(), + verifyAction: (variableName, recordValue) => + Assert.Equal("Item3", recordValue.GetField("Value").ToObject()) + ); + } + + [Fact] + public async Task TakeLastItemOperationEmptyTableAsync() + { + // Arrange: Create an empty table + RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); + TableValue tableValue = FormulaValue.NewTable(recordType); + this.State.Set("TestTable", tableValue); + + // Arrange, Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(TakeLastItemOperationEmptyTableAsync), + variableName: "TestTable", + changeType: new TakeLastItemOperation.Builder().Build()); + } + + [Fact] + public async Task TakeFirstItemOperationWithItemsAsync() + { + // Arrange: Create a table with some items + RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); + RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1"))); + RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2"))); + RecordValue record3 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item3"))); + TableValue tableValue = FormulaValue.NewTable(recordType, record1, record2, record3); + this.State.Set("TestTable", tableValue); + + // Act & Assert + await this.ExecuteTestAsync( + displayName: nameof(TakeFirstItemOperationWithItemsAsync), + variableName: "TestTable", + changeType: new TakeFirstItemOperation.Builder().Build(), + verifyAction: (variableName, recordValue) => + Assert.Equal("Item1", recordValue.GetField("Value").ToObject()) + ); + } + + [Fact] + public async Task TakeFirstItemOperationEmptyTableAsync() + { + // Arrange: Create an empty table + RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); + TableValue tableValue = FormulaValue.NewTable(recordType); + this.State.Set("TestTable", tableValue); + + // Act & Assert + await this.ExecuteTestAsync( + displayName: nameof(TakeFirstItemOperationEmptyTableAsync), + variableName: "TestTable", + changeType: new TakeFirstItemOperation.Builder().Build()); + } + + private async Task ExecuteTestAsync( + string displayName, + string variableName, + EditTableOperation changeType, + Action? verifyAction = null) where TValue : FormulaValue + { + // Arrange + EditTableV2 model = this.CreateModel(displayName, variableName, changeType); + + EditTableV2Executor action = new(model, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + FormulaValue value = this.State.Get(variableName); + TValue typedValue = Assert.IsAssignableFrom(value); + verifyAction?.Invoke(variableName, typedValue); + } + + private EditTableV2 CreateModel(string displayName, string variableName, EditTableOperation changeType) + { + EditTableV2.Builder actionBuilder = new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + ItemsVariable = PropertyPath.Create(FormatVariablePath(variableName)), + ChangeType = changeType + }; + + return AssignParent(actionBuilder); + } + + private AddItemOperation CreateAddItemOperation(DataValue value) + { + return new AddItemOperation.Builder + { + Value = new ValueExpression.Builder(ValueExpression.Literal(value)) + }.Build(); + } + + private RemoveItemOperation CreateRemoveItemOperation(string itemValue) + { + // Create a table with the item to remove + RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); + RecordValue recordToRemove = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New(itemValue))); + TableValue tableToRemove = FormulaValue.NewTable(recordType, recordToRemove); + + // Store in state for expression evaluation + this.State.Set("RemoveItems", tableToRemove); + this.State.Bind(); + + return new RemoveItemOperation.Builder + { + Value = new ValueExpression.Builder(ValueExpression.Variable(PropertyPath.TopicVariable("RemoveItems"))) + }.Build(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ForeachExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ForeachExecutorTest.cs new file mode 100644 index 0000000000..559ca2a33a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ForeachExecutorTest.cs @@ -0,0 +1,320 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +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 ForeachExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public void ForeachThrowsWhenModelInvalid() => + // Arrange, Act & Assert + Assert.Throws(() => new ForeachExecutor(new Foreach(), this.State)); + + [Fact] + public void ForeachNamingConvention() + { + // Arrange + string testId = this.CreateActionId().Value; + + // Act + string startStep = ForeachExecutor.Steps.Start(testId); + string nextStep = ForeachExecutor.Steps.Next(testId); + string endStep = ForeachExecutor.Steps.End(testId); + + // Assert + Assert.Equal($"{testId}_{nameof(ForeachExecutor.Steps.Start)}", startStep); + Assert.Equal($"{testId}_{nameof(ForeachExecutor.Steps.Next)}", nextStep); + Assert.Equal($"{testId}_{nameof(ForeachExecutor.Steps.End)}", endStep); + } + + [Fact] + public async Task ForeachInvokedWithSingleValueAsync() + { + // Arrange + this.SetVariableState("CurrentValue"); + + // Act & Assert + await this.ExecuteTestAsync( + displayName: nameof(ForeachInvokedWithSingleValueAsync), + items: ValueExpression.Literal(new NumberDataValue(42)), + valueName: "CurrentValue", + indexName: null); + } + + [Fact] + public async Task ForeachInvokedWithTableValueAsync() + { + // Arrange + this.SetVariableState("CurrentValue"); + + // Act & Assert + await this.ExecuteTestAsync( + displayName: nameof(ForeachInvokedWithTableValueAsync), + items: ValueExpression.Literal(DataValue.EmptyTable), + valueName: "CurrentValue", + indexName: null); + } + + [Fact] + public async Task ForeachInvokedWithIndexAsync() + { + // Arrange + this.SetVariableState("CurrentValue", "CurrentIndex"); + TableDataValue tableValue = DataValue.TableFromRecords( + DataValue.RecordFromFields(new KeyValuePair("item", new NumberDataValue(1))), + DataValue.RecordFromFields(new KeyValuePair("item", new NumberDataValue(2))), + DataValue.RecordFromFields(new KeyValuePair("item", new NumberDataValue(3)))); + + // Act & Assert + await this.ExecuteTestAsync( + displayName: nameof(ForeachInvokedWithIndexAsync), + items: ValueExpression.Literal(tableValue), + valueName: "CurrentValue", + indexName: "CurrentIndex"); + } + + [Fact] + public async Task ForeachInvokedWithExpressionAsync() + { + // Arrange + this.SetVariableState("CurrentValue"); + this.State.Set("SourceArray", FormulaValue.NewTable(RecordType.Empty())); + + // Act & Assert + await this.ExecuteTestAsync( + displayName: nameof(ForeachInvokedWithExpressionAsync), + items: ValueExpression.Variable(PropertyPath.TopicVariable("SourceArray")), + valueName: "CurrentValue", + indexName: null); + } + + [Fact] + public async Task ForeachTakeNextAsync() + { + // Arrange + this.SetVariableState("CurrentValue"); + this.State.Set( + "SourceArray", + FormulaValue.NewTable( + RecordType.Empty(), + FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(10))), + FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(20))), + FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(30))))); + + // Act & Assert + await this.TakeNextTestAsync( + displayName: nameof(ForeachTakeNextAsync), + items: ValueExpression.Variable(PropertyPath.TopicVariable("SourceArray")), + valueName: "CurrentValue", + indexName: null); + } + + [Fact] + public async Task ForeachTakeNextWithIndexAsync() + { + // Arrange + this.SetVariableState("CurrentValue", "CurrentIndex"); + this.State.Set( + "SourceArray", + FormulaValue.NewTable( + RecordType.Empty(), + FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(10))), + FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(20))), + FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(30))))); + + // Act & Assert + await this.TakeNextTestAsync( + displayName: nameof(ForeachTakeNextWithIndexAsync), + items: ValueExpression.Variable(PropertyPath.TopicVariable("SourceArray")), + valueName: "CurrentValue", + indexName: "CurrentIndex"); + } + + [Fact] + public async Task ForeachTakeLastAsync() + { + // Arrange + this.SetVariableState("CurrentValue"); + this.State.Set( + "SourceArray", + FormulaValue.NewTable( + RecordType.Empty(), + FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(10))))); + + // Act & Assert + await this.TakeNextTestAsync( + displayName: nameof(ForeachTakeLastAsync), + items: ValueExpression.Variable(PropertyPath.TopicVariable("SourceArray")), + valueName: "CurrentValue", + indexName: null); + } + + [Fact] + public async Task ForeachTakeNextWhenDoneAsync() + { + // Arrange + this.SetVariableState("CurrentValue"); + + // Act & Assert + await this.TakeNextTestAsync( + displayName: nameof(ForeachTakeNextWhenDoneAsync), + items: ValueExpression.Literal(DataValue.EmptyTable), + valueName: "CurrentValue", + indexName: null, + expectValue: false); + } + + [Fact] + public async Task ForeachCompletedWithoutIndexAsync() + { + // Arrange + this.SetVariableState("CurrentValue"); + + // Act & Assert + await this.CompletedTestAsync( + displayName: nameof(ForeachCompletedWithoutIndexAsync), + valueName: "CurrentValue", + indexName: null); + } + + [Fact] + public async Task ForeachCompletedWithIndexAsync() + { + // Arrange + this.SetVariableState("CurrentValue", "CurrentIndex"); + + // Act & Assert + await this.CompletedTestAsync( + displayName: nameof(ForeachCompletedWithIndexAsync), + valueName: "CurrentValue", + indexName: "CurrentIndex"); + } + + private void SetVariableState(string valueName, string? indexName = null, FormulaValue? valueState = null) + { + this.State.Set(valueName, valueState ?? FormulaValue.New("something")); + if (indexName is not null) + { + this.State.Set(indexName, FormulaValue.New(33)); + } + } + + private async Task ExecuteTestAsync( + string displayName, + ValueExpression items, + string valueName, + string? indexName, + bool expectValue = false) + { + // Arrange + Foreach model = this.CreateModel(displayName, items, valueName, indexName); + ForeachExecutor action = new(model, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false); + + // Assert + VerifyModel(model, action); + VerifyInvocationEvent(events); + + // IsDiscreteAction should be false for Foreach + Assert.Equal( + false, + action.GetType().BaseType? + .GetProperty("IsDiscreteAction", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)? + .GetValue(action)); + + // Verify HasValue state after execution + Assert.Equal(expectValue, action.HasValue); + + // Verify value was reset at the end + this.VerifyUndefined(valueName); + + // Verify index was reset at the end if it was used + if (indexName is not null) + { + this.VerifyUndefined(indexName); + } + } + + private async Task TakeNextTestAsync( + string displayName, + ValueExpression items, + string valueName, + string? indexName, + bool expectValue = true) + { + // Arrange + Foreach model = this.CreateModel(displayName, items, valueName, indexName); + ForeachExecutor action = new(model, this.State); + + // Act + await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync); + + // Assert + VerifyModel(model, action); + + // Verify HasValue state after execution + Assert.Equal(expectValue, action.HasValue); + } + + private async Task CompletedTestAsync( + string displayName, + string valueName, + string? indexName) + { + // Arrange + Foreach model = this.CreateModel(displayName, ValueExpression.Literal(DataValue.EmptyTable), valueName, indexName); + ForeachExecutor action = new(model, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync(ForeachExecutor.Steps.End(action.Id), action.CompleteAsync); + + // Assert + VerifyModel(model, action); + VerifyCompletionEvent(events); + + // Verify HasValue state after completion + Assert.False(action.HasValue); + + // Verify value was reset at the end + this.VerifyUndefined(valueName); + + // Verify index was reset at the end if it was used + if (indexName is not null) + { + this.VerifyUndefined(indexName); + } + } + + private Foreach CreateModel( + string displayName, + ValueExpression items, + string valueName, + string? indexName) + { + Foreach.Builder actionBuilder = new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + Items = items, + Value = PropertyPath.Create(FormatVariablePath(valueName)), + }; + + if (indexName is not null) + { + actionBuilder.Index = PropertyPath.Create(FormatVariablePath(indexName)); + } + + return AssignParent(actionBuilder); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs index 0591066471..22854c90e8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs @@ -25,95 +25,71 @@ public sealed class ParseValueExecutorTest(ITestOutputHelper output) : WorkflowA {"key1", new PropertyInfo.Builder() { Type = DataType.String } }, } }; - ParseValue model = - this.CreateModel( - this.FormatDisplayName(nameof(ParseRecordAsync)), - recordBuilder, - @"{ ""key1"": ""val1"" }"); - // Act - ParseValueExecutor action = new(model, this.State); - await this.ExecuteAsync(action); - - // Assert - VerifyModel(model, action); - this.VerifyState("Target", FormulaValue.NewRecordFromFields(new NamedValue("key1", FormulaValue.New("val1")))); + // Act & Assert + await this.ExecuteTestAsync( + this.FormatDisplayName(nameof(ParseRecordAsync)), + recordBuilder, + @"{ ""key1"": ""val1"" }", + FormulaValue.NewRecordFromFields(new NamedValue("key1", FormulaValue.New("val1")))); } [Fact] public async Task ParseTableAsync() { - // Arrange - RecordDataType.Builder recordBuilder = - new() - { - Properties = - { - {"key1", new PropertyInfo.Builder() { Type = DataType.String } }, - } - }; - ParseValue model = - this.CreateModel( - this.FormatDisplayName(nameof(ParseTableAsync)), - DataType.EmptyTable, - @"[""apple"",""banana"",""cat""]"); - - // Act - ParseValueExecutor action = new(model, this.State); - await this.ExecuteAsync(action); - - // Assert - VerifyModel(model, action); - this.VerifyState("Target", FormulaValue.NewSingleColumnTable(FormulaValue.New("apple"), FormulaValue.New("banana"), FormulaValue.New("cat"))); + // Arrange, Act & Assert + await this.ExecuteTestAsync( + this.FormatDisplayName(nameof(ParseTableAsync)), + DataType.EmptyTable, + @"[""apple"",""banana"",""cat""]", + FormulaValue.NewSingleColumnTable(FormulaValue.New("apple"), FormulaValue.New("banana"), FormulaValue.New("cat"))); } [Fact] public async Task ParseBooleanAsync() { - // Arrange - ParseValue model = - this.CreateModel( - this.FormatDisplayName(nameof(ParseTableAsync)), - new BooleanDataType.Builder(), - "True"); - - // Act - ParseValueExecutor action = new(model, this.State); - await this.ExecuteAsync(action); - - // Assert - VerifyModel(model, action); - this.VerifyState("Target", FormulaValue.New(true)); + // Arrange, Act & Assert + await this.ExecuteTestAsync( + this.FormatDisplayName(nameof(ParseBooleanAsync)), + new BooleanDataType.Builder(), + "True", + FormulaValue.New(true)); } [Fact] public async Task ParseNumberAsync() { - // Arrange - ParseValue model = - this.CreateModel( - this.FormatDisplayName(nameof(ParseNumberAsync)), - new NumberDataType.Builder(), - "42"); - - // Act - ParseValueExecutor action = new(model, this.State); - await this.ExecuteAsync(action); - - // Assert - VerifyModel(model, action); - this.VerifyState("Target", FormulaValue.New(42)); + // Arrange, Act & Assert + await this.ExecuteTestAsync( + this.FormatDisplayName(nameof(ParseNumberAsync)), + new NumberDataType.Builder(), + "42", + FormulaValue.New(42)); } [Fact] public async Task ParseStringAsync() { - // Arrange + // Arrange, Act & Assert + await this.ExecuteTestAsync( + this.FormatDisplayName(nameof(ParseStringAsync)), + new StringDataType.Builder(), + "Hello, World!", + FormulaValue.New("Hello, World!")); + } + + private async Task ExecuteTestAsync( + string displayName, + DataType.Builder dataBuilder, + string sourceText, + FormulaValue expectedValue) + { ParseValue model = this.CreateModel( - this.FormatDisplayName(nameof(ParseStringAsync)), - new StringDataType.Builder(), - "Hello, World!"); + displayName, + "Target", + dataBuilder, + sourceText); // Act ParseValueExecutor action = new(model, this.State); @@ -121,10 +97,14 @@ public sealed class ParseValueExecutorTest(ITestOutputHelper output) : WorkflowA // Assert VerifyModel(model, action); - this.VerifyState("Target", FormulaValue.New("Hello, World!")); + this.VerifyState("Target", expectedValue); } - private ParseValue CreateModel(string displayName, DataType.Builder typeBuilder, string sourceText) + private ParseValue CreateModel( + string displayName, + string variableName, + DataType.Builder typeBuilder, + string sourceText) { ParseValue.Builder actionBuilder = new() @@ -132,7 +112,7 @@ public sealed class ParseValueExecutorTest(ITestOutputHelper output) : WorkflowA Id = this.CreateActionId(), DisplayName = this.FormatDisplayName(displayName), ValueType = typeBuilder, - Variable = PropertyPath.TopicVariable("Target"), + Variable = PropertyPath.TopicVariable(variableName), Value = new ValueExpression.Builder(ValueExpression.Literal(StringDataValue.Create(sourceText))), }; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ResetVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ResetVariableExecutorTest.cs index ad77cb602e..9059780751 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ResetVariableExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ResetVariableExecutorTest.cs @@ -63,7 +63,7 @@ public sealed class ResetVariableExecutorTest(ITestOutputHelper output) : Workfl { Id = this.CreateActionId(), DisplayName = this.FormatDisplayName(displayName), - Variable = InitializablePropertyPath.Create(variablePath), + Variable = PropertyPath.Create(variablePath), }; return AssignParent(actionBuilder); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SetMultipleVariablesExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SetMultipleVariablesExecutorTest.cs index b25fff59b8..037ee5b94a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SetMultipleVariablesExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SetMultipleVariablesExecutorTest.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; @@ -32,7 +33,6 @@ public sealed class SetMultipleVariablesExecutorTest(ITestOutputHelper output) : // Arrange this.State.Set("SourceNumber", FormulaValue.New(10)); this.State.Set("SourceText", FormulaValue.New("Hello")); - this.State.Bind(); // Act, Assert await this.ExecuteTestAsync( @@ -50,7 +50,6 @@ public sealed class SetMultipleVariablesExecutorTest(ITestOutputHelper output) : // Arrange this.State.Set("Source1", FormulaValue.New(123)); this.State.Set("Source2", FormulaValue.New("Reference")); - this.State.Bind(); // Act, Assert await this.ExecuteTestAsync( @@ -74,6 +73,19 @@ public sealed class SetMultipleVariablesExecutorTest(ITestOutputHelper output) : ]); } + [Fact] + public async Task SetMultipleVariablesWithNullVariableAsync() + { + // Arrange, Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(SetMultipleVariablesWithNullVariableAsync), + assignments: [ + new AssignmentCase("NullVar1", null, FormulaValue.NewBlank()), + new AssignmentCase(null, new StringDataValue("NotNull"), FormulaValue.New("NotNull")), + new AssignmentCase("NullVar2", null, FormulaValue.NewBlank()) + ]); + } + [Fact] public async Task SetMultipleVariablesUpdateExistingAsync() { @@ -116,9 +128,9 @@ public sealed class SetMultipleVariablesExecutorTest(ITestOutputHelper output) : // Assert VerifyModel(model, action); - foreach (AssignmentCase assignment in assignments) + foreach (AssignmentCase assignment in assignments.Where(a => a.VariableName != null)) { - this.VerifyState(assignment.VariableName, assignment.ExpectedValue); + this.VerifyState(assignment.VariableName!, assignment.ExpectedValue); } } @@ -140,9 +152,15 @@ public sealed class SetMultipleVariablesExecutorTest(ITestOutputHelper output) : _ => throw new System.ArgumentException($"Unsupported value type: {assignment.ValueExpression?.GetType().Name}") }; + InitializablePropertyPath? variablePath = null; + if (assignment.VariableName != null) + { + variablePath = PropertyPath.Create(FormatVariablePath(assignment.VariableName)); + } + actionBuilder.Assignments.Add(new VariableAssignment.Builder() { - Variable = PropertyPath.Create(FormatVariablePath(assignment.VariableName)), + Variable = variablePath, Value = valueExpressionBuilder, }); } @@ -150,5 +168,5 @@ public sealed class SetMultipleVariablesExecutorTest(ITestOutputHelper output) : return AssignParent(actionBuilder); } - private sealed record AssignmentCase(string VariableName, object? ValueExpression, FormulaValue ExpectedValue); + private sealed record AssignmentCase(string? VariableName, object? ValueExpression, FormulaValue ExpectedValue); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SetTextVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SetTextVariableExecutorTest.cs index 930e5cb570..0bc850e9ce 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SetTextVariableExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SetTextVariableExecutorTest.cs @@ -16,20 +16,11 @@ public sealed class SetTextVariableExecutorTest(ITestOutputHelper output) : Work [Fact] public async Task SetLiteralValueAsync() { - // Arrange - SetTextVariable model = - this.CreateModel( + // Arrange, Act & Assert + await this.ExecuteTestAsync( this.FormatDisplayName(nameof(SetLiteralValueAsync)), - FormatVariablePath("TextVar"), - "Text variable value"); - - // Act - SetTextVariableExecutor action = new(model, this.State); - await this.ExecuteAsync(action); - - // Assert - VerifyModel(model, action); - this.VerifyState("TextVar", FormulaValue.New("Text variable value")); + "TextVar", + "New value"); } [Fact] @@ -38,11 +29,24 @@ public sealed class SetTextVariableExecutorTest(ITestOutputHelper output) : Work // Arrange this.State.Set("TextVar", FormulaValue.New("Old value")); + // Act & Assert + await this.ExecuteTestAsync( + this.FormatDisplayName(nameof(UpdateExistingValueAsync)), + "TextVar", + "New value"); + } + + private async Task ExecuteTestAsync( + string displayName, + string variableName, + string textValue) + { + // Arrange SetTextVariable model = this.CreateModel( - this.FormatDisplayName(nameof(UpdateExistingValueAsync)), - FormatVariablePath("TextVar"), - "New value"); + displayName, + variableName, + textValue); // Act SetTextVariableExecutor action = new(model, this.State); @@ -50,7 +54,7 @@ public sealed class SetTextVariableExecutorTest(ITestOutputHelper output) : Work // Assert VerifyModel(model, action); - this.VerifyState("TextVar", FormulaValue.New("New value")); + this.VerifyState(variableName, FormulaValue.New(textValue)); } private SetTextVariable CreateModel(string displayName, string variablePath, string textValue) @@ -60,7 +64,7 @@ public sealed class SetTextVariableExecutorTest(ITestOutputHelper output) : Work { Id = this.CreateActionId(), DisplayName = this.FormatDisplayName(displayName), - Variable = InitializablePropertyPath.Create(variablePath), + Variable = PropertyPath.Create(FormatVariablePath(variablePath)), Value = TemplateLine.Parse(textValue), }; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs index f45ccffa78..dddfab6365 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs @@ -92,7 +92,6 @@ public sealed class SetVariableExecutorTest(ITestOutputHelper output) : Workflow { // Arrange this.State.Set("Source", FormulaValue.New(true)); - this.State.Bind(); ValueExpression.Builder expressionBuilder = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source"))); @@ -109,7 +108,6 @@ public sealed class SetVariableExecutorTest(ITestOutputHelper output) : Workflow { // Arrange this.State.Set("Source", FormulaValue.New(321)); - this.State.Bind(); ValueExpression.Builder expressionBuilder = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source"))); @@ -126,7 +124,6 @@ public sealed class SetVariableExecutorTest(ITestOutputHelper output) : Workflow { // Arrange this.State.Set("Source", FormulaValue.New("Test")); - this.State.Bind(); ValueExpression.Builder expressionBuilder = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source"))); @@ -196,7 +193,7 @@ public sealed class SetVariableExecutorTest(ITestOutputHelper output) : Workflow { Id = this.CreateActionId(), DisplayName = this.FormatDisplayName(displayName), - Variable = InitializablePropertyPath.Create(variablePath), + Variable = PropertyPath.Create(variablePath), Value = valueExpression, }; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs index 19758aa372..cff93904b8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; using Microsoft.PowerFx.Types; @@ -25,15 +26,37 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor protected string FormatDisplayName(string name) => $"{this.GetType().Name}_{name}"; - internal async Task ExecuteAsync(DeclarativeActionExecutor executor) + internal Task ExecuteAsync(string actionId, DelegateAction executorAction) => + this.ExecuteAsync(new DelegateActionExecutor(actionId, this.State, executorAction), isDiscrete: false); + + internal Task ExecuteAsync(Executor executor, string actionId, DelegateAction executorAction) => + this.ExecuteAsync([executor, new DelegateActionExecutor(actionId, this.State, executorAction)], isDiscrete: false); + + internal Task ExecuteAsync(Executor executor, bool isDiscrete = true) => + this.ExecuteAsync([executor], isDiscrete); + + internal async Task ExecuteAsync(Executor[] executors, bool isDiscrete) { + this.State.Bind(); + TestWorkflowExecutor workflowExecutor = new(); WorkflowBuilder workflowBuilder = new(workflowExecutor); - workflowBuilder.AddEdge(workflowExecutor, executor); + Executor prevExecutor = workflowExecutor; + foreach (Executor executor in executors) + { + workflowBuilder.AddEdge(prevExecutor, executor); + prevExecutor = executor; + } + await using StreamingRun run = await InProcessExecution.StreamAsync(workflowBuilder.Build(), this.State); WorkflowEvent[] events = await run.WatchStreamAsync().ToArrayAsync(); - Assert.Contains(events, e => e is DeclarativeActionInvokedEvent); - Assert.Contains(events, e => e is DeclarativeActionCompletedEvent); + + if (isDiscrete) + { + VerifyInvocationEvent(events); + VerifyCompletionEvent(events); + } + ExecutorFailedEvent[] failureEvents = events.OfType().ToArray(); switch (failureEvents.Length) { @@ -46,6 +69,7 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor throw aggregateException; } + return events; } @@ -55,15 +79,21 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor Assert.Equal(model, action.Model); } + protected static void VerifyInvocationEvent(WorkflowEvent[] events) => + Assert.Contains(events, e => e is DeclarativeActionInvokedEvent); + + protected static void VerifyCompletionEvent(WorkflowEvent[] events) => + Assert.Contains(events, e => e is DeclarativeActionCompletedEvent); + protected void VerifyState(string variableName, FormulaValue expectedValue) => this.VerifyState(variableName, WorkflowFormulaState.DefaultScopeName, expectedValue); - internal void VerifyState(string variableName, string scopeName, FormulaValue expectedValue) + protected void VerifyState(string variableName, string scopeName, FormulaValue expectedValue) { FormulaValue actualValue = this.State.Get(variableName, scopeName); Assert.Equal(expectedValue.Format(), actualValue.Format()); } - internal void VerifyUndefined(string variableName, string? scopeName = null) => + protected void VerifyUndefined(string variableName, string? scopeName = null) => Assert.IsType(this.State.Get(variableName, scopeName)); protected static TAction AssignParent(DialogAction.Builder actionBuilder) where TAction : DialogAction diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/AddConversationMessage.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/AddConversationMessage.cs index 5692130893..3d4220cf38 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/AddConversationMessage.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/AddConversationMessage.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -52,7 +52,7 @@ public static class WorkflowProvider /// /// Adds a new message to the specified agent conversation /// - internal sealed class AddMessageExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "add_message", session) + internal sealed class AddMessageExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "add_message", session) { // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) @@ -116,4 +116,4 @@ public static class WorkflowProvider // Build the workflow return builder.Build(validateOrphans: false); } -} +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CopyConversationMessages.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CopyConversationMessages.cs index c5a1fc88fd..79ce7d1632 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CopyConversationMessages.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CopyConversationMessages.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -49,7 +49,7 @@ public static class WorkflowProvider /// /// Copies one or more messages into the specified agent conversation. /// - internal sealed class CopyMessagesExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "copy_messages", session) + internal sealed class CopyMessagesExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "copy_messages", session) { // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) @@ -92,4 +92,4 @@ public static class WorkflowProvider // Build the workflow return builder.Build(validateOrphans: false); } -} +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CreateConversation.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CreateConversation.cs index 21e86a5765..8672a259f4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CreateConversation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CreateConversation.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -51,7 +51,7 @@ public static class WorkflowProvider /// /// Creates a new conversation and stores the identifier value to the "Local.PrivateConversationId" variable. /// - internal sealed class ConversationCreateExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "conversation_create", session) + internal sealed class ConversationCreateExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "conversation_create", session) { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { @@ -85,4 +85,4 @@ public static class WorkflowProvider // Build the workflow return builder.Build(validateOrphans: false); } -} +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/InvokeAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/InvokeAgent.cs index 08002d16fb..297029af1c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/InvokeAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/InvokeAgent.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -54,7 +54,7 @@ public static class WorkflowProvider /// /// Invokes an agent to process messages and return a response within a conversation context. /// - internal sealed class InvokeAgentExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : AgentExecutor(id: "invoke_agent", session, agentProvider) + internal sealed class InvokeAgentExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : AgentExecutor(id: "invoke_agent", session, agentProvider) { // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) @@ -109,4 +109,4 @@ public static class WorkflowProvider // Build the workflow return builder.Build(validateOrphans: false); } -} +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopBreak.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopBreak.cs index f4ee656e0c..04459394b0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopBreak.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopBreak.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -81,7 +81,7 @@ public static class WorkflowProvider this._values = [evaluatedValue]; } - await this.ResetAsync(context, null, cancellationToken).ConfigureAwait(false); + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); return default; } @@ -99,7 +99,12 @@ public static class WorkflowProvider } } - public async ValueTask ResetAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + public async ValueTask CompleteAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + { + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken cancellationToken) { await context.QueueStateUpdateAsync(key: "LoopValue", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); await context.QueueStateUpdateAsync(key: "LoopIndex", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); @@ -160,7 +165,7 @@ public static class WorkflowProvider SetVariableInnerExecutor setVariableInner = new(myWorkflowRoot.Session); SendActivityInnerExecutor sendActivityInner = new(myWorkflowRoot.Session); DelegateExecutor endAll = new(id: "end_all", myWorkflowRoot.Session); - DelegateExecutor foreachLoopEnd = new(id: "foreach_loop_End", myWorkflowRoot.Session, foreachLoop.ResetAsync); + DelegateExecutor foreachLoopEnd = new(id: "foreach_loop_End", myWorkflowRoot.Session, foreachLoop.CompleteAsync); // Define the workflow builder WorkflowBuilder builder = new(myWorkflowRoot); @@ -182,4 +187,4 @@ public static class WorkflowProvider // Build the workflow return builder.Build(validateOrphans: false); } -} +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopContinue.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopContinue.cs index 474d69e7ed..d023184476 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopContinue.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopContinue.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -81,7 +81,7 @@ public static class WorkflowProvider this._values = [evaluatedValue]; } - await this.ResetAsync(context, null, cancellationToken).ConfigureAwait(false); + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); return default; } @@ -99,7 +99,12 @@ public static class WorkflowProvider } } - public async ValueTask ResetAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + public async ValueTask CompleteAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + { + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken cancellationToken) { await context.QueueStateUpdateAsync(key: "LoopValue", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); await context.QueueStateUpdateAsync(key: "LoopIndex", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); @@ -160,7 +165,7 @@ public static class WorkflowProvider SetVariableInnerExecutor setVariableInner = new(myWorkflowRoot.Session); SendActivityInnerExecutor sendActivityInner = new(myWorkflowRoot.Session); DelegateExecutor endAll = new(id: "end_all", myWorkflowRoot.Session); - DelegateExecutor foreachLoopEnd = new(id: "foreach_loop_End", myWorkflowRoot.Session, foreachLoop.ResetAsync); + DelegateExecutor foreachLoopEnd = new(id: "foreach_loop_End", myWorkflowRoot.Session, foreachLoop.CompleteAsync); // Define the workflow builder WorkflowBuilder builder = new(myWorkflowRoot); @@ -182,4 +187,4 @@ public static class WorkflowProvider // Build the workflow return builder.Build(validateOrphans: false); } -} +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopEach.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopEach.cs index 06137f222d..a467d42f33 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopEach.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopEach.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -81,7 +81,7 @@ public static class WorkflowProvider this._values = [evaluatedValue]; } - await this.ResetAsync(context, null, cancellationToken).ConfigureAwait(false); + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); return default; } @@ -99,7 +99,12 @@ public static class WorkflowProvider } } - public async ValueTask ResetAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + public async ValueTask CompleteAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + { + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken cancellationToken) { await context.QueueStateUpdateAsync(key: "LoopValue", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); await context.QueueStateUpdateAsync(key: "LoopIndex", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); @@ -158,7 +163,7 @@ public static class WorkflowProvider SetVariableInnerExecutor setVariableInner = new(myWorkflowRoot.Session); SendActivityInnerExecutor sendActivityInner = new(myWorkflowRoot.Session); DelegateExecutor endAll = new(id: "end_all", myWorkflowRoot.Session); - DelegateExecutor foreachLoopEnd = new(id: "foreach_loop_End", myWorkflowRoot.Session, foreachLoop.ResetAsync); + DelegateExecutor foreachLoopEnd = new(id: "foreach_loop_End", myWorkflowRoot.Session, foreachLoop.CompleteAsync); // Define the workflow builder WorkflowBuilder builder = new(myWorkflowRoot); @@ -178,4 +183,4 @@ public static class WorkflowProvider // Build the workflow return builder.Build(validateOrphans: false); } -} +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/RetrieveConversationMessage.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/RetrieveConversationMessage.cs index b45acec1f8..74a7acba52 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/RetrieveConversationMessage.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/RetrieveConversationMessage.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -53,7 +53,7 @@ public static class WorkflowProvider /// /// Retrieves a list of messages from an agent conversation. /// - internal sealed class GetMessageSingleExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "get_message_single", session) + internal sealed class GetMessageSingleExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "get_message_single", session) { // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) @@ -88,4 +88,4 @@ public static class WorkflowProvider // Build the workflow return builder.Build(validateOrphans: false); } -} +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/RetrieveConversationMessages.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/RetrieveConversationMessages.cs index 781012910c..64d11fc50b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/RetrieveConversationMessages.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/RetrieveConversationMessages.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -51,7 +51,7 @@ public static class WorkflowProvider /// /// Retrieves a specific message from an agent conversation. /// - internal sealed class GetMessagesAllExecutor(FormulaSession session, WorkflowAgentProvider agentProvider) : ActionExecutor(id: "get_messages_all", session) + internal sealed class GetMessagesAllExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "get_messages_all", session) { // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) @@ -101,4 +101,4 @@ public static class WorkflowProvider // Build the workflow return builder.Build(validateOrphans: false); } -} +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index a21eda21c4..3dfe605f2e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -141,7 +141,7 @@ public class AgentWorkflowBuilderTests protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => new(new DoubleEchoAgentSession()); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => default; protected override Task RunCoreAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs index f12ffc6988..dc51338aa3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs @@ -149,7 +149,7 @@ public class InProcessExecutionTests protected override ValueTask DeserializeSessionCoreAsync(System.Text.Json.JsonElement serializedState, System.Text.Json.JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => new(new SimpleTestAgentSession()); - protected override System.Text.Json.JsonElement SerializeSessionCore(AgentSession session, System.Text.Json.JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, System.Text.Json.JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => default; protected override Task RunCoreAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs index 686cdea308..c2a538b302 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs @@ -672,4 +672,104 @@ public class JsonSerializationTests ValidateCheckpoint(retrievedCheckpoint, prototype); } + + /// + /// Verifies that the default behavior (without AllowOutOfOrderMetadataProperties) fails + /// when $type metadata is not the first property, demonstrating the PostgreSQL jsonb issue. + /// See: https://github.com/microsoft/agent-framework/issues/2962 + /// + [Fact] + public void Test_OutOfOrderMetadataProperties_WithoutOption_Fails() + { + // Arrange + JsonMarshaller marshaller = new(); + EdgeInfo edgeInfo = TestEdgeInfo_DirectNoCondition; + + // Serialize to JSON + JsonElement serialized = marshaller.Marshal(edgeInfo); + string json = serialized.GetRawText(); + + // Simulate PostgreSQL jsonb behavior: reorder properties so $type is not first + string reorderedJson = ReorderJsonPropertiesToMoveTypeDiscriminatorLast(json); + + // Act & Assert - Without the option, deserialization should fail + JsonElement reorderedElement = JsonDocument.Parse(reorderedJson).RootElement; + Action act = () => marshaller.Marshal(reorderedElement); + + act.Should().Throw(); + } + + /// + /// Simulates PostgreSQL jsonb behavior where property order is not preserved, + /// causing $type metadata to not be the first property. + /// This test verifies that deserialization works when AllowOutOfOrderMetadataProperties is enabled. + /// See: https://github.com/microsoft/agent-framework/issues/2962 + /// + [Fact] + public void Test_OutOfOrderMetadataProperties_WithOptionEnabled_Succeeds() + { + // Arrange + EdgeInfo edgeInfo = TestEdgeInfo_DirectNoCondition; + + // Serialize to JSON using standard marshaller + JsonMarshaller marshaller = new(); + JsonElement serialized = marshaller.Marshal(edgeInfo); + string json = serialized.GetRawText(); + + // Simulate PostgreSQL jsonb behavior: reorder properties so $type is not first + string reorderedJson = ReorderJsonPropertiesToMoveTypeDiscriminatorLast(json); + JsonElement reorderedElement = JsonDocument.Parse(reorderedJson).RootElement; + + // Act - Deserialize with AllowOutOfOrderMetadataProperties enabled via JsonSerializerOptions + JsonSerializerOptions options = new() { AllowOutOfOrderMetadataProperties = true }; + JsonMarshaller marshallerWithOption = new(options); + EdgeInfo deserialized = marshallerWithOption.Marshal(reorderedElement); + + // Assert + deserialized.Should().Match(edgeInfo.CreatePolyValidator()); + } + + private static string ReorderJsonPropertiesToMoveTypeDiscriminatorLast(string json) + { + // Parse JSON, extract $type, rebuild with $type at end + using JsonDocument doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; + + Dictionary properties = []; + JsonElement? typeValue = null; + + foreach (JsonProperty prop in root.EnumerateObject()) + { + if (prop.Name == "$type") + { + typeValue = prop.Value.Clone(); + } + else + { + properties[prop.Name] = prop.Value.Clone(); + } + } + + // Rebuild JSON with $type last + using System.IO.MemoryStream ms = new(); + using (Utf8JsonWriter writer = new(ms)) + { + writer.WriteStartObject(); + foreach (KeyValuePair kvp in properties) + { + writer.WritePropertyName(kvp.Key); + kvp.Value.WriteTo(writer); + } + + if (typeValue.HasValue) + { + writer.WritePropertyName("$type"); + typeValue.Value.WriteTo(writer); + } + + writer.WriteEndObject(); + } + + return System.Text.Encoding.UTF8.GetString(ms.ToArray()); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs index 5d38353fde..391b6a3371 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs @@ -30,7 +30,7 @@ public class RepresentationTests protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RoleCheckAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RoleCheckAgent.cs index 48fc432eeb..dde1d1feed 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RoleCheckAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RoleCheckAgent.cs @@ -19,7 +19,7 @@ internal sealed class RoleCheckAgent(bool allowOtherAssistantRoles, string? id = protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => new(new RoleCheckAgentSession()); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => default; protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => new(new RoleCheckAgentSession()); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs index e4c905f814..afade362a1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs @@ -66,7 +66,7 @@ internal sealed class HelloAgent(string id = nameof(HelloAgent)) : AIAgent protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => new(new HelloAgentSession()); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => default; protected override async Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestEchoAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestEchoAgent.cs index 088c862efa..9d5eca42bf 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestEchoAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestEchoAgent.cs @@ -21,14 +21,14 @@ internal class TestEchoAgent(string? id = null, string? name = null, string? pre return serializedState.Deserialize(jsonSerializerOptions) ?? await this.CreateSessionAsync(cancellationToken); } - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { if (session is not EchoAgentSession typedSession) { throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized."); } - return typedSession.Serialize(jsonSerializerOptions); + return new(typedSession.Serialize(jsonSerializerOptions)); } protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestReplayAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestReplayAgent.cs index 032ba9001c..2dd33a67e4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestReplayAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestReplayAgent.cs @@ -51,7 +51,7 @@ public class TestReplayAgent(List? messages = null, string? id = nu protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => new(new ReplayAgentSession()); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => default; public static TestReplayAgent FromStrings(params string[] messages) => diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRequestAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRequestAgent.cs index 63cd8dd6f0..65a49add96 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRequestAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRequestAgent.cs @@ -45,7 +45,7 @@ internal sealed class TestRequestAgent(TestAgentRequestType requestType, int unp _ => throw new NotSupportedException(), }); - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => default; protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index ac6485131d..5a041699d1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -51,7 +51,7 @@ public class WorkflowHostSmokeTests return new(new Session()); } - protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null) + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => default; protected override async Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) 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/.github/skills/python-development/SKILL.md b/python/.github/skills/python-development/SKILL.md index 7b119d13d8..c19f273588 100644 --- a/python/.github/skills/python-development/SKILL.md +++ b/python/.github/skills/python-development/SKILL.md @@ -69,7 +69,7 @@ def equal(arg1: str, arg2: str) -> bool: ```python # Core -from agent_framework import ChatAgent, ChatMessage, tool +from agent_framework import ChatAgent, Message, tool # Components from agent_framework.observability import enable_instrumentation @@ -84,10 +84,10 @@ from agent_framework.azure import AzureOpenAIChatClient Define `__all__` in each module. Avoid `from module import *` in `__init__.py` files: ```python -__all__ = ["ChatAgent", "ChatMessage", "ChatResponse"] +__all__ = ["ChatAgent", "Message", "ChatResponse"] from ._agents import ChatAgent -from ._types import ChatMessage, ChatResponse +from ._types import Message, ChatResponse ``` ## Performance Guidelines diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index af64583090..caa2b40141 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0b260212] - 2026-02-12 + +### Added + +- **agent-framework-core**: Allow `AzureOpenAIResponsesClient` creation with Foundry project endpoint ([#3814](https://github.com/microsoft/agent-framework/pull/3814)) + +### Changed + +- **agent-framework-core**: [BREAKING] Wire context provider pipeline, remove old types, update all consumers ([#3850](https://github.com/microsoft/agent-framework/pull/3850)) +- **agent-framework-core**: [BREAKING] Checkpoint refactor: encode/decode, checkpoint format, etc ([#3744](https://github.com/microsoft/agent-framework/pull/3744)) +- **agent-framework-core**: [BREAKING] Replace `Hosted*Tool` classes with tool methods ([#3634](https://github.com/microsoft/agent-framework/pull/3634)) +- **agent-framework-core**: Replace Pydantic Settings with `TypedDict` + `load_settings()` ([#3843](https://github.com/microsoft/agent-framework/pull/3843)) +- **agent-framework-core**: Centralize tool result parsing in `FunctionTool.invoke()` ([#3854](https://github.com/microsoft/agent-framework/pull/3854)) +- **samples**: Restructure Python samples into progressive 01-05 layout ([#3862](https://github.com/microsoft/agent-framework/pull/3862)) +- **samples**: Adopt `AzureOpenAIResponsesClient`, reorganize orchestration examples, and fix workflow/orchestration bugs ([#3873](https://github.com/microsoft/agent-framework/pull/3873)) + +### Fixed + +- **agent-framework-core**: Fix non-ascii chars in span attributes ([#3894](https://github.com/microsoft/agent-framework/pull/3894)) +- **agent-framework-core**: Fix streamed workflow agent continuation context by finalizing `AgentExecutor` streams ([#3882](https://github.com/microsoft/agent-framework/pull/3882)) +- **agent-framework-ag-ui**: Fix `Workflow.as_agent()` streaming regression ([#3875](https://github.com/microsoft/agent-framework/pull/3875)) +- **agent-framework-declarative**: Fix declarative package powerfx import crash and `response_format` kwarg error ([#3841](https://github.com/microsoft/agent-framework/pull/3841)) + +## [1.0.0b260210] - 2026-02-10 + +### Added + +- **agent-framework-core**: Add long-running agents and background responses support with `ContinuationToken` TypedDict, `background` option in `OpenAIResponsesOptions`, and continuation token propagation through response types ([#3808](https://github.com/microsoft/agent-framework/pull/3808)) +- **agent-framework-core**: Add streaming support for code interpreter deltas ([#3775](https://github.com/microsoft/agent-framework/pull/3775)) +- **agent-framework-core**: Add explicit input, output, and workflow_output parameters to `@handler`, `@executor` and `request_info` ([#3472](https://github.com/microsoft/agent-framework/pull/3472)) +- **agent-framework-core**: Add explicit schema handling to `@tool` decorator ([#3734](https://github.com/microsoft/agent-framework/pull/3734)) +- **agent-framework-core**: New session and context provider types ([#3763](https://github.com/microsoft/agent-framework/pull/3763)) +- **agent-framework-purview**: Add tests to Purview package ([#3513](https://github.com/microsoft/agent-framework/pull/3513)) + +### Changed + +- **agent-framework-core**: [BREAKING] Renamed core types for simpler API: `ChatAgent` → `Agent`, `RawChatAgent` → `RawAgent`, `ChatMessage` → `Message`, `ChatClientProtocol` → `SupportsChatGetResponse` ([#3747](https://github.com/microsoft/agent-framework/pull/3747)) +- **agent-framework-core**: [BREAKING] Moved to a single `get_response` and `run` API ([#3379](https://github.com/microsoft/agent-framework/pull/3379)) +- **agent-framework-core**: [BREAKING] Merge `send_responses` into `run` method ([#3720](https://github.com/microsoft/agent-framework/pull/3720)) +- **agent-framework-core**: [BREAKING] Renamed `AgentRunContext` to `AgentContext` ([#3714](https://github.com/microsoft/agent-framework/pull/3714)) +- **agent-framework-core**: [BREAKING] Renamed `AgentProtocol` to `SupportsAgentRun` ([#3717](https://github.com/microsoft/agent-framework/pull/3717)) +- **agent-framework-core**: [BREAKING] Renamed next middleware parameter to `call_next` ([#3735](https://github.com/microsoft/agent-framework/pull/3735)) +- **agent-framework-core**: [BREAKING] Standardize TypeVar naming convention (`TName` → `NameT`) ([#3770](https://github.com/microsoft/agent-framework/pull/3770)) +- **agent-framework-core**: [BREAKING] Refactor workflow events to unified discriminated union pattern ([#3690](https://github.com/microsoft/agent-framework/pull/3690)) +- **agent-framework-core**: [BREAKING] Refactor `SharedState` to `State` with sync methods and superstep caching ([#3667](https://github.com/microsoft/agent-framework/pull/3667)) +- **agent-framework-core**: [BREAKING] Move single-config fluent methods to constructor parameters ([#3693](https://github.com/microsoft/agent-framework/pull/3693)) +- **agent-framework-core**: [BREAKING] Types API Review improvements ([#3647](https://github.com/microsoft/agent-framework/pull/3647)) +- **agent-framework-core**: [BREAKING] Fix workflow as agent streaming output ([#3649](https://github.com/microsoft/agent-framework/pull/3649)) +- **agent-framework-orchestrations**: [BREAKING] Move orchestrations to dedicated package ([#3685](https://github.com/microsoft/agent-framework/pull/3685)) +- **agent-framework-core**: [BREAKING] Remove workflow register factory methods; update tests and samples ([#3781](https://github.com/microsoft/agent-framework/pull/3781)) +- **agent-framework-core**: Include sub-workflow structure in graph signature for checkpoint validation ([#3783](https://github.com/microsoft/agent-framework/pull/3783)) +- **agent-framework-core**: Adjust workflows TypeVars from prefix to suffix naming convention ([#3661](https://github.com/microsoft/agent-framework/pull/3661)) +- **agent-framework-purview**: Update CorrelationId ([#3745](https://github.com/microsoft/agent-framework/pull/3745)) +- **agent-framework-anthropic**: Added internal kwargs filtering for Anthropic client ([#3544](https://github.com/microsoft/agent-framework/pull/3544)) +- **agent-framework-github-copilot**: Updated instructions/system_message logic in GitHub Copilot agent ([#3625](https://github.com/microsoft/agent-framework/pull/3625)) +- **agent-framework-mem0**: Disable mem0 telemetry by default ([#3506](https://github.com/microsoft/agent-framework/pull/3506)) + +### Fixed + +- **agent-framework-core**: Fix workflow not pausing when agent calls declaration-only tool ([#3757](https://github.com/microsoft/agent-framework/pull/3757)) +- **agent-framework-core**: Fix GroupChat orchestrator message cleanup issue ([#3712](https://github.com/microsoft/agent-framework/pull/3712)) +- **agent-framework-core**: Fix HandoffBuilder silently dropping `context_provider` during agent cloning ([#3721](https://github.com/microsoft/agent-framework/pull/3721)) +- **agent-framework-core**: Fix subworkflow duplicate request info events ([#3689](https://github.com/microsoft/agent-framework/pull/3689)) +- **agent-framework-core**: Fix workflow cancellation not propagating to active executors ([#3663](https://github.com/microsoft/agent-framework/pull/3663)) +- **agent-framework-core**: Filter `response_format` from MCP tool call kwargs ([#3494](https://github.com/microsoft/agent-framework/pull/3494)) +- **agent-framework-core**: Fix broken Content API imports in Python samples ([#3639](https://github.com/microsoft/agent-framework/pull/3639)) +- **agent-framework-core**: Potential fix for clear-text logging of sensitive information ([#3573](https://github.com/microsoft/agent-framework/pull/3573)) +- **agent-framework-core**: Skip `model_deployment_name` validation for application endpoints ([#3621](https://github.com/microsoft/agent-framework/pull/3621)) +- **agent-framework-azure-ai**: Fix AzureAIClient dropping agent instructions (Responses API) ([#3636](https://github.com/microsoft/agent-framework/pull/3636)) +- **agent-framework-azure-ai**: Fix AzureAIAgentClient dropping agent instructions in sequential workflows ([#3563](https://github.com/microsoft/agent-framework/pull/3563)) +- **agent-framework-ag-ui**: Fix AG-UI message handling and MCP tool double-call bug ([#3635](https://github.com/microsoft/agent-framework/pull/3635)) +- **agent-framework-claude**: Handle API errors in `run_stream()` method ([#3653](https://github.com/microsoft/agent-framework/pull/3653)) +- **agent-framework-claude**: Preserve `$defs` in JSON schema for nested Pydantic models ([#3655](https://github.com/microsoft/agent-framework/pull/3655)) + ## [1.0.0b260130] - 2026-01-30 ### Added @@ -268,7 +342,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **agent-framework-core**: [BREAKING] Support Magentic agent tool call approvals and plan stalling HITL behavior (#2569) -- **agent-framework-core**: [BREAKING] Standardize orchestration outputs as list of `ChatMessage`; allow agent as group chat manager (#2291) +- **agent-framework-core**: [BREAKING] Standardize orchestration outputs as list of `Message`; allow agent as group chat manager (#2291) - **agent-framework-core**: [BREAKING] Respond with `AgentRunResponse` including serialized structured output (#2285) - **observability**: Use `executor_id` and `edge_group_id` as span names for clearer traces (#2538) - **agent-framework-devui**: Add multimodal input support for workflows and refactor chat input (#2593) @@ -314,7 +388,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **agent-framework-core**: Fix tool execution bleed-over in aiohttp/Bot Framework scenarios ([#2314](https://github.com/microsoft/agent-framework/pull/2314)) - **agent-framework-core**: `@ai_function` now correctly handles `self` parameter ([#2266](https://github.com/microsoft/agent-framework/pull/2266)) - **agent-framework-core**: Resolve string annotations in `FunctionExecutor` ([#2308](https://github.com/microsoft/agent-framework/pull/2308)) -- **agent-framework-core**: Langfuse observability captures ChatAgent system instructions ([#2316](https://github.com/microsoft/agent-framework/pull/2316)) +- **agent-framework-core**: Langfuse observability captures Agent system instructions ([#2316](https://github.com/microsoft/agent-framework/pull/2316)) - **agent-framework-core**: Incomplete URL substring sanitization fix ([#2274](https://github.com/microsoft/agent-framework/pull/2274)) - **observability**: Handle datetime serialization in tool results ([#2248](https://github.com/microsoft/agent-framework/pull/2248)) @@ -571,7 +645,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/). -[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260130...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260212...HEAD +[1.0.0b260212]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260210...python-1.0.0b260212 +[1.0.0b260210]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260130...python-1.0.0b260210 [1.0.0b260130]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260128...python-1.0.0b260130 [1.0.0b260128]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260127...python-1.0.0b260128 [1.0.0b260127]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260123...python-1.0.0b260127 diff --git a/python/CODING_STANDARD.md b/python/CODING_STANDARD.md index 16f34be54c..e052b60669 100644 --- a/python/CODING_STANDARD.md +++ b/python/CODING_STANDARD.md @@ -118,10 +118,10 @@ Prefer attributes over inheritance when parameters are mostly the same: ```python # ✅ Preferred - using attributes -from agent_framework import ChatMessage +from agent_framework import Message -user_msg = ChatMessage("user", ["Hello, world!"]) -asst_msg = ChatMessage("assistant", ["Hello, world!"]) +user_msg = Message("user", ["Hello, world!"]) +asst_msg = Message("assistant", ["Hello, world!"]) # ❌ Not preferred - unnecessary inheritance from agent_framework import UserMessage, AssistantMessage @@ -157,7 +157,7 @@ The package follows a flat import structure: - **Core**: Import directly from `agent_framework` ```python - from agent_framework import ChatAgent, tool + from agent_framework import Agent, tool ``` - **Components**: Import from `agent_framework.` @@ -381,12 +381,12 @@ def create_client( Use Google-style docstrings for all public APIs: ```python -def create_agent(name: str, chat_client: ChatClientProtocol) -> Agent: +def create_agent(name: str, client: SupportsChatGetResponse) -> Agent: """Create a new agent with the specified configuration. Args: name: The name of the agent. - chat_client: The chat client to use for communication. + client: The chat client to use for communication. Returns: True if the strings are the same, False otherwise. @@ -409,10 +409,10 @@ Define `__all__` in each module to explicitly declare the public API. Avoid usin ```python # ✅ Preferred - explicit __all__ and imports -__all__ = ["ChatAgent", "ChatMessage", "ChatResponse"] +__all__ = ["Agent", "Message", "ChatResponse"] -from ._agents import ChatAgent -from ._types import ChatMessage, ChatResponse +from ._agents import Agent +from ._types import Message, ChatResponse # ❌ Avoid - star imports from ._agents import * diff --git a/python/DEV_SETUP.md b/python/DEV_SETUP.md index f189031468..025e3ce36e 100644 --- a/python/DEV_SETUP.md +++ b/python/DEV_SETUP.md @@ -116,7 +116,7 @@ You will then configure the ChatClient class with the keyword argument `env_file ```python from agent_framework.openai import OpenAIChatClient -chat_client = OpenAIChatClient(env_file_path="openai.env") +client = OpenAIChatClient(env_file_path="openai.env") ``` ## Tests diff --git a/python/README.md b/python/README.md index 80cb85e4f4..6e7a2c50f2 100644 --- a/python/README.md +++ b/python/README.md @@ -62,7 +62,7 @@ You can also override environment variables by explicitly passing configuration ```python from agent_framework.azure import AzureOpenAIChatClient -chat_client = AzureOpenAIChatClient( +client = AzureOpenAIChatClient( api_key='', endpoint='', deployment_name='', @@ -70,7 +70,7 @@ chat_client = AzureOpenAIChatClient( ) ``` -See the following [setup guide](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started) for more information. +See the following [setup guide](samples/01-get-started) for more information. ## 2. Create a Simple Agent @@ -78,12 +78,12 @@ Create agents and invoke them directly: ```python import asyncio -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.openai import OpenAIChatClient async def main(): - agent = ChatAgent( - chat_client=OpenAIChatClient(), + agent = Agent( + client=OpenAIChatClient(), instructions=""" 1) A robot may not injure a human being... 2) A robot must obey orders given it by human beings... @@ -106,15 +106,15 @@ You can use the chat client classes directly for advanced workflows: ```python import asyncio -from agent_framework import ChatMessage +from agent_framework import Message from agent_framework.openai import OpenAIChatClient async def main(): client = OpenAIChatClient() messages = [ - ChatMessage("system", ["You are a helpful assistant."]), - ChatMessage("user", ["Write a haiku about Agent Framework."]) + Message("system", ["You are a helpful assistant."]), + Message("user", ["Write a haiku about Agent Framework."]) ] response = await client.get_response(messages) @@ -140,7 +140,7 @@ import asyncio from typing import Annotated from random import randint from pydantic import Field -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.openai import OpenAIChatClient @@ -162,8 +162,8 @@ def get_menu_specials() -> str: async def main(): - agent = ChatAgent( - chat_client=OpenAIChatClient(), + agent = Agent( + client=OpenAIChatClient(), instructions="You are a helpful assistant that can provide weather and restaurant information.", tools=[get_weather, get_menu_specials] ) @@ -181,7 +181,7 @@ if __name__ == "__main__": asyncio.run(main()) ``` -You can explore additional agent samples [here](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents). +You can explore additional agent samples [here](samples/02-agents). ## 5. Multi-Agent Orchestration @@ -189,20 +189,20 @@ Coordinate multiple agents to collaborate on complex tasks using orchestration p ```python import asyncio -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.openai import OpenAIChatClient async def main(): # Create specialized agents - writer = ChatAgent( - chat_client=OpenAIChatClient(), + writer = Agent( + client=OpenAIChatClient(), name="Writer", instructions="You are a creative content writer. Generate and refine slogans based on feedback." ) - reviewer = ChatAgent( - chat_client=OpenAIChatClient(), + reviewer = Agent( + client=OpenAIChatClient(), name="Reviewer", instructions="You are a critical reviewer. Provide detailed feedback on proposed slogans." ) @@ -233,14 +233,14 @@ if __name__ == "__main__": asyncio.run(main()) ``` -For more advanced orchestration patterns including Sequential, Concurrent, Group Chat, Handoff, and Magentic orchestrations, see the [orchestration samples](samples/getting_started/orchestrations). +For more advanced orchestration patterns including Sequential, Concurrent, Group Chat, Handoff, and Magentic orchestrations, see the [orchestration samples](samples/03-workflows/orchestrations). ## More Examples & Samples -- [Getting Started with Agents](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents): Basic agent creation and tool usage -- [Chat Client Examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/chat_client): Direct chat client usage patterns +- [Getting Started with Agents](samples/02-agents): Basic agent creation and tool usage +- [Chat Client Examples](samples/02-agents/chat_client): Direct chat client usage patterns - [Azure AI Integration](https://github.com/microsoft/agent-framework/tree/main/python/packages/azure-ai): Azure AI integration -- [Workflow Samples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/workflows): Advanced multi-agent patterns +- [Workflow Samples](samples/03-workflows): Advanced multi-agent patterns ## Agent Framework Documentation diff --git a/python/packages/a2a/README.md b/python/packages/a2a/README.md index 29750ff8e2..5ae15e3647 100644 --- a/python/packages/a2a/README.md +++ b/python/packages/a2a/README.md @@ -12,7 +12,7 @@ The A2A agent integration enables communication with remote A2A-compliant agents ### Basic Usage Example -See the [A2A agent examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/a2a/) which demonstrate: +See the [A2A agent examples](../../samples/04-hosting/a2a/) which demonstrate: - Connecting to remote A2A agents - Sending messages and receiving responses diff --git a/python/packages/a2a/agent_framework_a2a/__init__.py b/python/packages/a2a/agent_framework_a2a/__init__.py index ab4deb98ce..4b4d54ecc3 100644 --- a/python/packages/a2a/agent_framework_a2a/__init__.py +++ b/python/packages/a2a/agent_framework_a2a/__init__.py @@ -2,7 +2,7 @@ import importlib.metadata -from ._agent import A2AAgent +from ._agent import A2AAgent, A2AContinuationToken try: __version__ = importlib.metadata.version(__name__) @@ -11,5 +11,6 @@ except importlib.metadata.PackageNotFoundError: __all__ = [ "A2AAgent", + "A2AContinuationToken", "__version__", ] diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index 335e4c1f68..fa53d8d675 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -7,7 +7,7 @@ import json import re import uuid from collections.abc import AsyncIterable, Awaitable, Sequence -from typing import Any, Final, Literal, cast, overload +from typing import Any, Final, Literal, overload import httpx from a2a.client import Client, ClientConfig, ClientFactory, minimal_agent_card @@ -18,8 +18,9 @@ from a2a.types import ( FilePart, FileWithBytes, FileWithUri, - Message, Task, + TaskIdParams, + TaskQueryParams, TaskState, TextPart, TransportProtocol, @@ -30,25 +31,43 @@ from a2a.types import Role as A2ARole from agent_framework import ( AgentResponse, AgentResponseUpdate, - AgentThread, + AgentSession, BaseAgent, - ChatMessage, Content, + ContinuationToken, + Message, ResponseStream, normalize_messages, prepend_agent_framework_to_user_agent, ) from agent_framework.observability import AgentTelemetryLayer -__all__ = ["A2AAgent"] +__all__ = ["A2AAgent", "A2AContinuationToken"] URI_PATTERN = re.compile(r"^data:(?P[^;]+);base64,(?P[A-Za-z0-9+/=]+)$") + + +class A2AContinuationToken(ContinuationToken): + """Continuation token for A2A protocol long-running tasks.""" + + task_id: str + """A2A protocol task ID.""" + context_id: str + """A2A protocol context ID.""" + + TERMINAL_TASK_STATES = [ TaskState.completed, TaskState.failed, TaskState.canceled, TaskState.rejected, ] +IN_PROGRESS_TASK_STATES = [ + TaskState.submitted, + TaskState.working, + TaskState.input_required, + TaskState.auth_required, +] def _get_uri_data(uri: str) -> str: @@ -63,7 +82,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): """Agent2Agent (A2A) protocol implementation. Wraps an A2A Client to connect the Agent Framework with external A2A-compliant agents - via HTTP/JSON-RPC. Converts framework ChatMessages to A2A Messages on send, and converts + via HTTP/JSON-RPC. Converts framework Messages to A2A Messages on send, and converts A2A responses (Messages/Tasks) back to framework types. Inherits BaseAgent capabilities while managing the underlying A2A protocol communication. @@ -189,107 +208,89 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): @overload def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[False] = ..., - thread: AgentThread | None = None, + session: AgentSession | None = None, + continuation_token: A2AContinuationToken | None = None, + background: bool = False, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @overload def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[True], - thread: AgentThread | None = None, + session: AgentSession | None = None, + continuation_token: A2AContinuationToken | None = None, + background: bool = False, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, + continuation_token: A2AContinuationToken | None = None, + background: bool = False, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Get a response from the agent. - This method returns the final result of the agent's execution - as a single AgentResponse object when stream=False. When stream=True, - it returns a ResponseStream that yields AgentResponseUpdate objects. - Args: messages: The message(s) to send to the agent. Keyword Args: stream: Whether to stream the response. Defaults to False. - thread: The conversation thread associated with the message(s). + session: The conversation session associated with the message(s). + continuation_token: Optional token to resume a long-running task + instead of starting a new one. + background: When True, in-progress task updates surface continuation + tokens so the caller can poll or resubscribe later. When False + (default), the agent internally waits for the task to complete. kwargs: Additional keyword arguments. Returns: When stream=False: An Awaitable[AgentResponse]. When stream=True: A ResponseStream of AgentResponseUpdate items. """ + if continuation_token is not None: + a2a_stream: AsyncIterable[Any] = self.client.resubscribe(TaskIdParams(id=continuation_token["task_id"])) + else: + normalized_messages = normalize_messages(messages) + a2a_message = self._prepare_message_for_a2a(normalized_messages[-1]) + a2a_stream = self.client.send_message(a2a_message) + + response = ResponseStream( + self._map_a2a_stream(a2a_stream, background=background), + finalizer=AgentResponse.from_updates, + ) if stream: - return self._run_stream_impl(messages=messages, thread=thread, **kwargs) - return self._run_impl(messages=messages, thread=thread, **kwargs) + return response + return response.get_final_response() - async def _run_impl( + async def _map_a2a_stream( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + a2a_stream: AsyncIterable[Any], *, - thread: AgentThread | None = None, - **kwargs: Any, - ) -> AgentResponse[Any]: - """Non-streaming implementation of run.""" - # Collect all updates and use framework to consolidate updates into response - updates: list[AgentResponseUpdate] = [] - async for update in self._stream_updates(messages, thread=thread, **kwargs): - updates.append(update) - return AgentResponse.from_updates(updates) - - def _run_stream_impl( - self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, - *, - thread: AgentThread | None = None, - **kwargs: Any, - ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: - """Streaming implementation of run.""" - - def _finalize(updates: Sequence[AgentResponseUpdate]) -> AgentResponse[Any]: - return AgentResponse.from_updates(list(updates)) - - return ResponseStream(self._stream_updates(messages, thread=thread, **kwargs), finalizer=_finalize) - - async def _stream_updates( - self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, - *, - thread: AgentThread | None = None, - **kwargs: Any, + background: bool = False, ) -> AsyncIterable[AgentResponseUpdate]: - """Internal method to stream updates from the A2A agent. + """Map raw A2A protocol items to AgentResponseUpdates. Args: - messages: The message(s) to send to the agent. + a2a_stream: The raw A2A event stream. Keyword Args: - thread: The conversation thread associated with the message(s). - kwargs: Additional keyword arguments. - - Yields: - AgentResponseUpdate items from the A2A agent. + background: When False, in-progress task updates are silently + consumed (the stream keeps iterating until a terminal state). + When True, they are yielded with a continuation token. """ - normalized_messages = normalize_messages(messages) - a2a_message = self._prepare_message_for_a2a(normalized_messages[-1]) - - response_stream = self.client.send_message(a2a_message) - - async for item in response_stream: - if isinstance(item, Message): + async for item in a2a_stream: + if isinstance(item, A2AMessage): # Process A2A Message contents = self._parse_contents_from_a2a(item.parts) yield AgentResponseUpdate( @@ -300,37 +301,86 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): ) elif isinstance(item, tuple) and len(item) == 2: # ClientEvent = (Task, UpdateEvent) task, _update_event = item - if isinstance(task, Task) and task.status.state in TERMINAL_TASK_STATES: - # Convert Task artifacts to ChatMessages and yield as separate updates - task_messages = self._parse_messages_from_task(task) - if task_messages: - for message in task_messages: - # Use the artifact's ID from raw_representation as message_id for unique identification - artifact_id = getattr(message.raw_representation, "artifact_id", None) - yield AgentResponseUpdate( - contents=message.contents, - role=message.role, - response_id=task.id, - message_id=artifact_id, - raw_representation=task, - ) - else: - # Empty task - yield AgentResponseUpdate( - contents=[], - role="assistant", - response_id=task.id, - raw_representation=task, - ) + if isinstance(task, Task): + for update in self._updates_from_task(task, background=background): + yield update else: - # Unknown response type msg = f"Only Message and Task responses are supported from A2A agents. Received: {type(item)}" raise NotImplementedError(msg) - def _prepare_message_for_a2a(self, message: ChatMessage) -> A2AMessage: - """Prepare a ChatMessage for the A2A protocol. + # ------------------------------------------------------------------ + # Task helpers + # ------------------------------------------------------------------ - Transforms Agent Framework ChatMessage objects into A2A protocol Messages by: + def _updates_from_task(self, task: Task, *, background: bool = False) -> list[AgentResponseUpdate]: + """Convert an A2A Task into AgentResponseUpdate(s). + + Terminal tasks produce updates from their artifacts/history. + In-progress tasks produce a continuation token update only when + ``background=True``; otherwise they are silently skipped so the + caller keeps consuming the stream until completion. + """ + if task.status.state in TERMINAL_TASK_STATES: + task_messages = self._parse_messages_from_task(task) + if task_messages: + return [ + AgentResponseUpdate( + contents=message.contents, + role=message.role, + response_id=task.id, + message_id=getattr(message.raw_representation, "artifact_id", None), + raw_representation=task, + ) + for message in task_messages + ] + return [AgentResponseUpdate(contents=[], role="assistant", response_id=task.id, raw_representation=task)] + + if background and task.status.state in IN_PROGRESS_TASK_STATES: + token = self._build_continuation_token(task) + return [ + AgentResponseUpdate( + contents=[], + role="assistant", + response_id=task.id, + continuation_token=token, + raw_representation=task, + ) + ] + + return [] + + @staticmethod + def _build_continuation_token(task: Task) -> A2AContinuationToken | None: + """Build an A2AContinuationToken from an A2A Task if it is still in progress.""" + if task.status.state in IN_PROGRESS_TASK_STATES: + return A2AContinuationToken(task_id=task.id, context_id=task.context_id) + return None + + async def poll_task(self, continuation_token: A2AContinuationToken) -> AgentResponse[Any]: + """Poll for the current state of a long-running A2A task. + + Unlike ``run(continuation_token=...)``, which resubscribes to the SSE + stream, this performs a single request to retrieve the task state. + + Args: + continuation_token: A token previously obtained from a response's + ``continuation_token`` field. + + Returns: + An AgentResponse whose ``continuation_token`` is set when the task + is still in progress, or ``None`` when it has reached a terminal state. + """ + task_id = continuation_token["task_id"] + task = await self.client.get_task(TaskQueryParams(id=task_id)) + updates = self._updates_from_task(task, background=True) + if updates: + return AgentResponse.from_updates(updates) + return AgentResponse(messages=[], response_id=task.id, raw_representation=task) + + def _prepare_message_for_a2a(self, message: Message) -> A2AMessage: + """Prepare a Message for the A2A protocol. + + Transforms Agent Framework Message objects into A2A protocol Messages by: - Converting all message contents to appropriate A2A Part types - Mapping text content to TextPart objects - Converting file references (URI/data/hosted_file) to FilePart objects @@ -339,7 +389,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): """ parts: list[A2APart] = [] if not message.contents: - raise ValueError("ChatMessage.contents is empty; cannot convert to A2AMessage.") + raise ValueError("Message.contents is empty; cannot convert to A2AMessage.") # Process ALL contents for content in message.contents: @@ -401,11 +451,15 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): case _: raise ValueError(f"Unknown content type: {content.type}") + # Exclude framework-internal keys (e.g. attribution) from wire metadata + internal_keys = {"_attribution"} + metadata = {k: v for k, v in message.additional_properties.items() if k not in internal_keys} or None + return A2AMessage( role=A2ARole("user"), parts=parts, message_id=message.message_id or uuid.uuid4().hex, - metadata=cast(dict[str, Any], message.additional_properties), + metadata=metadata, ) def _parse_contents_from_a2a(self, parts: Sequence[A2APart]) -> list[Content]: @@ -457,9 +511,9 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): raise ValueError(f"Unknown Part kind: {inner_part.kind}") return contents - def _parse_messages_from_task(self, task: Task) -> list[ChatMessage]: - """Parse A2A Task artifacts into ChatMessages with ASSISTANT role.""" - messages: list[ChatMessage] = [] + def _parse_messages_from_task(self, task: Task) -> list[Message]: + """Parse A2A Task artifacts into Messages with ASSISTANT role.""" + messages: list[Message] = [] if task.artifacts is not None: for artifact in task.artifacts: @@ -469,7 +523,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): history_item = task.history[-1] contents = self._parse_contents_from_a2a(history_item.parts) messages.append( - ChatMessage( + Message( role="assistant" if history_item.role == A2ARole.agent else "user", contents=contents, raw_representation=history_item, @@ -478,10 +532,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): return messages - def _parse_message_from_artifact(self, artifact: Artifact) -> ChatMessage: - """Parse A2A Artifact into ChatMessage using part contents.""" + def _parse_message_from_artifact(self, artifact: Artifact) -> Message: + """Parse A2A Artifact into Message using part contents.""" contents = self._parse_contents_from_a2a(artifact.parts) - return ChatMessage( + return Message( role="assistant", contents=contents, raw_representation=artifact, diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index dbf59aea85..c8a56cb673 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "a2a-sdk>=0.3.5", ] diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index 10e2e9c956..61123df5ab 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -12,23 +12,24 @@ from a2a.types import ( DataPart, FilePart, FileWithUri, - Message, Part, Task, TaskState, TaskStatus, TextPart, ) +from a2a.types import Message as A2AMessage from a2a.types import Role as A2ARole from agent_framework import ( AgentResponse, AgentResponseUpdate, - ChatMessage, Content, + Message, ) from agent_framework.a2a import A2AAgent from pytest import fixture, raises +from agent_framework_a2a import A2AContinuationToken from agent_framework_a2a._agent import _get_uri_data # type: ignore @@ -38,6 +39,8 @@ class MockA2AClient: def __init__(self) -> None: self.call_count: int = 0 self.responses: list[Any] = [] + self.resubscribe_responses: list[Any] = [] + self.get_task_response: Task | None = None def add_message_response(self, message_id: str, text: str, role: str = "agent") -> None: """Add a mock Message response.""" @@ -46,7 +49,7 @@ class MockA2AClient: text_part = Part(root=TextPart(text=text)) # Create actual Message instance - message = Message( + message = A2AMessage( message_id=message_id, role=A2ARole.agent if role == "agent" else A2ARole.user, parts=[text_part] ) self.responses.append(message) @@ -80,6 +83,18 @@ class MockA2AClient: client_event = (task, update_event) self.responses.append(client_event) + def add_in_progress_task_response( + self, + task_id: str, + context_id: str = "test-context", + state: TaskState = TaskState.working, + ) -> None: + """Add a mock in-progress Task response (non-terminal).""" + status = TaskStatus(state=state, message=None) + task = Task(id=task_id, context_id=context_id, status=status) + client_event = (task, None) + self.responses.append(client_event) + async def send_message(self, message: Any) -> AsyncIterator[Any]: """Mock send_message method that yields responses.""" self.call_count += 1 @@ -88,6 +103,22 @@ class MockA2AClient: response = self.responses.pop(0) yield response + async def resubscribe(self, request: Any) -> AsyncIterator[Any]: + """Mock resubscribe method that yields responses.""" + self.call_count += 1 + + for response in self.resubscribe_responses: + yield response + self.resubscribe_responses.clear() + + async def get_task(self, request: Any) -> Task: + """Mock get_task method that returns a task.""" + self.call_count += 1 + if self.get_task_response is not None: + return self.get_task_response + msg = "No get_task response configured" + raise ValueError(msg) + @fixture def mock_a2a_client() -> MockA2AClient: @@ -250,7 +281,7 @@ def test_parse_message_from_artifact(a2a_agent: A2AAgent) -> None: result = a2a_agent._parse_message_from_artifact(artifact) - assert isinstance(result, ChatMessage) + assert isinstance(result, Message) assert result.role == "assistant" assert result.text == "Artifact content" assert result.raw_representation == artifact @@ -293,9 +324,9 @@ def test_parse_contents_from_a2a_conversion(a2a_agent: A2AAgent) -> None: def test_prepare_message_for_a2a_with_error_content(a2a_agent: A2AAgent) -> None: """Test _prepare_message_for_a2a with ErrorContent.""" - # Create ChatMessage with ErrorContent + # Create Message with ErrorContent error_content = Content.from_error(message="Test error message") - message = ChatMessage(role="user", contents=[error_content]) + message = Message(role="user", contents=[error_content]) # Convert to A2A message a2a_message = a2a_agent._prepare_message_for_a2a(message) @@ -308,9 +339,9 @@ def test_prepare_message_for_a2a_with_error_content(a2a_agent: A2AAgent) -> None def test_prepare_message_for_a2a_with_uri_content(a2a_agent: A2AAgent) -> None: """Test _prepare_message_for_a2a with UriContent.""" - # Create ChatMessage with UriContent + # Create Message with UriContent uri_content = Content.from_uri(uri="http://example.com/file.pdf", media_type="application/pdf") - message = ChatMessage(role="user", contents=[uri_content]) + message = Message(role="user", contents=[uri_content]) # Convert to A2A message a2a_message = a2a_agent._prepare_message_for_a2a(message) @@ -324,9 +355,9 @@ def test_prepare_message_for_a2a_with_uri_content(a2a_agent: A2AAgent) -> None: def test_prepare_message_for_a2a_with_data_content(a2a_agent: A2AAgent) -> None: """Test _prepare_message_for_a2a with DataContent.""" - # Create ChatMessage with DataContent (base64 data URI) + # Create Message with DataContent (base64 data URI) data_content = Content.from_uri(uri="data:text/plain;base64,SGVsbG8gV29ybGQ=", media_type="text/plain") - message = ChatMessage(role="user", contents=[data_content]) + message = Message(role="user", contents=[data_content]) # Convert to A2A message a2a_message = a2a_agent._prepare_message_for_a2a(message) @@ -339,11 +370,11 @@ def test_prepare_message_for_a2a_with_data_content(a2a_agent: A2AAgent) -> None: def test_prepare_message_for_a2a_empty_contents_raises_error(a2a_agent: A2AAgent) -> None: """Test _prepare_message_for_a2a with empty contents raises ValueError.""" - # Create ChatMessage with no contents - message = ChatMessage(role="user", contents=[]) + # Create Message with no contents + message = Message(role="user", contents=[]) # Should raise ValueError for empty contents - with raises(ValueError, match="ChatMessage.contents is empty"): + with raises(ValueError, match="Message.contents is empty"): a2a_agent._prepare_message_for_a2a(message) @@ -401,12 +432,12 @@ async def test_context_manager_no_cleanup_when_no_http_client() -> None: def test_prepare_message_for_a2a_with_multiple_contents() -> None: - """Test conversion of ChatMessage with multiple contents.""" + """Test conversion of Message with multiple contents.""" agent = A2AAgent(client=MagicMock(), _http_client=None) # Create message with multiple content types - message = ChatMessage( + message = Message( role="user", contents=[ Content.from_text(text="Here's the analysis:"), @@ -458,12 +489,12 @@ def test_parse_contents_from_a2a_unknown_part_kind() -> None: def test_prepare_message_for_a2a_with_hosted_file() -> None: - """Test conversion of ChatMessage with HostedFileContent to A2A message.""" + """Test conversion of Message with HostedFileContent to A2A message.""" agent = A2AAgent(client=MagicMock(), _http_client=None) # Create message with hosted file content - message = ChatMessage( + message = Message( role="user", contents=[Content.from_hosted_file(file_id="hosted://storage/document.pdf")], ) @@ -598,3 +629,158 @@ def test_a2a_agent_initialization_with_timeout_parameter() -> None: # Verify it's an httpx.Timeout object with our custom timeout applied to all components assert isinstance(timeout_arg, httpx.Timeout) + + +# region Continuation Token Tests + + +async def test_working_task_emits_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """Test that a working (non-terminal) task yields an update with a continuation token when background=True.""" + mock_a2a_client.add_in_progress_task_response("task-wip", context_id="ctx-1", state=TaskState.working) + + response = await a2a_agent.run("Start long task", background=True) + + assert isinstance(response, AgentResponse) + assert response.continuation_token is not None + assert response.continuation_token["task_id"] == "task-wip" + assert response.continuation_token["context_id"] == "ctx-1" + + +async def test_submitted_task_emits_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """Test that a submitted task yields a continuation token when background=True.""" + mock_a2a_client.add_in_progress_task_response("task-sub", state=TaskState.submitted) + + response = await a2a_agent.run("Submit task", background=True) + + assert response.continuation_token is not None + assert response.continuation_token["task_id"] == "task-sub" + + +async def test_input_required_task_emits_continuation_token( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that an input_required task yields a continuation token when background=True.""" + mock_a2a_client.add_in_progress_task_response("task-input", state=TaskState.input_required) + + response = await a2a_agent.run("Need input", background=True) + + assert response.continuation_token is not None + assert response.continuation_token["task_id"] == "task-input" + + +async def test_working_task_no_token_without_background(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """Test that background=False (default) does not emit continuation tokens for in-progress tasks.""" + mock_a2a_client.add_in_progress_task_response("task-fg", context_id="ctx-fg", state=TaskState.working) + + response = await a2a_agent.run("Foreground task") + + assert response.continuation_token is None + + +async def test_completed_task_has_no_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """Test that a completed task does not set a continuation token.""" + mock_a2a_client.add_task_response("task-done", [{"id": "art-1", "content": "Result"}]) + + response = await a2a_agent.run("Quick task") + + assert response.continuation_token is None + assert len(response.messages) == 1 + assert response.messages[0].text == "Result" + + +async def test_streaming_emits_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """Test that streaming with background=True yields updates with continuation tokens.""" + mock_a2a_client.add_in_progress_task_response("task-stream", context_id="ctx-s", state=TaskState.working) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Stream task", stream=True, background=True): + updates.append(update) + + assert len(updates) == 1 + assert updates[0].continuation_token is not None + assert updates[0].continuation_token["task_id"] == "task-stream" + assert updates[0].continuation_token["context_id"] == "ctx-s" + + +async def test_resume_via_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """Test that run() with continuation_token uses resubscribe instead of send_message.""" + # Set up the resubscribe response (completed task) + status = TaskStatus(state=TaskState.completed, message=None) + artifact = Artifact( + artifact_id="art-resume", + name="result", + parts=[Part(root=TextPart(text="Resumed result"))], + ) + task = Task(id="task-resume", context_id="ctx-r", status=status, artifacts=[artifact]) + mock_a2a_client.resubscribe_responses.append((task, None)) + + token = A2AContinuationToken(task_id="task-resume", context_id="ctx-r") + response = await a2a_agent.run(continuation_token=token) + + assert isinstance(response, AgentResponse) + assert len(response.messages) == 1 + assert response.messages[0].text == "Resumed result" + assert response.continuation_token is None + + +async def test_resume_streaming_via_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """Test that streaming run() with continuation_token and background=True uses resubscribe.""" + # Still working + status_wip = TaskStatus(state=TaskState.working, message=None) + task_wip = Task(id="task-rs", context_id="ctx-rs", status=status_wip) + # Then completed + status_done = TaskStatus(state=TaskState.completed, message=None) + artifact = Artifact( + artifact_id="art-rs", + name="result", + parts=[Part(root=TextPart(text="Stream resumed"))], + ) + task_done = Task(id="task-rs", context_id="ctx-rs", status=status_done, artifacts=[artifact]) + mock_a2a_client.resubscribe_responses.extend([(task_wip, None), (task_done, None)]) + + token = A2AContinuationToken(task_id="task-rs", context_id="ctx-rs") + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run(stream=True, continuation_token=token, background=True): + updates.append(update) + + # First update: in-progress with token, second: completed with content + assert len(updates) == 2 + assert updates[0].continuation_token is not None + assert updates[0].continuation_token["task_id"] == "task-rs" + assert updates[1].continuation_token is None + assert updates[1].contents[0].text == "Stream resumed" + + +async def test_poll_task_in_progress(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """Test poll_task returns continuation token when task is still in progress.""" + status = TaskStatus(state=TaskState.working, message=None) + mock_a2a_client.get_task_response = Task(id="task-poll", context_id="ctx-p", status=status) + + token = A2AContinuationToken(task_id="task-poll", context_id="ctx-p") + response = await a2a_agent.poll_task(token) + + assert response.continuation_token is not None + assert response.continuation_token["task_id"] == "task-poll" + + +async def test_poll_task_completed(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """Test poll_task returns result with no continuation token when task is complete.""" + status = TaskStatus(state=TaskState.completed, message=None) + artifact = Artifact( + artifact_id="art-poll", + name="result", + parts=[Part(root=TextPart(text="Poll result"))], + ) + mock_a2a_client.get_task_response = Task( + id="task-poll-done", context_id="ctx-pd", status=status, artifacts=[artifact] + ) + + token = A2AContinuationToken(task_id="task-poll-done", context_id="ctx-pd") + response = await a2a_agent.poll_task(token) + + assert response.continuation_token is None + assert len(response.messages) == 1 + assert response.messages[0].text == "Poll result" + + +# endregion diff --git a/python/packages/ag-ui/README.md b/python/packages/ag-ui/README.md index d2ff4c3d10..3488d9c8bf 100644 --- a/python/packages/ag-ui/README.md +++ b/python/packages/ag-ui/README.md @@ -14,15 +14,15 @@ pip install agent-framework-ag-ui ```python from fastapi import FastAPI -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.azure import AzureOpenAIChatClient from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint # Create your agent -agent = ChatAgent( +agent = Agent( name="my_agent", instructions="You are a helpful assistant.", - chat_client=AzureOpenAIChatClient( + client=AzureOpenAIChatClient( endpoint="https://your-resource.openai.azure.com/", deployment_name="gpt-4o-mini", api_key="your-api-key", @@ -58,7 +58,7 @@ The `AGUIChatClient` supports: - Streaming and non-streaming responses - Hybrid tool execution (client-side + server-side tools) - Automatic thread management for conversation continuity -- Integration with `ChatAgent` for client-side history management +- Integration with `Agent` for client-side history management ## Documentation @@ -91,7 +91,7 @@ The AG-UI endpoint does not enforce authentication by default. **For production import os from fastapi import Depends, FastAPI, HTTPException, Security from fastapi.security import APIKeyHeader -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint # Configure API key authentication @@ -104,7 +104,7 @@ async def verify_api_key(api_key: str | None = Security(API_KEY_HEADER)) -> None raise HTTPException(status_code=401, detail="Invalid or missing API key") # Create agent and app -agent = ChatAgent(name="my_agent", instructions="...", chat_client=...) +agent = Agent(name="my_agent", instructions="...", client=...) app = FastAPI() # Register endpoint WITH authentication diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent.py index b7e632dbea..765cde7f73 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent.py @@ -18,7 +18,7 @@ class AgentConfig: self, state_schema: Any | None = None, predict_state_config: dict[str, dict[str, str]] | None = None, - use_service_thread: bool = False, + use_service_session: bool = False, require_confirmation: bool = True, ): """Initialize agent configuration. @@ -26,12 +26,12 @@ class AgentConfig: Args: state_schema: Optional state schema for state management; accepts dict or Pydantic model/class predict_state_config: Configuration for predictive state updates - use_service_thread: Whether the agent thread is service-managed + use_service_session: Whether the agent session is service-managed require_confirmation: Whether predictive updates require user confirmation before applying """ self.state_schema = self._normalize_state_schema(state_schema) self.predict_state_config = predict_state_config or {} - self.use_service_thread = use_service_thread + self.use_service_session = use_service_session self.require_confirmation = require_confirmation @staticmethod @@ -77,7 +77,7 @@ class AgentFrameworkAgent: state_schema: Any | None = None, predict_state_config: dict[str, dict[str, str]] | None = None, require_confirmation: bool = True, - use_service_thread: bool = False, + use_service_session: bool = False, ): """Initialize the AG-UI compatible agent wrapper. @@ -88,7 +88,7 @@ class AgentFrameworkAgent: state_schema: Optional state schema for state management; accepts dict or Pydantic model/class predict_state_config: Configuration for predictive state updates require_confirmation: Whether predictive updates require user confirmation before applying - use_service_thread: Whether the agent thread is service-managed + use_service_session: Whether the agent session is service-managed """ self.agent = agent self.name = name or getattr(agent, "name", "agent") @@ -97,7 +97,7 @@ class AgentFrameworkAgent: self.config = AgentConfig( state_schema=state_schema, predict_state_config=predict_state_config, - use_service_thread=use_service_thread, + use_service_session=use_service_session, require_confirmation=require_confirmation, ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_client.py b/python/packages/ag-ui/agent_framework_ag_ui/_client.py index d04550f9c9..373e6321bb 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_client.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_client.py @@ -15,11 +15,11 @@ from typing import TYPE_CHECKING, Any, Generic, TypedDict, cast import httpx from agent_framework import ( BaseChatClient, - ChatMessage, ChatResponse, ChatResponseUpdate, Content, FunctionTool, + Message, ResponseStream, ) from agent_framework._middleware import ChatMiddlewareLayer @@ -59,20 +59,20 @@ def _unwrap_server_function_call_contents(contents: MutableSequence[Content | di contents[idx] = content.function_call # type: ignore[assignment, union-attr] -TBaseChatClient = TypeVar("TBaseChatClient", bound=type[BaseChatClient[Any]]) +BaseChatClientT = TypeVar("BaseChatClientT", bound=type[BaseChatClient[Any]]) -TAGUIChatOptions = TypeVar( - "TAGUIChatOptions", +AGUIChatOptionsT = TypeVar( + "AGUIChatOptionsT", bound=TypedDict, # type: ignore[valid-type] default="AGUIChatOptions", covariant=True, ) -def _apply_server_function_call_unwrap(chat_client: TBaseChatClient) -> TBaseChatClient: +def _apply_server_function_call_unwrap(client: BaseChatClientT) -> BaseChatClientT: """Class decorator that unwraps server-side function calls after tool handling.""" - original_get_response = chat_client.get_response + original_get_response = client.get_response @wraps(original_get_response) def response_wrapper( @@ -105,17 +105,17 @@ def _apply_server_function_call_unwrap(chat_client: TBaseChatClient) -> TBaseCha _unwrap_server_function_call_contents(cast(MutableSequence[Content | dict[str, Any]], update.contents)) return update - chat_client.get_response = response_wrapper # type: ignore[assignment] - return chat_client + client.get_response = response_wrapper # type: ignore[assignment] + return client @_apply_server_function_call_unwrap class AGUIChatClient( - ChatMiddlewareLayer[TAGUIChatOptions], - FunctionInvocationLayer[TAGUIChatOptions], - ChatTelemetryLayer[TAGUIChatOptions], - BaseChatClient[TAGUIChatOptions], - Generic[TAGUIChatOptions], + ChatMiddlewareLayer[AGUIChatOptionsT], + FunctionInvocationLayer[AGUIChatOptionsT], + ChatTelemetryLayer[AGUIChatOptionsT], + BaseChatClient[AGUIChatOptionsT], + Generic[AGUIChatOptionsT], ): """Chat client for communicating with AG-UI compliant servers. @@ -130,8 +130,8 @@ class AGUIChatClient( This client sends exactly the messages it receives to the server. It does NOT automatically maintain conversation history. The server must handle history via thread_id. - For stateless servers: Use ChatAgent wrapper which will send full message history on each - request. However, even with ChatAgent, the server must echo back all context for the + For stateless servers: Use Agent wrapper which will send full message history on each + request. However, even with Agent, the server must echo back all context for the agent to maintain history across turns. Important: Tool Handling (Hybrid Execution - matches .NET) @@ -140,7 +140,7 @@ class AGUIChatClient( 3. When LLM calls a client tool, function invocation executes it locally 4. Both client and server tools work together (hybrid pattern) - The wrapping ChatAgent's function invocation handles client tool execution + The wrapping Agent's function invocation handles client tool execution automatically when the server's LLM decides to call them. Examples: @@ -162,20 +162,20 @@ class AGUIChatClient( metadata={"thread_id": thread_id} ) - Recommended usage with ChatAgent (client manages history): + Recommended usage with Agent (client manages history): .. code-block:: python - from agent_framework import ChatAgent + from agent_framework import Agent from agent_framework.ag_ui import AGUIChatClient client = AGUIChatClient(endpoint="http://localhost:8888/") - agent = ChatAgent(name="assistant", client=client) - thread = await agent.get_new_thread() + agent = Agent(name="assistant", client=client) + session = agent.create_session() - # ChatAgent automatically maintains history and sends full context - response = await agent.run("Hello!", thread=thread) - response2 = await agent.run("How are you?", thread=thread) + # Agent automatically maintains history and sends full context + response = await agent.run("Hello!", session=session) + response2 = await agent.run("How are you?", session=session) Streaming usage: @@ -267,7 +267,7 @@ class AGUIChatClient( if any(getattr(tool, "name", None) == tool_name for tool in additional_tools): return - placeholder: FunctionTool[Any, Any] = FunctionTool( + placeholder: FunctionTool[Any] = FunctionTool( name=tool_name, description="Server-managed tool placeholder (AG-UI)", func=None, @@ -282,9 +282,7 @@ class AGUIChatClient( logger = get_logger() logger.debug(f"[AGUIChatClient] Registered server placeholder: {tool_name}") - def _extract_state_from_messages( - self, messages: Sequence[ChatMessage] - ) -> tuple[list[ChatMessage], dict[str, Any] | None]: + def _extract_state_from_messages(self, messages: Sequence[Message]) -> tuple[list[Message], dict[str, Any] | None]: """Extract state from last message if present. Args: @@ -319,11 +317,11 @@ class AGUIChatClient( return list(messages), None - def _convert_messages_to_agui_format(self, messages: list[ChatMessage]) -> list[dict[str, Any]]: + def _convert_messages_to_agui_format(self, messages: list[Message]) -> list[dict[str, Any]]: """Convert Agent Framework messages to AG-UI format. Args: - messages: List of ChatMessage objects + messages: List of Message objects Returns: List of AG-UI formatted message dictionaries @@ -353,7 +351,7 @@ class AGUIChatClient( def _inner_get_response( self, *, - messages: Sequence[ChatMessage], + messages: Sequence[Message], stream: bool = False, options: Mapping[str, Any], **kwargs: Any, @@ -393,7 +391,7 @@ class AGUIChatClient( async def _streaming_impl( self, *, - messages: Sequence[ChatMessage], + messages: Sequence[Message], options: Mapping[str, Any], **kwargs: Any, ) -> AsyncIterable[ChatResponseUpdate]: @@ -415,7 +413,7 @@ class AGUIChatClient( agui_messages = self._convert_messages_to_agui_format(messages_to_send) # Send client tools to server so LLM knows about them - # Client tools execute via ChatAgent's function invocation wrapper + # Client tools execute via Agent's function invocation wrapper agui_tools = convert_tools_to_agui_format(options.get("tools")) # Build set of client tool names (matches .NET clientToolSet) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py index 3f35572f78..efdb3a0f53 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py @@ -9,9 +9,8 @@ import logging from typing import Any, cast from agent_framework import ( - ChatMessage, Content, - prepare_function_call_results, + Message, ) from ._utils import ( @@ -25,9 +24,9 @@ from ._utils import ( logger = logging.getLogger(__name__) -def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]: +def _sanitize_tool_history(messages: list[Message]) -> list[Message]: """Normalize tool ordering and inject synthetic results for AG-UI edge cases.""" - sanitized: list[ChatMessage] = [] + sanitized: list[Message] = [] pending_tool_call_ids: set[str] | None = None pending_confirm_changes_id: str | None = None @@ -60,7 +59,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]: ] if filtered_contents: # Create a new message without confirm_changes to avoid mutating the input - filtered_msg = ChatMessage(role=msg.role, contents=filtered_contents) + filtered_msg = Message(role=msg.role, contents=filtered_contents) sanitized.append(filtered_msg) # If no contents left after filtering, don't append anything @@ -99,7 +98,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]: if pending_confirm_changes_id and approval_accepted is not None: logger.info(f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}") - synthetic_result = ChatMessage( + synthetic_result = Message( role="tool", contents=[ Content.from_function_result( @@ -128,7 +127,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]: logger.info( f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}" ) - synthetic_result = ChatMessage( + synthetic_result = Message( role="tool", contents=[ Content.from_function_result( @@ -152,7 +151,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]: ) for pending_call_id in pending_tool_call_ids: logger.info(f"Injecting synthetic tool result for pending call_id={pending_call_id}") - synthetic_result = ChatMessage( + synthetic_result = Message( role="tool", contents=[ Content.from_function_result( @@ -196,10 +195,10 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]: return sanitized -def _deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]: +def _deduplicate_messages(messages: list[Message]) -> list[Message]: """Remove duplicate messages while preserving order.""" seen_keys: dict[Any, int] = {} - unique_messages: list[ChatMessage] = [] + unique_messages: list[Message] = [] for idx, msg in enumerate(messages): role_value = get_role_value(msg) @@ -256,7 +255,7 @@ def _deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]: def normalize_agui_input_messages( messages: list[dict[str, Any]], -) -> tuple[list[ChatMessage], list[dict[str, Any]]]: +) -> tuple[list[Message], list[dict[str, Any]]]: """Normalize raw AG-UI messages into provider and snapshot formats.""" provider_messages = agui_messages_to_agent_framework(messages) provider_messages = _sanitize_tool_history(provider_messages) @@ -265,14 +264,14 @@ def normalize_agui_input_messages( return provider_messages, snapshot_messages -def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[ChatMessage]: +def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Message]: """Convert AG-UI messages to Agent Framework format. Args: messages: List of AG-UI messages Returns: - List of Agent Framework ChatMessage objects + List of Agent Framework Message objects """ def _update_tool_call_arguments( @@ -367,7 +366,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha allowed_keys = set(original_args.keys()) return {key: value for key, value in modified_args.items() if key in allowed_keys} - result: list[ChatMessage] = [] + result: list[Message] = [] for msg in messages: # Handle standard tool result messages early (role="tool") to preserve provider invariants # This path maps AG‑UI tool messages to function_result content with the correct tool_call_id @@ -480,7 +479,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha merged_args["steps"] = merged_steps state_args = merged_args - # Update the ChatMessage tool call with only enabled steps (for LLM context). + # Update the Message tool call with only enabled steps (for LLM context). # The LLM should only see the steps that were actually approved/executed. updated_args_for_llm = ( json.dumps(filtered_args) @@ -510,14 +509,14 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha function_call=func_call_for_approval, additional_properties={"ag_ui_state_args": state_args} if state_args else None, ) - chat_msg = ChatMessage( + chat_msg = Message( role="user", contents=[approval_response], ) else: # No matching function call found - this is likely a confirm_changes approval # Keep the old behavior for backwards compatibility - chat_msg = ChatMessage( + chat_msg = Message( role="user", contents=[Content.from_text(text=approval_payload_text)], additional_properties={"is_tool_result": True, "tool_call_id": str(tool_call_id or "")}, @@ -537,7 +536,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha func_result = result_content else: func_result = str(result_content) - chat_msg = ChatMessage( + chat_msg = Message( role="tool", contents=[Content.from_function_result(call_id=str(tool_call_id), result=func_result)], ) @@ -553,7 +552,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha tool_call_id = msg.get("toolCallId") or msg.get("tool_call_id") or msg.get("actionExecutionId", "") result_content = msg.get("result", msg.get("content", "")) - chat_msg = ChatMessage( + chat_msg = Message( role="tool", contents=[Content.from_function_result(call_id=str(tool_call_id), result=result_content)], ) @@ -592,7 +591,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha arguments=arguments, ) ) - chat_msg = ChatMessage(role="assistant", contents=contents) + chat_msg = Message(role="assistant", contents=contents) if "id" in msg: chat_msg.message_id = msg["id"] result.append(chat_msg) @@ -622,14 +621,14 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha ) approval_contents.append(approval_response) - chat_msg = ChatMessage(role=role, contents=approval_contents) # type: ignore[call-overload] + chat_msg = Message(role=role, contents=approval_contents) # type: ignore[call-overload] else: # Regular text message content = msg.get("content", "") if isinstance(content, str): - chat_msg = ChatMessage(role=role, contents=[Content.from_text(text=content)]) # type: ignore[call-overload] + chat_msg = Message(role=role, contents=[Content.from_text(text=content)]) # type: ignore[call-overload] else: - chat_msg = ChatMessage(role=role, contents=[Content.from_text(text=str(content))]) # type: ignore[call-overload] + chat_msg = Message(role=role, contents=[Content.from_text(text=str(content))]) # type: ignore[call-overload] if "id" in msg: chat_msg.message_id = msg["id"] @@ -639,11 +638,11 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha return result -def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str, Any]]) -> list[dict[str, Any]]: +def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, Any]]) -> list[dict[str, Any]]: """Convert Agent Framework messages to AG-UI format. Args: - messages: List of Agent Framework ChatMessage objects or AG-UI dicts (already converted) + messages: List of Agent Framework Message objects or AG-UI dicts (already converted) Returns: List of AG-UI message dictionaries @@ -672,7 +671,7 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str result.append(normalized_msg) continue - # Convert ChatMessage to AG-UI format + # Convert Message to AG-UI format role_value: str = msg.role if hasattr(msg.role, "value") else msg.role # type: ignore[assignment] role = FRAMEWORK_TO_AGUI_ROLE.get(role_value, "user") @@ -697,8 +696,7 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str elif content.type == "function_result": # Tool result content - extract call_id and result tool_result_call_id = content.call_id - # Serialize result to string using core utility - content_text = prepare_function_call_results(content.result) + content_text = content.result if content.result is not None else "" agui_msg: dict[str, Any] = { "id": msg.message_id if msg.message_id else generate_event_id(), # Always include id diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_helpers.py b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_helpers.py index 277b5effce..aea3eb66c5 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_helpers.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_helpers.py @@ -13,8 +13,8 @@ import logging from typing import Any from agent_framework import ( - ChatMessage, Content, + Message, ) from .._utils import get_role_value @@ -22,7 +22,7 @@ from .._utils import get_role_value logger = logging.getLogger(__name__) -def pending_tool_call_ids(messages: list[ChatMessage]) -> set[str]: +def pending_tool_call_ids(messages: list[Message]) -> set[str]: """Get IDs of tool calls without corresponding results. Args: @@ -42,7 +42,7 @@ def pending_tool_call_ids(messages: list[ChatMessage]) -> set[str]: return pending_ids - resolved_ids -def is_state_context_message(message: ChatMessage) -> bool: +def is_state_context_message(message: Message) -> bool: """Check if a message is a state context system message. Args: @@ -178,7 +178,7 @@ def build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, Any return safe_metadata -def latest_approval_response(messages: list[ChatMessage]) -> Content | None: +def latest_approval_response(messages: list[Message]) -> Content | None: """Get the latest approval response from messages. Args: diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_tooling.py b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_tooling.py index 069622f490..442138649a 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_tooling.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_orchestration/_tooling.py @@ -39,7 +39,7 @@ def collect_server_tools(agent: SupportsAgentRun) -> list[Any]: functions need to be included for tool execution during approval flows. Args: - agent: Agent instance to collect tools from. Works with ChatAgent + agent: Agent instance to collect tools from. Works with Agent or any agent with default_options and optional mcp_tools attributes. Returns: @@ -53,7 +53,7 @@ def collect_server_tools(agent: SupportsAgentRun) -> list[Any]: tools_from_agent = default_options.get("tools") if isinstance(default_options, dict) else None server_tools = list(tools_from_agent) if tools_from_agent else [] - # Include functions from connected MCP tools (only available on ChatAgent) + # Include functions from connected MCP tools (only available on Agent) mcp_tools = getattr(agent, "mcp_tools", None) if mcp_tools: server_tools.extend(_collect_mcp_tool_functions(mcp_tools)) @@ -70,19 +70,19 @@ def register_additional_client_tools(agent: SupportsAgentRun, client_tools: list """Register client tools as additional declaration-only tools to avoid server execution. Args: - agent: Agent instance to register tools on. Works with ChatAgent - or any agent with a chat_client attribute. + agent: Agent instance to register tools on. Works with Agent + or any agent with a client attribute. client_tools: List of client tools to register. """ if not client_tools: return - chat_client = getattr(agent, "chat_client", None) - if chat_client is None: + client = getattr(agent, "client", None) + if client is None: return - if isinstance(chat_client, BaseChatClient) and chat_client.function_invocation_configuration is not None: # type: ignore[attr-defined] - chat_client.function_invocation_configuration["additional_tools"] = client_tools # type: ignore[attr-defined] + if isinstance(client, BaseChatClient) and client.function_invocation_configuration is not None: # type: ignore[attr-defined] + client.function_invocation_configuration["additional_tools"] = client_tools # type: ignore[attr-defined] logger.debug(f"[TOOLS] Registered {len(client_tools)} client tools as additional_tools (declaration-only)") diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_run.py index d47fdc4d67..46f2dd4829 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run.py @@ -7,7 +7,7 @@ from __future__ import annotations import json import logging import uuid -from collections.abc import Awaitable +from collections.abc import AsyncIterable, Awaitable from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, cast @@ -27,11 +27,10 @@ from ag_ui.core import ( ToolCallStartEvent, ) from agent_framework import ( - AgentThread, - ChatMessage, + AgentSession, Content, + Message, SupportsAgentRun, - prepare_function_call_results, ) from agent_framework._middleware import FunctionMiddlewarePipeline from agent_framework._tools import ( @@ -173,12 +172,12 @@ class FlowState: tool_call_id: str | None = None # Current tool call being streamed tool_call_name: str | None = None # Name of current tool call waiting_for_approval: bool = False # Stop after approval request - current_state: dict[str, Any] = field(default_factory=dict) # Shared state + current_state: dict[str, Any] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType] accumulated_text: str = "" # For MessagesSnapshotEvent - pending_tool_calls: list[dict[str, Any]] = field(default_factory=list) # For MessagesSnapshotEvent - tool_calls_by_id: dict[str, dict[str, Any]] = field(default_factory=dict) - tool_results: list[dict[str, Any]] = field(default_factory=list) - tool_calls_ended: set[str] = field(default_factory=set) # Track which tool calls have been ended + pending_tool_calls: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + tool_calls_by_id: dict[str, dict[str, Any]] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType] + tool_results: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + tool_calls_ended: set[str] = field(default_factory=set) # pyright: ignore[reportUnknownVariableType] def get_tool_name(self, call_id: str | None) -> str | None: """Get tool name by call ID.""" @@ -192,10 +191,44 @@ class FlowState: return [tc for tc in self.pending_tool_calls if tc.get("id") not in self.tool_calls_ended] +async def _normalize_response_stream(response_stream: Any) -> AsyncIterable[Any]: + """Normalize agent streaming return types to an async iterable. + + Supports: + - ResponseStream (standard agent stream type) + - AsyncIterable[AgentResponseUpdate] (workflow-style stream) + - Awaitable that resolves to either of the above + """ + if isinstance(response_stream, Awaitable): + resolved_stream = await cast(Awaitable[Any], response_stream) + if isinstance(resolved_stream, ResponseStream): + # AG-UI consumes update iteration only; ResponseStream finalizers are not used here. + return cast(AsyncIterable[Any], resolved_stream) + if isinstance(resolved_stream, AsyncIterable): + return cast(AsyncIterable[Any], resolved_stream) + resolved_type = f"{type(resolved_stream).__module__}.{type(resolved_stream).__name__}" + raise AgentExecutionException( + "Agent did not return a streaming AsyncIterable response. " + f"Awaitable resolved to unsupported type: {resolved_type}." + ) + + if isinstance(response_stream, ResponseStream): + # AG-UI consumes update iteration only; ResponseStream finalizers are not used here. + return cast(AsyncIterable[Any], response_stream) + + if isinstance(response_stream, AsyncIterable): + return cast(AsyncIterable[Any], response_stream) + + stream_type = f"{type(response_stream).__module__}.{type(response_stream).__name__}" + raise AgentExecutionException( + f"Agent did not return a streaming AsyncIterable response. Received unsupported type: {stream_type}." + ) + + def _create_state_context_message( current_state: dict[str, Any], state_schema: dict[str, Any], -) -> ChatMessage | None: +) -> Message | None: """Create a system message with current state context. This injects the current state into the conversation so the model @@ -206,13 +239,13 @@ def _create_state_context_message( state_schema: The state schema (used to determine if injection is needed) Returns: - ChatMessage with state context, or None if not needed + Message with state context, or None if not needed """ if not current_state or not state_schema: return None state_json = json.dumps(current_state, indent=2) - return ChatMessage( + return Message( role="system", contents=[ Content.from_text( @@ -229,10 +262,10 @@ def _create_state_context_message( def _inject_state_context( - messages: list[ChatMessage], + messages: list[Message], current_state: dict[str, Any], state_schema: dict[str, Any], -) -> list[ChatMessage]: +) -> list[Message]: """Inject state context message into messages if appropriate. The state context is injected before the last user message to give @@ -360,7 +393,7 @@ def _emit_tool_result( events.append(ToolCallEndEvent(tool_call_id=content.call_id)) flow.tool_calls_ended.add(content.call_id) # Track ended tool calls - result_content = prepare_function_call_results(content.result) + result_content = content.result if content.result is not None else "" message_id = generate_event_id() events.append( ToolCallResultEvent( @@ -461,7 +494,7 @@ def _emit_approval_request( parent_message_id=flow.message_id, ) ) - args = { + args: dict[str, Any] = { "function_name": func_name, "function_call_id": func_call_id, "function_arguments": make_json_safe(func_call.parse_arguments()) or {}, @@ -516,7 +549,8 @@ def _is_confirm_changes_response(messages: list[Any]) -> bool: if not messages: return False last = messages[-1] - if not last.additional_properties.get("is_tool_result", False): + additional_properties = cast(dict[str, Any], getattr(last, "additional_properties", {}) or {}) + if not additional_properties.get("is_tool_result", False): return False # Parse the content to check if it has the confirm_changes structure @@ -524,6 +558,8 @@ def _is_confirm_changes_response(messages: list[Any]) -> bool: if getattr(content, "type", None) == "text" and content.text: try: result = json.loads(content.text) + if not isinstance(result, dict): + continue # confirm_changes results have 'accepted' and 'steps' keys if "accepted" in result and "steps" in result: return True @@ -549,13 +585,19 @@ def _handle_step_based_approval(messages: list[Any]) -> list[BaseEvent]: message = "Acknowledged." else: try: - result = json.loads(approval_text) - accepted = result.get("accepted", False) - steps = result.get("steps", []) + parsed_result = json.loads(approval_text) + result: dict[str, Any] = cast(dict[str, Any], parsed_result) if isinstance(parsed_result, dict) else {} + accepted = bool(result.get("accepted", False)) + steps_raw = result.get("steps", []) + steps: list[dict[str, Any]] = [] + if isinstance(steps_raw, list): + for step_raw in cast(list[Any], steps_raw): + if isinstance(step_raw, dict): + steps.append(cast(dict[str, Any], step_raw)) if accepted: # Generate acceptance message with step descriptions - enabled_steps = [s for s in steps if s.get("status") == "enabled"] + enabled_steps: list[dict[str, Any]] = [step for step in steps if step.get("status") == "enabled"] if enabled_steps: message_parts = [f"Executing {len(enabled_steps)} approved steps:\n\n"] for i, step in enumerate(enabled_steps, 1): @@ -592,7 +634,7 @@ async def _resolve_approval_responses( Args: messages: List of messages (will be modified in place) tools: List of available tools - agent: The agent instance (to get chat_client and config) + agent: The agent instance (to get client and config) run_kwargs: Kwargs for tool execution """ fcc_todo = _collect_approval_responses(messages) @@ -605,12 +647,10 @@ async def _resolve_approval_responses( # Execute approved tool calls if approved_responses and tools: - chat_client = getattr(agent, "chat_client", None) - config = normalize_function_invocation_configuration( - getattr(chat_client, "function_invocation_configuration", None) - ) + client = getattr(agent, "client", None) + config = normalize_function_invocation_configuration(getattr(client, "function_invocation_configuration", None)) middleware_pipeline = FunctionMiddlewarePipeline( - *getattr(chat_client, "function_middleware", ()), + *getattr(client, "function_middleware", ()), *run_kwargs.get("middleware", ()), ) # Filter out AG-UI-specific kwargs that should not be passed to tool execution @@ -672,7 +712,7 @@ def _convert_approval_results_to_tool_messages(messages: list[Any]) -> None: This modifies the messages list in place. Args: - messages: List of ChatMessage objects to process + messages: List of Message objects to process """ result: list[Any] = [] @@ -681,8 +721,9 @@ def _convert_approval_results_to_tool_messages(messages: list[Any]) -> None: result.append(msg) continue - function_results = [c for c in (msg.contents or []) if getattr(c, "type", None) == "function_result"] - other_contents = [c for c in (msg.contents or []) if getattr(c, "type", None) != "function_result"] + msg_contents = cast(list[Content], getattr(msg, "contents", None) or []) + function_results: list[Content] = [content for content in msg_contents if content.type == "function_result"] + other_contents: list[Content] = [content for content in msg_contents if content.type != "function_result"] if not function_results: result.append(msg) @@ -694,11 +735,11 @@ def _convert_approval_results_to_tool_messages(messages: list[Any]) -> None: # Tool messages first (right after the preceding assistant message per OpenAI requirements) for func_result in function_results: - result.append(ChatMessage(role="tool", contents=[func_result])) + result.append(Message(role="tool", contents=[func_result])) # Then user message with remaining content (if any) if other_contents: - result.append(ChatMessage(role=msg.role, contents=other_contents)) + result.append(Message(role="user", contents=other_contents)) messages[:] = result @@ -768,21 +809,24 @@ async def run_agent_stream( if input_data.get("state"): flow.current_state = dict(input_data["state"]) + state_schema = cast(dict[str, Any], getattr(config, "state_schema", {}) or {}) + predict_state_config = cast(dict[str, dict[str, str]], getattr(config, "predict_state_config", {}) or {}) + # Apply schema defaults for missing state keys - if config.state_schema: - for key, schema in config.state_schema.items(): + if state_schema: + for key, schema in state_schema.items(): if key in flow.current_state: continue - if isinstance(schema, dict) and schema.get("type") == "array": + if isinstance(schema, dict) and cast(dict[str, Any], schema).get("type") == "array": flow.current_state[key] = [] else: flow.current_state[key] = {} # Initialize predictive state handler if configured predictive_handler: PredictiveStateHandler | None = None - if config.predict_state_config: + if predict_state_config: predictive_handler = PredictiveStateHandler( - predict_state_config=config.predict_state_config, + predict_state_config=predict_state_config, current_state=flow.current_state, ) @@ -792,11 +836,11 @@ async def run_agent_stream( # Check for structured output mode (skip text content) skip_text = False - response_format = None - from agent_framework import ChatAgent - - if isinstance(agent, ChatAgent): - response_format = agent.default_options.get("response_format") + response_format: type[Any] | None = None + default_options = getattr(agent, "default_options", None) + if isinstance(default_options, dict): + typed_default_options = cast(dict[str, Any], default_options) + response_format = cast(type[Any] | None, typed_default_options.get("response_format")) skip_text = response_format is not None # Handle empty messages (emit RunStarted immediately since no agent response) @@ -812,12 +856,12 @@ async def run_agent_stream( register_additional_client_tools(agent, client_tools) tools = merge_tools(server_tools, client_tools) - # Create thread (with service thread support) - if config.use_service_thread: + # Create session (with service session support) + if config.use_service_session: supplied_thread_id = input_data.get("thread_id") or input_data.get("threadId") - thread = AgentThread(service_thread_id=supplied_thread_id) + session = AgentSession(service_session_id=supplied_thread_id) else: - thread = AgentThread() + session = AgentSession() # Inject metadata for AG-UI orchestration (Feature #2: Azure-safe truncation) base_metadata: dict[str, Any] = { @@ -826,16 +870,17 @@ async def run_agent_stream( } if flow.current_state: base_metadata["current_state"] = flow.current_state - thread.metadata = _build_safe_metadata(base_metadata) # type: ignore[attr-defined] + session.metadata = _build_safe_metadata(base_metadata) # type: ignore[attr-defined] # Build run kwargs (Feature #6: Azure store flag when metadata present) - run_kwargs: dict[str, Any] = {"thread": thread} + run_kwargs: dict[str, Any] = {"session": session} if tools: run_kwargs["tools"] = tools # Filter out AG-UI internal metadata keys before passing to chat client # These are used internally for orchestration and should not be sent to the LLM provider - client_metadata = { - k: v for k, v in (getattr(thread, "metadata", None) or {}).items() if k not in AG_UI_INTERNAL_METADATA_KEYS + session_metadata = cast(dict[str, Any], getattr(session, "metadata", None) or {}) + client_metadata: dict[str, Any] = { + k: v for k, v in session_metadata.items() if k not in AG_UI_INTERNAL_METADATA_KEYS } safe_metadata = _build_safe_metadata(client_metadata) if client_metadata else {} if safe_metadata: @@ -866,19 +911,14 @@ async def run_agent_stream( # Inject state context message so the model knows current application state # This is critical for shared state scenarios where the UI state needs to be visible - if config.state_schema and flow.current_state: - messages = _inject_state_context(messages, flow.current_state, config.state_schema) + if state_schema and flow.current_state: + messages = _inject_state_context(messages, flow.current_state, state_schema) # Stream from agent - emit RunStarted after first update to get service IDs run_started_emitted = False all_updates: list[Any] = [] # Collect for structured output processing response_stream = agent.run(messages, stream=True, **run_kwargs) - if isinstance(response_stream, ResponseStream): - stream = response_stream - else: - stream = await cast(Awaitable[ResponseStream[Any, Any]], response_stream) - if not isinstance(stream, ResponseStream): - raise AgentExecutionException("Chat client did not return a ResponseStream.") + stream = await _normalize_response_stream(response_stream) async for update in stream: # Collect updates for structured output processing if response_format is not None: @@ -894,18 +934,18 @@ async def run_agent_stream( # NOW emit RunStarted with proper IDs yield RunStartedEvent(run_id=run_id, thread_id=thread_id) # Emit PredictState custom event if configured - if config.predict_state_config: + if predict_state_config: predict_state_value = [ { "state_key": state_key, "tool": cfg["tool"], "tool_argument": cfg["tool_argument"], } - for state_key, cfg in config.predict_state_config.items() + for state_key, cfg in predict_state_config.items() ] yield CustomEvent(name="PredictState", value=predict_state_value) # Emit initial state snapshot only if we have both state_schema and state - if config.state_schema and flow.current_state: + if state_schema and flow.current_state: yield StateSnapshotEvent(snapshot=flow.current_state) run_started_emitted = True @@ -936,17 +976,17 @@ async def run_agent_stream( # If no updates at all, still emit RunStarted if not run_started_emitted: yield RunStartedEvent(run_id=run_id, thread_id=thread_id) - if config.predict_state_config: + if predict_state_config: predict_state_value = [ { "state_key": state_key, "tool": cfg["tool"], "tool_argument": cfg["tool_argument"], } - for state_key, cfg in config.predict_state_config.items() + for state_key, cfg in predict_state_config.items() ] yield CustomEvent(name="PredictState", value=predict_state_value) - if config.state_schema and flow.current_state: + if state_schema and flow.current_state: yield StateSnapshotEvent(snapshot=flow.current_state) # Process structured output if response_format is set @@ -954,31 +994,33 @@ async def run_agent_stream( from agent_framework import AgentResponse from pydantic import BaseModel - logger.info(f"Processing structured output, update count: {len(all_updates)}") - final_response = AgentResponse.from_updates(all_updates, output_format_type=response_format) + if not (isinstance(response_format, type) and issubclass(response_format, BaseModel)): + logger.warning("Skipping structured output parsing: response_format is not a Pydantic model type.") + else: + logger.info(f"Processing structured output, update count: {len(all_updates)}") + final_response = AgentResponse.from_updates(all_updates, output_format_type=response_format) - if final_response.value and isinstance(final_response.value, BaseModel): - response_dict = final_response.value.model_dump(mode="json", exclude_none=True) - logger.info(f"Received structured output keys: {list(response_dict.keys())}") + if final_response.value and isinstance(final_response.value, BaseModel): + response_dict = final_response.value.model_dump(mode="json", exclude_none=True) + logger.info(f"Received structured output keys: {list(response_dict.keys())}") - # Extract state updates - if no state_schema, all non-message fields are state - state_keys = ( - set(config.state_schema.keys()) if config.state_schema else set(response_dict.keys()) - {"message"} - ) - state_updates = {k: v for k, v in response_dict.items() if k in state_keys} + # Extract state updates - if no state_schema, all non-message fields are state + state_keys = set(state_schema.keys()) if state_schema else set(response_dict.keys()) - {"message"} + state_updates = {k: v for k, v in response_dict.items() if k in state_keys} - if state_updates: - flow.current_state.update(state_updates) - yield StateSnapshotEvent(snapshot=flow.current_state) - logger.info(f"Emitted StateSnapshotEvent with updates: {list(state_updates.keys())}") + if state_updates: + flow.current_state.update(state_updates) + yield StateSnapshotEvent(snapshot=flow.current_state) + logger.info(f"Emitted StateSnapshotEvent with updates: {list(state_updates.keys())}") - # Emit message field as text if present - if "message" in response_dict and response_dict["message"]: - message_id = generate_event_id() - yield TextMessageStartEvent(message_id=message_id, role="assistant") - yield TextMessageContentEvent(message_id=message_id, delta=response_dict["message"]) - yield TextMessageEndEvent(message_id=message_id) - logger.info(f"Emitted conversational message with length={len(response_dict['message'])}") + # Emit message field as text if present + message_text = response_dict.get("message") + if isinstance(message_text, str) and message_text: + message_id = generate_event_id() + yield TextMessageStartEvent(message_id=message_id, role="assistant") + yield TextMessageContentEvent(message_id=message_id, delta=message_text) + yield TextMessageEndEvent(message_id=message_id) + logger.info(f"Emitted conversational message with length={len(message_text)}") # Feature #1: Emit ToolCallEndEvent for declaration-only tools (tools without results) pending_without_end = flow.get_pending_without_end() @@ -992,8 +1034,8 @@ async def run_agent_stream( yield ToolCallEndEvent(tool_call_id=tool_call_id) # For predictive tools with require_confirmation, emit confirm_changes - if config.require_confirmation and config.predict_state_config and tool_name: - is_predictive_tool = any(cfg["tool"] == tool_name for cfg in config.predict_state_config.values()) + if config.require_confirmation and predict_state_config and tool_name: + is_predictive_tool = any(cfg["tool"] == tool_name for cfg in predict_state_config.values()) if is_predictive_tool: logger.info(f"Emitting confirm_changes for predictive tool '{tool_name}'") # Extract state value from tool arguments for StateSnapshot @@ -1074,7 +1116,7 @@ async def run_agent_stream( last_call_id = last_result.get("toolCallId") last_tool_name = flow.get_tool_name(last_call_id) if not _should_suppress_intermediate_snapshot( - last_tool_name, config.predict_state_config, config.require_confirmation + last_tool_name, predict_state_config, config.require_confirmation ): yield _build_messages_snapshot(flow, snapshot_messages) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_types.py b/python/packages/ag-ui/agent_framework_ag_ui/_types.py index 928a755b31..383bf78b5a 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_types.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_types.py @@ -18,8 +18,8 @@ else: from typing_extensions import TypedDict # type: ignore # pragma: no cover -TAGUIChatOptions = TypeVar("TAGUIChatOptions", bound=TypedDict, default="AGUIChatOptions", covariant=True) # type: ignore[valid-type] -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None) +AGUIChatOptionsT = TypeVar("AGUIChatOptionsT", bound=TypedDict, default="AGUIChatOptions", covariant=True) # type: ignore[valid-type] +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) class PredictStateConfig(TypedDict): @@ -84,7 +84,7 @@ class AGUIRequest(BaseModel): # region AG-UI Chat Options TypedDict -class AGUIChatOptions(ChatOptions[TResponseModel], Generic[TResponseModel], total=False): +class AGUIChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): """AG-UI protocol-specific chat options dict. Extends base ChatOptions for the AG-UI (Agent-UI) protocol. diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_utils.py b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py index 356ad7da96..abbfb88562 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_utils.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py @@ -12,7 +12,7 @@ from dataclasses import asdict, is_dataclass from datetime import date, datetime from typing import Any -from agent_framework import AgentResponseUpdate, ChatResponseUpdate, FunctionTool, ToolProtocol +from agent_framework import AgentResponseUpdate, ChatResponseUpdate, FunctionTool # Role mapping constants AGUI_TO_FRAMEWORK_ROLE: dict[str, str] = { @@ -162,7 +162,7 @@ def make_json_safe(obj: Any) -> Any: # noqa: ANN401 def convert_agui_tools_to_agent_framework( agui_tools: list[dict[str, Any]] | None, -) -> list[FunctionTool[Any, Any]] | None: +) -> list[FunctionTool[Any]] | None: """Convert AG-UI tool definitions to Agent Framework FunctionTool declarations. Creates declaration-only FunctionTool instances (no executable implementation). @@ -181,13 +181,13 @@ def convert_agui_tools_to_agent_framework( if not agui_tools: return None - result: list[FunctionTool[Any, Any]] = [] + result: list[FunctionTool[Any]] = [] for tool_def in agui_tools: # Create declaration-only FunctionTool (func=None means no implementation) # When func=None, the declaration_only property returns True, # which tells the function invocation mixin to return the function call # without executing it (so it can be sent back to the client) - func: FunctionTool[Any, Any] = FunctionTool( + func: FunctionTool[Any] = FunctionTool( name=tool_def.get("name", ""), description=tool_def.get("description", ""), func=None, # CRITICAL: Makes declaration_only=True @@ -200,10 +200,10 @@ def convert_agui_tools_to_agent_framework( def convert_tools_to_agui_format( tools: ( - ToolProtocol + FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None ), ) -> list[dict[str, Any]] | None: @@ -225,7 +225,7 @@ def convert_tools_to_agui_format( # Normalize to list if not isinstance(tools, list): - tool_list: list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] = [tools] # type: ignore[list-item] + tool_list: list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] = [tools] # type: ignore[list-item] else: tool_list = tools # type: ignore[assignment] @@ -256,12 +256,8 @@ def convert_tools_to_agui_format( "parameters": ai_func.parameters(), } ) - elif isinstance(tool_item, ToolProtocol): - # Handle other ToolProtocol implementations - # For now, we'll skip non-FunctionTool instances as they may not have - # the parameters() method. This matches .NET behavior which only - # converts FunctionToolDeclaration instances. - continue + # Note: dict-based hosted tools (CodeInterpreter, WebSearch, etc.) are passed through + # as-is in the first branch. Non-FunctionTool, non-dict items are skipped. return results if results else None diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md b/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md index df07cff85d..e11a05d863 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md @@ -12,7 +12,7 @@ pip install agent-framework-ag-ui ### Using Example Agents with Any Chat Client -All example agents are factory functions that accept any `ChatClientProtocol`-compatible chat client: +All example agents are factory functions that accept any `SupportsChatGetResponse`-compatible chat client: ```python from fastapi import FastAPI @@ -38,15 +38,15 @@ add_agent_framework_fastapi_endpoint(app, weather_agent(openai_client), "/weathe ```python from fastapi import FastAPI -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.azure import AzureOpenAIChatClient from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint # Create your agent -agent = ChatAgent( +agent = Agent( name="my_agent", instructions="You are a helpful assistant.", - chat_client=AzureOpenAIChatClient(model_id="gpt-4o"), + client=AzureOpenAIChatClient(model_id="gpt-4o"), ) # Create FastAPI app and add AG-UI endpoint @@ -70,21 +70,21 @@ This integration supports all 7 AG-UI features: ## Examples -All example agents are implemented as **factory functions** that accept any chat client implementing `ChatClientProtocol`. This provides maximum flexibility to use Azure OpenAI, OpenAI, Anthropic, or any custom chat client implementation. +All example agents are implemented as **factory functions** that accept any chat client implementing `SupportsChatGetResponse`. This provides maximum flexibility to use Azure OpenAI, OpenAI, Anthropic, or any custom chat client implementation. ### Available Example Agents Complete examples for all AG-UI features are available: -- `simple_agent(chat_client)` - Basic agentic chat (Feature 1) -- `weather_agent(chat_client)` - Backend tool rendering (Feature 2) -- `human_in_the_loop_agent(chat_client)` - Human-in-the-loop with step customization (Feature 3) -- `task_steps_agent_wrapped(chat_client)` - Agentic generative UI with step execution (Feature 4) -- `ui_generator_agent(chat_client)` - Tool-based generative UI (Feature 5) -- `recipe_agent(chat_client)` - Shared state management (Feature 6) -- `document_writer_agent(chat_client)` - Predictive state updates (Feature 7) -- `research_assistant_agent(chat_client)` - Research with progress events -- `task_planner_agent(chat_client)` - Task planning with approvals +- `simple_agent(client)` - Basic agentic chat (Feature 1) +- `weather_agent(client)` - Backend tool rendering (Feature 2) +- `human_in_the_loop_agent(client)` - Human-in-the-loop with step customization (Feature 3) +- `task_steps_agent_wrapped(client)` - Agentic generative UI with step execution (Feature 4) +- `ui_generator_agent(client)` - Tool-based generative UI (Feature 5) +- `recipe_agent(client)` - Shared state management (Feature 6) +- `document_writer_agent(client)` - Predictive state updates (Feature 7) +- `research_assistant_agent(client)` - Research with progress events +- `task_planner_agent(client)` - Task planning with approvals ### Using Example Agents @@ -97,7 +97,7 @@ from agent_framework_ag_ui_examples.agents import ( recipe_agent, ) -# Create a chat client (use any ChatClientProtocol implementation) +# Create a chat client (use any SupportsChatGetResponse implementation) azure_client = AzureOpenAIChatClient(model_id="gpt-4") openai_client = OpenAIChatClient(model_id="gpt-4o") @@ -150,16 +150,16 @@ from agent_framework_ag_ui_examples.agents import ( app = FastAPI(title="AG-UI Examples") # Create a chat client (shared across all agents, or create individual ones) -chat_client = AzureOpenAIChatClient(model_id="gpt-4") +client = AzureOpenAIChatClient(model_id="gpt-4") # Add all example endpoints -add_agent_framework_fastapi_endpoint(app, simple_agent(chat_client), "/agentic_chat") -add_agent_framework_fastapi_endpoint(app, weather_agent(chat_client), "/backend_tool_rendering") -add_agent_framework_fastapi_endpoint(app, human_in_the_loop_agent(chat_client), "/human_in_the_loop") -add_agent_framework_fastapi_endpoint(app, task_steps_agent_wrapped(chat_client), "/agentic_generative_ui") # type: ignore[arg-type] -add_agent_framework_fastapi_endpoint(app, ui_generator_agent(chat_client), "/tool_based_generative_ui") -add_agent_framework_fastapi_endpoint(app, recipe_agent(chat_client), "/shared_state") -add_agent_framework_fastapi_endpoint(app, document_writer_agent(chat_client), "/predictive_state_updates") +add_agent_framework_fastapi_endpoint(app, simple_agent(client), "/agentic_chat") +add_agent_framework_fastapi_endpoint(app, weather_agent(client), "/backend_tool_rendering") +add_agent_framework_fastapi_endpoint(app, human_in_the_loop_agent(client), "/human_in_the_loop") +add_agent_framework_fastapi_endpoint(app, task_steps_agent_wrapped(client), "/agentic_generative_ui") # type: ignore[arg-type] +add_agent_framework_fastapi_endpoint(app, ui_generator_agent(client), "/tool_based_generative_ui") +add_agent_framework_fastapi_endpoint(app, recipe_agent(client), "/shared_state") +add_agent_framework_fastapi_endpoint(app, document_writer_agent(client), "/predictive_state_updates") ``` ## Architecture @@ -187,8 +187,8 @@ The package uses a clean, orchestrator-based architecture: You can create your own agent factories following the same pattern as the examples: ```python -from agent_framework import ChatAgent, tool -from agent_framework import ChatClientProtocol +from agent_framework import Agent, tool +from agent_framework import SupportsChatGetResponse from agent_framework.ag_ui import AgentFrameworkAgent @tool @@ -196,19 +196,19 @@ def my_tool(param: str) -> str: """My custom tool.""" return f"Result: {param}" -def my_custom_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent: +def my_custom_agent(client: SupportsChatGetResponse) -> AgentFrameworkAgent: """Create a custom agent with the specified chat client. Args: - chat_client: The chat client to use for the agent + client: The chat client to use for the agent Returns: A configured AgentFrameworkAgent instance """ - agent = ChatAgent( + agent = Agent( name="my_custom_agent", instructions="Custom instructions here", - chat_client=chat_client, + client=client, tools=[my_tool], ) @@ -220,8 +220,8 @@ def my_custom_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent: # Use it from agent_framework.azure import AzureOpenAIChatClient -chat_client = AzureOpenAIChatClient() -agent = my_custom_agent(chat_client) +client = AzureOpenAIChatClient() +agent = my_custom_agent(client) ``` ### Shared State @@ -229,14 +229,14 @@ agent = my_custom_agent(chat_client) State is injected as system messages and updated via predictive state updates: ```python -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.azure import AzureOpenAIChatClient from agent_framework.ag_ui import AgentFrameworkAgent # Create your agent -agent = ChatAgent( +agent = Agent( name="recipe_agent", - chat_client=AzureOpenAIChatClient(model_id="gpt-4o"), + client=AzureOpenAIChatClient(model_id="gpt-4o"), ) state_schema = { @@ -266,14 +266,14 @@ wrapped_agent = AgentFrameworkAgent( Predictive state updates automatically stream tool arguments as optimistic state updates: ```python -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.azure import AzureOpenAIChatClient from agent_framework.ag_ui import AgentFrameworkAgent # Create your agent -agent = ChatAgent( +agent = Agent( name="document_writer", - chat_client=AzureOpenAIChatClient(model_id="gpt-4o"), + client=AzureOpenAIChatClient(model_id="gpt-4o"), ) predict_state_config = { diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/document_writer_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/document_writer_agent.py index 3a74af346a..427583a589 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/document_writer_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/document_writer_agent.py @@ -4,7 +4,7 @@ from __future__ import annotations -from agent_framework import ChatAgent, ChatClientProtocol, tool +from agent_framework import Agent, SupportsChatGetResponse, tool from agent_framework.ag_ui import AgentFrameworkAgent @@ -40,19 +40,19 @@ _DOCUMENT_WRITER_INSTRUCTIONS = ( ) -def document_writer_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent: +def document_writer_agent(client: SupportsChatGetResponse) -> AgentFrameworkAgent: """Create a document writer agent with predictive state updates. Args: - chat_client: The chat client to use for the agent + client: The chat client to use for the agent Returns: A configured AgentFrameworkAgent instance with document writing capabilities """ - agent = ChatAgent( + agent = Agent( name="document_writer", instructions=_DOCUMENT_WRITER_INSTRUCTIONS, - chat_client=chat_client, + client=client, tools=[write_document], ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/human_in_the_loop_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/human_in_the_loop_agent.py index 368c4e47ed..b04b6619e4 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/human_in_the_loop_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/human_in_the_loop_agent.py @@ -5,7 +5,7 @@ from enum import Enum from typing import Any -from agent_framework import ChatAgent, ChatClientProtocol, tool +from agent_framework import Agent, SupportsChatGetResponse, tool from pydantic import BaseModel, Field @@ -43,16 +43,16 @@ def generate_task_steps(steps: list[TaskStep]) -> str: return f"Generated {len(steps)} execution steps for the task." -def human_in_the_loop_agent(chat_client: ChatClientProtocol[Any]) -> ChatAgent[Any]: +def human_in_the_loop_agent(client: SupportsChatGetResponse[Any]) -> Agent[Any]: """Create a human-in-the-loop agent using tool-based approach for predictive state. Args: - chat_client: The chat client to use for the agent + client: The chat client to use for the agent Returns: - A configured ChatAgent instance with human-in-the-loop capabilities + A configured Agent instance with human-in-the-loop capabilities """ - return ChatAgent( + return Agent( name="human_in_the_loop_agent", instructions="""You are a helpful assistant that can perform any task by breaking it down into steps. @@ -81,6 +81,6 @@ def human_in_the_loop_agent(chat_client: ChatClientProtocol[Any]) -> ChatAgent[A After the user approves and the function executes, THEN provide a brief acknowledgment like: "The plan has been created with X steps selected." """, - chat_client=chat_client, + client=client, tools=[generate_task_steps], ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/recipe_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/recipe_agent.py index 2d9bb066ba..f2d1aecdff 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/recipe_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/recipe_agent.py @@ -7,7 +7,7 @@ from __future__ import annotations from enum import Enum from typing import Any -from agent_framework import ChatAgent, ChatClientProtocol, tool +from agent_framework import Agent, SupportsChatGetResponse, tool from agent_framework.ag_ui import AgentFrameworkAgent from pydantic import BaseModel, Field @@ -104,19 +104,19 @@ _RECIPE_INSTRUCTIONS = """You are a helpful recipe assistant that creates and mo """ -def recipe_agent(chat_client: ChatClientProtocol[Any]) -> AgentFrameworkAgent: +def recipe_agent(client: SupportsChatGetResponse[Any]) -> AgentFrameworkAgent: """Create a recipe agent with streaming state updates. Args: - chat_client: The chat client to use for the agent + client: The chat client to use for the agent Returns: A configured AgentFrameworkAgent instance with recipe management """ - agent = ChatAgent( + agent = Agent( name="recipe_agent", instructions=_RECIPE_INSTRUCTIONS, - chat_client=chat_client, + client=client, tools=[update_recipe], ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/research_assistant_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/research_assistant_agent.py index b92874421a..fbd6a0b89c 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/research_assistant_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/research_assistant_agent.py @@ -5,7 +5,7 @@ import asyncio from typing import Any -from agent_framework import ChatAgent, ChatClientProtocol, tool +from agent_framework import Agent, SupportsChatGetResponse, tool from agent_framework.ag_ui import AgentFrameworkAgent @@ -88,19 +88,19 @@ _RESEARCH_ASSISTANT_INSTRUCTIONS = ( ) -def research_assistant_agent(chat_client: ChatClientProtocol[Any]) -> AgentFrameworkAgent: +def research_assistant_agent(client: SupportsChatGetResponse[Any]) -> AgentFrameworkAgent: """Create a research assistant agent. Args: - chat_client: The chat client to use for the agent + client: The chat client to use for the agent Returns: A configured AgentFrameworkAgent instance with research capabilities """ - agent = ChatAgent( + agent = Agent( name="research_assistant", instructions=_RESEARCH_ASSISTANT_INSTRUCTIONS, - chat_client=chat_client, + client=client, tools=[research_topic, create_presentation, analyze_data], ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/simple_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/simple_agent.py index 3e72fd3a11..5be88cbfd3 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/simple_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/simple_agent.py @@ -4,20 +4,20 @@ from typing import Any -from agent_framework import ChatAgent, ChatClientProtocol +from agent_framework import Agent, SupportsChatGetResponse -def simple_agent(chat_client: ChatClientProtocol[Any]) -> ChatAgent[Any]: +def simple_agent(client: SupportsChatGetResponse[Any]) -> Agent[Any]: """Create a simple chat agent. Args: - chat_client: The chat client to use for the agent + client: The chat client to use for the agent Returns: - A configured ChatAgent instance + A configured Agent instance """ - return ChatAgent[Any]( + return Agent[Any]( name="simple_chat_agent", instructions="You are a helpful assistant. Be concise and friendly.", - chat_client=chat_client, + client=client, ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_planner_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_planner_agent.py index 57e14bb6c3..18065dd15f 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_planner_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_planner_agent.py @@ -4,7 +4,7 @@ from typing import Any -from agent_framework import ChatAgent, ChatClientProtocol, tool +from agent_framework import Agent, SupportsChatGetResponse, tool from agent_framework.ag_ui import AgentFrameworkAgent @@ -61,19 +61,19 @@ _TASK_PLANNER_INSTRUCTIONS = ( ) -def task_planner_agent(chat_client: ChatClientProtocol[Any]) -> AgentFrameworkAgent: +def task_planner_agent(client: SupportsChatGetResponse[Any]) -> AgentFrameworkAgent: """Create a task planner agent with user approval for actions. Args: - chat_client: The chat client to use for the agent + client: The chat client to use for the agent Returns: A configured AgentFrameworkAgent instance with task planning capabilities """ - agent = ChatAgent( + agent = Agent( name="task_planner", instructions=_TASK_PLANNER_INSTRUCTIONS, - chat_client=chat_client, + client=client, tools=[create_calendar_event, send_email, book_meeting_room], ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_steps_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_steps_agent.py index 2fe79d063f..be2da28a9d 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_steps_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/task_steps_agent.py @@ -20,7 +20,7 @@ from ag_ui.core import ( TextMessageStartEvent, ToolCallStartEvent, ) -from agent_framework import ChatAgent, ChatClientProtocol, ChatMessage, Content, tool +from agent_framework import Agent, Content, Message, SupportsChatGetResponse, tool from agent_framework.ag_ui import AgentFrameworkAgent from pydantic import BaseModel, Field @@ -54,16 +54,16 @@ def generate_task_steps(steps: list[TaskStep]) -> str: return "Steps generated." -def _create_task_steps_agent(chat_client: ChatClientProtocol[Any]) -> AgentFrameworkAgent: +def _create_task_steps_agent(client: SupportsChatGetResponse[Any]) -> AgentFrameworkAgent: """Create the task steps agent using tool-based approach for streaming. Args: - chat_client: The chat client to use for the agent + client: The chat client to use for the agent Returns: A configured AgentFrameworkAgent instance """ - agent = ChatAgent[Any]( + agent = Agent[Any]( name="task_steps_agent", instructions="""You are a helpful assistant that breaks down tasks into actionable steps. @@ -83,7 +83,7 @@ def _create_task_steps_agent(chat_client: ChatClientProtocol[Any]) -> AgentFrame - "Installing platform" - "Adding finishing touches" """, - chat_client=chat_client, + client=client, tools=[generate_task_steps], ) @@ -220,30 +220,30 @@ class TaskStepsAgentWithExecution: # Get the underlying chat agent and client chat_agent = self._base_agent.agent # type: ignore - chat_client = chat_agent.chat_client # type: ignore + client = chat_agent.client # type: ignore # Build messages for summary call original_messages = input_data.get("messages", []) - # Convert to ChatMessage objects if needed - messages: list[ChatMessage] = [] + # Convert to Message objects if needed + messages: list[Message] = [] for msg in original_messages: if isinstance(msg, dict): content_str = msg.get("content", "") if isinstance(content_str, str): messages.append( - ChatMessage( + Message( role=msg.get("role", "user"), contents=[Content.from_text(text=content_str)], ) ) - elif isinstance(msg, ChatMessage): + elif isinstance(msg, Message): messages.append(msg) # Add completion message messages.append( - ChatMessage( + Message( role="user", contents=[ Content.from_text( @@ -270,7 +270,7 @@ class TaskStepsAgentWithExecution: # Stream completion accumulated_text = "" - async for chunk in chat_client.get_response(messages=messages, stream=True): + async for chunk in client.get_response(messages=messages, stream=True): # chunk is ChatResponseUpdate if hasattr(chunk, "text") and chunk.text: accumulated_text += chunk.text @@ -332,14 +332,14 @@ class TaskStepsAgentWithExecution: yield run_finished_event -def task_steps_agent_wrapped(chat_client: ChatClientProtocol[Any]) -> TaskStepsAgentWithExecution: +def task_steps_agent_wrapped(client: SupportsChatGetResponse[Any]) -> TaskStepsAgentWithExecution: """Create a task steps agent with execution simulation. Args: - chat_client: The chat client to use for the agent + client: The chat client to use for the agent Returns: A wrapped agent instance with step execution simulation """ - base_agent = _create_task_steps_agent(chat_client) + base_agent = _create_task_steps_agent(client) return TaskStepsAgentWithExecution(base_agent) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py index 33848c379c..7f5a4b0f2c 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py @@ -7,7 +7,7 @@ from __future__ import annotations import sys from typing import TYPE_CHECKING, Any, TypedDict -from agent_framework import ChatAgent, ChatClientProtocol, FunctionTool +from agent_framework import Agent, FunctionTool, SupportsChatGetResponse from agent_framework.ag_ui import AgentFrameworkAgent if sys.version_info >= (3, 13): @@ -23,7 +23,7 @@ if TYPE_CHECKING: from agent_framework import ChatOptions # Declaration-only tools (func=None) - actual rendering happens on the client side -generate_haiku = FunctionTool[Any, str]( +generate_haiku = FunctionTool[Any]( name="generate_haiku", description="""Generate a haiku with image and gradient background (FRONTEND_RENDER). @@ -71,7 +71,7 @@ generate_haiku = FunctionTool[Any, str]( }, ) -create_chart = FunctionTool[Any, str]( +create_chart = FunctionTool[Any]( name="create_chart", description="""Create an interactive chart (FRONTEND_RENDER). @@ -99,7 +99,7 @@ create_chart = FunctionTool[Any, str]( }, ) -display_timeline = FunctionTool[Any, str]( +display_timeline = FunctionTool[Any]( name="display_timeline", description="""Display an interactive timeline (FRONTEND_RENDER). @@ -127,7 +127,7 @@ display_timeline = FunctionTool[Any, str]( }, ) -show_comparison_table = FunctionTool[Any, str]( +show_comparison_table = FunctionTool[Any]( name="show_comparison_table", description="""Show a comparison table (FRONTEND_RENDER). @@ -165,22 +165,22 @@ _UI_GENERATOR_INSTRUCTIONS = """You MUST use the provided tools to generate cont For other requests, use the appropriate tool (create_chart, display_timeline, show_comparison_table). """ -TOptions = TypeVar("TOptions", bound=TypedDict, default="ChatOptions") # type: ignore[valid-type] +OptionsT = TypeVar("OptionsT", bound=TypedDict, default="ChatOptions") # type: ignore[valid-type] -def ui_generator_agent(chat_client: ChatClientProtocol[TOptions]) -> AgentFrameworkAgent: +def ui_generator_agent(client: SupportsChatGetResponse[OptionsT]) -> AgentFrameworkAgent: """Create a UI generator agent with custom React component rendering. Args: - chat_client: The chat client to use for the agent + client: The chat client to use for the agent Returns: A configured AgentFrameworkAgent instance with UI generation capabilities """ - agent = ChatAgent( + agent = Agent( name="ui_generator", instructions=_UI_GENERATOR_INSTRUCTIONS, - chat_client=chat_client, + client=client, tools=[generate_haiku, create_chart, display_timeline, show_comparison_table], # Force tool usage - the LLM MUST call a tool, cannot respond with plain text default_options={"tool_choice": "required"}, # type: ignore diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_agent.py index f8b03c2d0e..7e80fccfd7 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/weather_agent.py @@ -6,7 +6,7 @@ from __future__ import annotations from typing import Any -from agent_framework import ChatAgent, ChatClientProtocol, tool +from agent_framework import Agent, SupportsChatGetResponse, tool @tool @@ -59,16 +59,16 @@ def get_forecast(location: str, days: int = 3) -> str: return f"{days}-day forecast for {location}:\n" + "\n".join(forecast) -def weather_agent(chat_client: ChatClientProtocol[Any]) -> ChatAgent[Any]: +def weather_agent(client: SupportsChatGetResponse[Any]) -> Agent[Any]: """Create a weather agent with get_weather and get_forecast tools. Args: - chat_client: The chat client to use for the agent + client: The chat client to use for the agent Returns: - A configured ChatAgent instance with weather tools + A configured Agent instance with weather tools """ - return ChatAgent[Any]( + return Agent[Any]( name="weather_agent", instructions=( "You are a helpful weather assistant. " @@ -76,6 +76,6 @@ def weather_agent(chat_client: ChatClientProtocol[Any]) -> ChatAgent[Any]: "Always provide friendly and informative responses. " "First return the weather result, and then return details about the forecast." ), - chat_client=chat_client, + client=client, tools=[get_weather, get_forecast], ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py index 915e57c6e2..b18fc103e8 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py @@ -4,7 +4,7 @@ from typing import Any, cast -from agent_framework._clients import ChatClientProtocol +from agent_framework._clients import SupportsChatGetResponse from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint from agent_framework.azure import AzureOpenAIChatClient from fastapi import FastAPI @@ -19,10 +19,10 @@ def register_backend_tool_rendering(app: FastAPI) -> None: app: The FastAPI application. """ # Create a chat client and call the factory function - chat_client = cast(ChatClientProtocol[Any], AzureOpenAIChatClient()) + client = cast(SupportsChatGetResponse[Any], AzureOpenAIChatClient()) add_agent_framework_fastapi_endpoint( app, - weather_agent(chat_client), + weather_agent(client), "/backend_tool_rendering", ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py index 8c2f4be261..f45b30816f 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py @@ -10,7 +10,7 @@ from typing import cast import uvicorn from agent_framework import ChatOptions -from agent_framework._clients import ChatClientProtocol +from agent_framework._clients import SupportsChatGetResponse from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint from agent_framework.anthropic import AnthropicClient from agent_framework.azure import AzureOpenAIChatClient @@ -67,43 +67,43 @@ app.add_middleware( # Create a shared chat client for all agents # You can use different chat clients for different agents if needed # Set CHAT_CLIENT=anthropic to use Anthropic, defaults to Azure OpenAI -chat_client: ChatClientProtocol[ChatOptions] = cast( - ChatClientProtocol[ChatOptions], +client: SupportsChatGetResponse[ChatOptions] = cast( + SupportsChatGetResponse[ChatOptions], AnthropicClient() if os.getenv("CHAT_CLIENT", "").lower() == "anthropic" else AzureOpenAIChatClient(), ) # Agentic Chat - basic chat agent add_agent_framework_fastapi_endpoint( app=app, - agent=simple_agent(chat_client), + agent=simple_agent(client), path="/agentic_chat", ) # Backend Tool Rendering - agent with tools add_agent_framework_fastapi_endpoint( app=app, - agent=weather_agent(chat_client), + agent=weather_agent(client), path="/backend_tool_rendering", ) # Shared State - recipe agent with structured output add_agent_framework_fastapi_endpoint( app=app, - agent=recipe_agent(chat_client), + agent=recipe_agent(client), path="/shared_state", ) # Predictive State Updates - document writer with predictive state add_agent_framework_fastapi_endpoint( app=app, - agent=document_writer_agent(chat_client), + agent=document_writer_agent(client), path="/predictive_state_updates", ) # Human in the Loop - human-in-the-loop agent with step customization add_agent_framework_fastapi_endpoint( app=app, - agent=human_in_the_loop_agent(chat_client), + agent=human_in_the_loop_agent(client), path="/human_in_the_loop", state_schema={"steps": {"type": "array"}}, predict_state_config={"steps": {"tool": "generate_task_steps", "tool_argument": "steps"}}, @@ -112,14 +112,14 @@ add_agent_framework_fastapi_endpoint( # Agentic Generative UI - task steps agent with streaming state updates add_agent_framework_fastapi_endpoint( app=app, - agent=task_steps_agent_wrapped(chat_client), # type: ignore[arg-type] + agent=task_steps_agent_wrapped(client), # type: ignore[arg-type] path="/agentic_generative_ui", ) # Tool-based Generative UI - UI generator with frontend-rendered tools add_agent_framework_fastapi_endpoint( app=app, - agent=ui_generator_agent(chat_client), + agent=ui_generator_agent(client), path="/tool_based_generative_ui", ) diff --git a/python/packages/ag-ui/getting_started/README.md b/python/packages/ag-ui/getting_started/README.md index 9421935a4d..d3d14694a5 100644 --- a/python/packages/ag-ui/getting_started/README.md +++ b/python/packages/ag-ui/getting_started/README.md @@ -35,9 +35,9 @@ python client_advanced.py **Note:** This example shows direct `AGUIChatClient` usage. Tool execution and conversation continuity depend on server-side configuration and capabilities. -### ChatAgent Integration (`client_with_agent.py`) +### Agent Integration (`client_with_agent.py`) -Best practice example using `ChatAgent` wrapper with **AgentThread** +Best practice example using `Agent` wrapper with **AgentThread** - **AgentThread** maintains conversation state - Client-side conversation history management via `thread.message_store` - **Hybrid tool execution**: client-side + server-side tools simultaneously @@ -77,7 +77,7 @@ The AG-UI protocol supports two approaches to conversation history: - Full message history sent with each request - Works with any AG-UI server (stateful or stateless) -The `ChatAgent` wrapper (used in client_with_agent.py) collects messages from local storage and sends the full history to `AGUIChatClient`, which then forwards everything to the server. +The `Agent` wrapper (used in client_with_agent.py) collects messages from local storage and sends the full history to `AGUIChatClient`, which then forwards everything to the server. ### Tool/Function Calling @@ -91,14 +91,14 @@ Client defines: Server defines: User: "What's the weather in SF and what time is it?" ↓ -ChatAgent sends: full history + tool definitions for get_weather, read_sensors +Agent sends: full history + tool definitions for get_weather, read_sensors ↓ Server LLM decides: "I need get_weather('SF') and get_current_time()" ↓ Server executes get_current_time() → "2025-11-11 14:30:00 UTC" Server sends function call request → get_weather('SF') ↓ -ChatAgent intercepts get_weather call → executes locally +Agent intercepts get_weather call → executes locally ↓ Client sends result → "Sunny, 72°F" ↓ @@ -110,7 +110,7 @@ Client receives final response **How it works:** 1. **Client-Side Tools** (`client_with_agent.py`): - - Tools defined in ChatAgent's `tools` parameter execute locally + - Tools defined in Agent's `tools` parameter execute locally - Tool metadata (name, description, schema) sent to server for planning - When server requests client tool → client intercepts → executes locally → sends result @@ -126,7 +126,7 @@ Client receives final response - Client tools execute client-side **Direct AGUIChatClient Usage** (client_advanced.py): -Even without ChatAgent wrapper, client-side tools work: +Even without Agent wrapper, client-side tools work: - Tools passed in ChatOptions execute locally - Server can also have its own tools - Hybrid execution works automatically @@ -184,7 +184,7 @@ Create a file named `server.py`: import os -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.azure import AzureOpenAIChatClient from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint from fastapi import FastAPI @@ -202,10 +202,10 @@ if not api_key: raise ValueError("AZURE_OPENAI_API_KEY environment variable is required") # Create the AI agent -agent = ChatAgent( +agent = Agent( name="AGUIAssistant", instructions="You are a helpful assistant.", - chat_client=AzureOpenAIChatClient( + client=AzureOpenAIChatClient( endpoint=endpoint, deployment_name=deployment_name, api_key=api_key, @@ -227,7 +227,7 @@ if __name__ == "__main__": ### Key Concepts - **`add_agent_framework_fastapi_endpoint`**: Registers the AG-UI endpoint with automatic request/response handling and SSE streaming -- **`ChatAgent`**: The agent that will handle incoming requests +- **`Agent`**: The agent that will handle incoming requests - **FastAPI Integration**: Uses FastAPI's native async support for streaming responses - **Instructions**: The agent is created with default instructions, which can be overridden by client messages - **Configuration**: `AzureOpenAIChatClient` can read from environment variables (`AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_KEY`) or accept parameters directly @@ -236,10 +236,10 @@ if __name__ == "__main__": ```python # No need to read environment variables manually -agent = ChatAgent( +agent = Agent( name="AGUIAssistant", instructions="You are a helpful assistant.", - chat_client=AzureOpenAIChatClient(), # Reads from environment automatically + client=AzureOpenAIChatClient(), # Reads from environment automatically ) ``` @@ -354,7 +354,7 @@ if __name__ == "__main__": - **Thread Management**: Pass `thread_id` in metadata to maintain conversation context across requests - **Streaming Responses**: Use `get_response(..., stream=True)` for real-time streaming or `get_response(..., stream=False)` for non-streaming - **Context Manager**: Use `async with` for automatic cleanup of HTTP connections -- **Standard Interface**: Works with all Agent Framework patterns (ChatAgent, tools, etc.) +- **Standard Interface**: Works with all Agent Framework patterns (Agent, tools, etc.) - **Hybrid Tool Execution**: Supports both client-side and server-side tools executing together in the same conversation ### Configure and Run the Client diff --git a/python/packages/ag-ui/getting_started/client_advanced.py b/python/packages/ag-ui/getting_started/client_advanced.py index 65f5e896bf..dcb4e5ca3c 100644 --- a/python/packages/ag-ui/getting_started/client_advanced.py +++ b/python/packages/ag-ui/getting_started/client_advanced.py @@ -114,15 +114,15 @@ async def non_streaming_example(client: AGUIChatClient, thread_id: str | None = async def tool_example(client: AGUIChatClient, thread_id: str | None = None): """Demonstrate sending tool definitions to the server. - IMPORTANT: When using AGUIChatClient directly (without ChatAgent wrapper): + IMPORTANT: When using AGUIChatClient directly (without Agent wrapper): - Tools are sent as DEFINITIONS only - No automatic client-side execution (no function invocation middleware) - Server must have matching tool implementations to execute them For CLIENT-SIDE tool execution (like .NET AGUIClient sample): - - Use ChatAgent wrapper with tools + - Use Agent wrapper with tools - See client_with_agent.py for the hybrid pattern - - ChatAgent middleware intercepts and executes client tools locally + - Agent middleware intercepts and executes client tools locally - Server can have its own tools that execute server-side - Both client and server tools work together in same conversation @@ -186,7 +186,7 @@ async def conversation_example(client: AGUIChatClient): # Check if context was maintained if "alice" not in response2.text.lower(): - print("\n[Note: Server may not maintain thread context - consider using ChatAgent for history management]") + print("\n[Note: Server may not maintain thread context - consider using Agent for history management]") # Third turn print("\nUser: Can you also tell me what 10 * 5 is?\n") diff --git a/python/packages/ag-ui/getting_started/client_with_agent.py b/python/packages/ag-ui/getting_started/client_with_agent.py index 5d9917327b..11ba4c95ca 100644 --- a/python/packages/ag-ui/getting_started/client_with_agent.py +++ b/python/packages/ag-ui/getting_started/client_with_agent.py @@ -1,13 +1,13 @@ # Copyright (c) Microsoft. All rights reserved. -"""Example showing ChatAgent with AGUIChatClient for hybrid tool execution. +"""Example showing Agent with AGUIChatClient for hybrid tool execution. This demonstrates the HYBRID pattern matching .NET AGUIClient implementation: -1. AgentThread Pattern (like .NET): - - Create thread with agent.get_new_thread() - - Pass thread to agent.run(stream=True) on each turn - - Thread automatically maintains conversation history via message_store +1. AgentSession Pattern (like .NET): + - Create session with agent.create_session() + - Pass session to agent.run(stream=True) on each turn + - Session maintains conversation context via context providers 2. Hybrid Tool Execution: - AGUIChatClient uses function invocation mixin @@ -15,7 +15,7 @@ This demonstrates the HYBRID pattern matching .NET AGUIClient implementation: - Server may also have its own tools that execute server-side - Both work together: server LLM decides which tool to call, decorator handles client execution -This matches .NET pattern: thread maintains state, tools execute on appropriate side. +This matches .NET pattern: session maintains state, tools execute on appropriate side. """ from __future__ import annotations @@ -24,7 +24,7 @@ import asyncio import logging import os -from agent_framework import ChatAgent, tool +from agent_framework import Agent, tool from agent_framework.ag_ui import AGUIChatClient # Enable debug logging @@ -55,26 +55,26 @@ def get_weather(location: str) -> str: async def main(): - """Demonstrate ChatAgent + AGUIChatClient hybrid tool execution. + """Demonstrate Agent + AGUIChatClient hybrid tool execution. This matches the .NET pattern from Program.cs where: - AIAgent agent = chatClient.CreateAIAgent(tools: [...]) - - AgentThread thread = agent.GetNewThread() - - RunStreamingAsync(messages, thread) + - AgentSession session = agent.CreateSession() + - RunStreamingAsync(messages, session) Python equivalent: - - agent = ChatAgent(chat_client=AGUIChatClient(...), tools=[...]) - - thread = agent.get_new_thread() # Creates thread with message_store - - agent.run(message, stream=True, thread=thread) # Thread accumulates history + - agent = Agent(client=AGUIChatClient(...), tools=[...]) + - session = agent.create_session() # Creates session + - agent.run(message, stream=True, session=session) # Session tracks context """ server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/") print("=" * 70) - print("ChatAgent + AGUIChatClient: Hybrid Tool Execution") + print("Agent + AGUIChatClient: Hybrid Tool Execution") print("=" * 70) print(f"\nServer: {server_url}") print("\nThis example demonstrates:") - print(" 1. AgentThread maintains conversation state (like .NET)") + print(" 1. AgentSession maintains conversation state (like .NET)") print(" 2. Client-side tools execute locally via function invocation mixin") print(" 3. Server may have additional tools that execute server-side") print(" 4. HYBRID: Client and server tools work together simultaneously\n") @@ -82,16 +82,16 @@ async def main(): try: # Create remote client in async context manager async with AGUIChatClient(endpoint=server_url) as remote_client: - # Wrap in ChatAgent for conversation history management - agent = ChatAgent( + # Wrap in Agent for conversation history management + agent = Agent( name="remote_assistant", instructions="You are a helpful assistant. Remember user information across the conversation.", - chat_client=remote_client, + client=remote_client, tools=[get_weather], ) - # Create a thread to maintain conversation state (like .NET AgentThread) - thread = agent.get_new_thread() + # Create a session to maintain conversation state (like .NET AgentSession) + session = agent.create_session() print("=" * 70) print("CONVERSATION WITH HISTORY") @@ -99,21 +99,21 @@ async def main(): # Turn 1: Introduce print("\nUser: My name is Alice and I live in Seattle\n") - async for chunk in agent.run("My name is Alice and I live in Seattle", stream=True, thread=thread): + async for chunk in agent.run("My name is Alice and I live in Seattle", stream=True, session=session): if chunk.text: print(chunk.text, end="", flush=True) print("\n") # Turn 2: Ask about name (tests history) print("User: What's my name?\n") - async for chunk in agent.run("What's my name?", stream=True, thread=thread): + async for chunk in agent.run("What's my name?", stream=True, session=session): if chunk.text: print(chunk.text, end="", flush=True) print("\n") # Turn 3: Ask about location (tests history) print("User: Where do I live?\n") - async for chunk in agent.run("Where do I live?", stream=True, thread=thread): + async for chunk in agent.run("Where do I live?", stream=True, session=session): if chunk.text: print(chunk.text, end="", flush=True) print("\n") @@ -123,7 +123,7 @@ async def main(): async for chunk in agent.run( "What's the weather forecast for today in Seattle?", stream=True, - thread=thread, + session=session, ): if chunk.text: print(chunk.text, end="", flush=True) @@ -131,56 +131,11 @@ async def main(): # Turn 5: Test server-side tool (get_time_zone is server-side only) print("User: What time zone is Seattle in?\n") - async for chunk in agent.run("What time zone is Seattle in?", stream=True, thread=thread): + async for chunk in agent.run("What time zone is Seattle in?", stream=True, session=session): if chunk.text: print(chunk.text, end="", flush=True) print("\n") - # Show thread state - if thread.message_store: - - def _preview_for_message(m) -> str: - # Prefer plain text when present - if getattr(m, "text", ""): - t = m.text - return (t[:60] + "...") if len(t) > 60 else t - # Build from contents when no direct text - parts: list[str] = [] - for c in getattr(m, "contents", []) or []: - content_type = getattr(c, "type", None) - if content_type == "function_call": - args = getattr(c, "arguments", None) - if isinstance(args, dict): - try: - import json as _json - - args_str = _json.dumps(args) - except Exception: - args_str = str(args) - else: - args_str = str(args or "{}") - parts.append(f"tool_call {getattr(c, 'name', '?')} {args_str}") - elif content_type == "function_result": - call_id = getattr(c, "call_id", "?") - result = getattr(c, "result", None) - parts.append(f"tool_result[{call_id}]: {str(result)[:40]}") - elif content_type == "text": - text = getattr(c, "text", None) - if text: - parts.append(text) - else: - typename = getattr(c, "type", c.__class__.__name__) - parts.append(f"<{typename}>") - preview = " | ".join(parts) if parts else "" - return (preview[:60] + "...") if len(preview) > 60 else preview - - messages = await thread.message_store.list_messages() - print(f"\n[THREAD STATE] {len(messages)} messages in thread's message_store") - for i, msg in enumerate(messages[-6:], 1): # Show last 6 - role = msg.role if hasattr(msg.role, "value") else str(msg.role) - text_preview = _preview_for_message(msg) - print(f" {i}. [{role}]: {text_preview}") - except ConnectionError as e: print(f"\n\033[91mConnection Error: {e}\033[0m") print("\nMake sure an AG-UI server is running at the specified endpoint.") diff --git a/python/packages/ag-ui/getting_started/server.py b/python/packages/ag-ui/getting_started/server.py index fa3f21c3e7..8d32009fb1 100644 --- a/python/packages/ag-ui/getting_started/server.py +++ b/python/packages/ag-ui/getting_started/server.py @@ -7,7 +7,7 @@ from __future__ import annotations import logging import os -from agent_framework import ChatAgent, tool +from agent_framework import Agent, tool from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint from agent_framework.azure import AzureOpenAIChatClient from dotenv import load_dotenv @@ -116,10 +116,10 @@ def get_time_zone(location: str) -> str: # The client will send get_weather tool metadata so the LLM knows about it, # and the function invocation mixin on AGUIChatClient will execute it client-side. # This matches the .NET AG-UI hybrid execution pattern. -agent = ChatAgent( +agent = Agent( name="AGUIAssistant", instructions="You are a helpful assistant. Use get_weather for weather and get_time_zone for time zones.", - chat_client=AzureOpenAIChatClient( + client=AzureOpenAIChatClient( endpoint=endpoint, deployment_name=deployment_name, ), diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 128c684d35..72893a088b 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-ag-ui" -version = "1.0.0b260130" +version = "1.0.0b260212" description = "AG-UI protocol integration for Agent Framework" readme = "README.md" license-files = ["LICENSE"] @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "ag-ui-protocol>=0.1.9", "fastapi>=0.115.0", "uvicorn>=0.30.0" diff --git a/python/packages/ag-ui/tests/ag_ui/conftest.py b/python/packages/ag-ui/tests/ag_ui/conftest.py index 176f4c031d..09a4ff57f1 100644 --- a/python/packages/ag-ui/tests/ag_ui/conftest.py +++ b/python/packages/ag-ui/tests/ag_ui/conftest.py @@ -11,17 +11,17 @@ import pytest from agent_framework import ( AgentResponse, AgentResponseUpdate, - AgentThread, + AgentSession, BaseChatClient, - ChatClientProtocol, - ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate, Content, + Message, SupportsAgentRun, + SupportsChatGetResponse, ) -from agent_framework._clients import TOptions_co +from agent_framework._clients import OptionsCoT from agent_framework._middleware import ChatMiddlewareLayer from agent_framework._tools import FunctionInvocationLayer from agent_framework._types import ResponseStream @@ -37,25 +37,25 @@ ResponseFn = Callable[..., Awaitable[ChatResponse]] class StreamingChatClientStub( - ChatMiddlewareLayer[TOptions_co], - FunctionInvocationLayer[TOptions_co], - ChatTelemetryLayer[TOptions_co], - BaseChatClient[TOptions_co], - Generic[TOptions_co], + ChatMiddlewareLayer[OptionsCoT], + FunctionInvocationLayer[OptionsCoT], + ChatTelemetryLayer[OptionsCoT], + BaseChatClient[OptionsCoT], + Generic[OptionsCoT], ): - """Typed streaming stub that satisfies ChatClientProtocol.""" + """Typed streaming stub that satisfies SupportsChatGetResponse.""" def __init__(self, stream_fn: StreamFn, response_fn: ResponseFn | None = None) -> None: super().__init__(function_middleware=[]) self._stream_fn = stream_fn self._response_fn = response_fn - self.last_thread: AgentThread | None = None - self.last_service_thread_id: str | None = None + self.last_session: AgentSession | None = None + self.last_service_session_id: str | None = None @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[False] = ..., options: ChatOptions[Any], @@ -65,33 +65,33 @@ class StreamingChatClientStub( @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[False] = ..., - options: TOptions_co | ChatOptions[None] | None = ..., + options: OptionsCoT | ChatOptions[None] | None = ..., **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[True], - options: TOptions_co | ChatOptions[Any] | None = ..., + options: OptionsCoT | ChatOptions[Any] | None = ..., **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: bool = False, - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: - self.last_thread = kwargs.get("thread") - self.last_service_thread_id = self.last_thread.service_thread_id if self.last_thread else None + self.last_session = kwargs.get("session") + self.last_service_session_id = self.last_session.service_session_id if self.last_session else None return cast( Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]], super().get_response( @@ -106,7 +106,7 @@ class StreamingChatClientStub( def _inner_get_response( self, *, - messages: Sequence[ChatMessage], + messages: Sequence[Message], stream: bool = False, options: Mapping[str, Any], **kwargs: Any, @@ -121,7 +121,7 @@ class StreamingChatClientStub( return self._get_response_impl(messages, options, **kwargs) async def _get_response_impl( - self, messages: Sequence[ChatMessage], options: Mapping[str, Any], **kwargs: Any + self, messages: Sequence[Message], options: Mapping[str, Any], **kwargs: Any ) -> ChatResponse: """Non-streaming implementation.""" if self._response_fn is not None: @@ -132,7 +132,7 @@ class StreamingChatClientStub( contents.extend(update.contents) return ChatResponse( - messages=[ChatMessage(role="assistant", contents=contents)], + messages=[Message(role="assistant", contents=contents)], response_id="stub-response", ) @@ -141,7 +141,7 @@ def stream_from_updates(updates: list[ChatResponseUpdate]) -> StreamFn: """Create a stream function that yields from a static list of updates.""" async def _stream( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: for update in updates: yield update @@ -159,7 +159,7 @@ class StubAgent(SupportsAgentRun): agent_id: str = "stub-agent", agent_name: str | None = "stub-agent", default_options: Any | None = None, - chat_client: Any | None = None, + client: Any | None = None, ) -> None: self.id = agent_id self.name = agent_name @@ -168,36 +168,36 @@ class StubAgent(SupportsAgentRun): self.default_options: dict[str, Any] = ( default_options if isinstance(default_options, dict) else {"tools": None, "response_format": None} ) - self.chat_client = chat_client or SimpleNamespace(function_invocation_configuration=None) + self.client = client or SimpleNamespace(function_invocation_configuration=None) self.messages_received: list[Any] = [] self.tools_received: list[Any] | None = None @overload def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[False] = ..., - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @overload def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[True], - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: if stream: @@ -218,15 +218,15 @@ class StubAgent(SupportsAgentRun): return _get_response() - def get_new_thread(self, **kwargs: Any) -> AgentThread: - return AgentThread() + def create_session(self, **kwargs: Any) -> AgentSession: + return AgentSession() # Fixtures @pytest.fixture -def streaming_chat_client_stub() -> type[ChatClientProtocol]: +def streaming_chat_client_stub() -> type[SupportsChatGetResponse]: """Return the StreamingChatClientStub class for creating test instances.""" return StreamingChatClientStub # type: ignore[return-value] diff --git a/python/packages/ag-ui/tests/ag_ui/test_ag_ui_client.py b/python/packages/ag-ui/tests/ag_ui/test_ag_ui_client.py index b5dc73bd02..7e7fd7cded 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_ag_ui_client.py +++ b/python/packages/ag-ui/tests/ag_ui/test_ag_ui_client.py @@ -7,11 +7,11 @@ from collections.abc import AsyncGenerator, Awaitable, MutableSequence from typing import Any from agent_framework import ( - ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate, Content, + Message, ResponseStream, tool, ) @@ -29,13 +29,11 @@ class TestableAGUIChatClient(AGUIChatClient): """Expose http service for monkeypatching.""" return self._http_service - def extract_state_from_messages( - self, messages: list[ChatMessage] - ) -> tuple[list[ChatMessage], dict[str, Any] | None]: + def extract_state_from_messages(self, messages: list[Message]) -> tuple[list[Message], dict[str, Any] | None]: """Expose state extraction helper.""" return self._extract_state_from_messages(messages) - def convert_messages_to_agui_format(self, messages: list[ChatMessage]) -> list[dict[str, Any]]: + def convert_messages_to_agui_format(self, messages: list[Message]) -> list[dict[str, Any]]: """Expose message conversion helper.""" return self._convert_messages_to_agui_format(messages) @@ -44,7 +42,7 @@ class TestableAGUIChatClient(AGUIChatClient): return self._get_thread_id(options) def inner_get_response( - self, *, messages: MutableSequence[ChatMessage], options: dict[str, Any], stream: bool = False + self, *, messages: MutableSequence[Message], options: dict[str, Any], stream: bool = False ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: """Proxy to protected response call.""" return self._inner_get_response(messages=messages, options=options, stream=stream) @@ -69,8 +67,8 @@ class TestAGUIChatClient: """Test state extraction when no state is present.""" client = TestableAGUIChatClient(endpoint="http://localhost:8888/") messages = [ - ChatMessage(role="user", text="Hello"), - ChatMessage(role="assistant", text="Hi there"), + Message(role="user", text="Hello"), + Message(role="assistant", text="Hi there"), ] result_messages, state = client.extract_state_from_messages(messages) @@ -89,8 +87,8 @@ class TestAGUIChatClient: state_b64 = base64.b64encode(state_json.encode("utf-8")).decode("utf-8") messages = [ - ChatMessage(role="user", text="Hello"), - ChatMessage( + Message(role="user", text="Hello"), + Message( role="user", contents=[Content.from_uri(uri=f"data:application/json;base64,{state_b64}")], ), @@ -112,7 +110,7 @@ class TestAGUIChatClient: state_b64 = base64.b64encode(invalid_json.encode("utf-8")).decode("utf-8") messages = [ - ChatMessage( + Message( role="user", contents=[Content.from_uri(uri=f"data:application/json;base64,{state_b64}")], ), @@ -127,8 +125,8 @@ class TestAGUIChatClient: """Test message conversion to AG-UI format.""" client = TestableAGUIChatClient(endpoint="http://localhost:8888/") messages = [ - ChatMessage(role="user", text="What is the weather?"), - ChatMessage(role="assistant", text="Let me check.", message_id="msg_123"), + Message(role="user", text="What is the weather?"), + Message(role="assistant", text="Let me check.", message_id="msg_123"), ] agui_messages = client.convert_messages_to_agui_format(messages) @@ -175,7 +173,7 @@ class TestAGUIChatClient: client = TestableAGUIChatClient(endpoint="http://localhost:8888/") monkeypatch.setattr(client.http_service, "post_run", mock_post_run) - messages = [ChatMessage(role="user", text="Test message")] + messages = [Message(role="user", text="Test message")] chat_options = ChatOptions() updates: list[ChatResponseUpdate] = [] @@ -208,7 +206,7 @@ class TestAGUIChatClient: client = TestableAGUIChatClient(endpoint="http://localhost:8888/") monkeypatch.setattr(client.http_service, "post_run", mock_post_run) - messages = [ChatMessage(role="user", text="Test message")] + messages = [Message(role="user", text="Test message")] chat_options = {} response = await client.inner_get_response(messages=messages, options=chat_options) @@ -251,7 +249,7 @@ class TestAGUIChatClient: client = TestableAGUIChatClient(endpoint="http://localhost:8888/") monkeypatch.setattr(client.http_service, "post_run", mock_post_run) - messages = [ChatMessage(role="user", text="Test with tools")] + messages = [Message(role="user", text="Test with tools")] chat_options = ChatOptions(tools=[test_tool]) response = await client.inner_get_response(messages=messages, options=chat_options) @@ -275,7 +273,7 @@ class TestAGUIChatClient: client = TestableAGUIChatClient(endpoint="http://localhost:8888/") monkeypatch.setattr(client.http_service, "post_run", mock_post_run) - messages = [ChatMessage(role="user", text="Test server tool execution")] + messages = [Message(role="user", text="Test server tool execution")] updates: list[ChatResponseUpdate] = [] async for update in client.get_response(messages, stream=True): @@ -317,7 +315,7 @@ class TestAGUIChatClient: client = TestableAGUIChatClient(endpoint="http://localhost:8888/") monkeypatch.setattr(client.http_service, "post_run", mock_post_run) - messages = [ChatMessage(role="user", text="Test server tool execution")] + messages = [Message(role="user", text="Test server tool execution")] async for _ in client.get_response( messages, stream=True, options={"tool_choice": "auto", "tools": [client_tool]} @@ -333,8 +331,8 @@ class TestAGUIChatClient: state_b64 = base64.b64encode(state_json.encode("utf-8")).decode("utf-8") messages = [ - ChatMessage(role="user", text="Hello"), - ChatMessage( + Message(role="user", text="Hello"), + Message( role="user", contents=[Content.from_uri(uri=f"data:application/json;base64,{state_b64}")], ), diff --git a/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py b/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py index b61aa1edd3..ae978f7869 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py +++ b/python/packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py @@ -7,7 +7,7 @@ from collections.abc import AsyncIterator, MutableSequence from typing import Any import pytest -from agent_framework import ChatAgent, ChatMessage, ChatOptions, ChatResponseUpdate, Content +from agent_framework import Agent, ChatOptions, ChatResponseUpdate, Content, Message from pydantic import BaseModel @@ -16,12 +16,12 @@ async def test_agent_initialization_basic(streaming_chat_client_stub): from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")]) - agent = ChatAgent[ChatOptions]( - chat_client=streaming_chat_client_stub(stream_fn), + agent = Agent[ChatOptions]( + client=streaming_chat_client_stub(stream_fn), name="test_agent", instructions="Test", ) @@ -38,11 +38,11 @@ async def test_agent_initialization_with_state_schema(streaming_chat_client_stub from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) state_schema: dict[str, dict[str, Any]] = {"document": {"type": "string"}} wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema) @@ -54,11 +54,11 @@ async def test_agent_initialization_with_predict_state_config(streaming_chat_cli from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) predict_config = {"document": {"tool": "write_doc", "tool_argument": "content"}} wrapper = AgentFrameworkAgent(agent=agent, predict_state_config=predict_config) @@ -70,7 +70,7 @@ async def test_agent_initialization_with_pydantic_state_schema(streaming_chat_cl from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")]) @@ -78,7 +78,7 @@ async def test_agent_initialization_with_pydantic_state_schema(streaming_chat_cl document: str tags: list[str] = [] - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) wrapper_class_schema = AgentFrameworkAgent(agent=agent, state_schema=MyState) wrapper_instance_schema = AgentFrameworkAgent(agent=agent, state_schema=MyState(document="hi")) @@ -93,11 +93,11 @@ async def test_run_started_event_emission(streaming_chat_client_stub): from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) input_data = {"messages": [{"role": "user", "content": "Hi"}]} @@ -117,11 +117,11 @@ async def test_predict_state_custom_event_emission(streaming_chat_client_stub): from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) predict_config = { "document": {"tool": "write_doc", "tool_argument": "content"}, "summary": {"tool": "summarize", "tool_argument": "text"}, @@ -149,11 +149,11 @@ async def test_initial_state_snapshot_with_schema(streaming_chat_client_stub): from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) state_schema = {"document": {"type": "string"}} wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema) @@ -179,11 +179,11 @@ async def test_state_initialization_object_type(streaming_chat_client_stub): from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) state_schema: dict[str, dict[str, Any]] = {"recipe": {"type": "object", "properties": {}}} wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema) @@ -206,11 +206,11 @@ async def test_state_initialization_array_type(streaming_chat_client_stub): from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) state_schema: dict[str, dict[str, Any]] = {"steps": {"type": "array", "items": {}}} wrapper = AgentFrameworkAgent(agent=agent, state_schema=state_schema) @@ -233,11 +233,11 @@ async def test_run_finished_event_emission(streaming_chat_client_stub): from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) input_data = {"messages": [{"role": "user", "content": "Hi"}]} @@ -255,11 +255,11 @@ async def test_tool_result_confirm_changes_accepted(streaming_chat_client_stub): from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text(text="Document updated")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) wrapper = AgentFrameworkAgent( agent=agent, state_schema={"document": {"type": "string"}}, @@ -302,11 +302,11 @@ async def test_tool_result_confirm_changes_rejected(streaming_chat_client_stub): from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text(text="OK")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) # Simulate tool result message with rejection @@ -336,11 +336,11 @@ async def test_tool_result_function_approval_accepted(streaming_chat_client_stub from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text(text="OK")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) # Simulate tool result with multiple steps @@ -382,11 +382,11 @@ async def test_tool_result_function_approval_rejected(streaming_chat_client_stub from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text(text="OK")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) # Simulate tool result rejection with steps @@ -425,13 +425,13 @@ async def test_thread_metadata_tracking(streaming_chat_client_stub): captured_options: dict[str, Any] = {} async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: # Capture options to verify internal keys are NOT passed to chat client captured_options.update(options) yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) input_data = { @@ -444,13 +444,7 @@ async def test_thread_metadata_tracking(streaming_chat_client_stub): async for event in wrapper.run_agent(input_data): events.append(event) - # AG-UI internal metadata should be stored in thread.metadata - thread = agent.chat_client.last_thread - thread_metadata = thread.metadata if thread and hasattr(thread, "metadata") else {} - assert thread_metadata.get("ag_ui_thread_id") == "test_thread_123" - assert thread_metadata.get("ag_ui_run_id") == "test_run_456" - - # Internal metadata should NOT be passed to chat client options + # AG-UI internal metadata should NOT be passed to chat client options options_metadata = captured_options.get("metadata", {}) assert "ag_ui_thread_id" not in options_metadata assert "ag_ui_run_id" not in options_metadata @@ -467,13 +461,13 @@ async def test_state_context_injection(streaming_chat_client_stub): captured_options: dict[str, Any] = {} async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: # Capture options to verify internal keys are NOT passed to chat client captured_options.update(options) yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) wrapper = AgentFrameworkAgent( agent=agent, state_schema={"document": {"type": "string"}}, @@ -488,15 +482,7 @@ async def test_state_context_injection(streaming_chat_client_stub): async for event in wrapper.run_agent(input_data): events.append(event) - # Current state should be stored in thread.metadata - thread = agent.chat_client.last_thread - thread_metadata = thread.metadata if thread and hasattr(thread, "metadata") else {} - current_state = thread_metadata.get("current_state") - if isinstance(current_state, str): - current_state = json.loads(current_state) - assert current_state == {"document": "Test content"} - - # Internal metadata should NOT be passed to chat client options + # Current state should NOT be passed to chat client options options_metadata = captured_options.get("metadata", {}) assert "current_state" not in options_metadata @@ -506,11 +492,11 @@ async def test_no_messages_provided(streaming_chat_client_stub): from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) input_data: dict[str, Any] = {"messages": []} @@ -530,11 +516,11 @@ async def test_message_end_event_emission(streaming_chat_client_stub): from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text(text="Hello world")]) - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) input_data: dict[str, Any] = {"messages": [{"role": "user", "content": "Hi"}]} @@ -558,13 +544,13 @@ async def test_error_handling_with_exception(streaming_chat_client_stub): from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: if False: yield ChatResponseUpdate(contents=[]) raise RuntimeError("Simulated failure") - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) input_data: dict[str, Any] = {"messages": [{"role": "user", "content": "Hi"}]} @@ -579,13 +565,13 @@ async def test_json_decode_error_in_tool_result(streaming_chat_client_stub): from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: if False: yield ChatResponseUpdate(contents=[]) raise AssertionError("ChatClient should not be called with orphaned tool result") - agent = ChatAgent(name="test_agent", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn)) wrapper = AgentFrameworkAgent(agent=agent) # Send invalid JSON as tool result without preceding tool call @@ -611,56 +597,56 @@ async def test_json_decode_error_in_tool_result(streaming_chat_client_stub): assert len(tool_events) == 0 -async def test_agent_with_use_service_thread_is_false(streaming_chat_client_stub): - """Test that when use_service_thread is False, the AgentThread used to run the agent is NOT set to the service thread ID.""" +async def test_agent_with_use_service_session_is_false(streaming_chat_client_stub): + """Test that when use_service_session is False, the AgentSession used to run the agent is NOT set to the service session ID.""" from agent_framework.ag_ui import AgentFrameworkAgent - request_service_thread_id: str | None = None + request_service_session_id: str | None = None async def stream_fn( - messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + messages: MutableSequence[Message], chat_options: ChatOptions, **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate( contents=[Content.from_text(text="Response")], response_id="resp_67890", conversation_id="conv_12345" ) - agent = ChatAgent(chat_client=streaming_chat_client_stub(stream_fn)) - wrapper = AgentFrameworkAgent(agent=agent, use_service_thread=False) + agent = Agent(client=streaming_chat_client_stub(stream_fn)) + wrapper = AgentFrameworkAgent(agent=agent, use_service_session=False) input_data = {"messages": [{"role": "user", "content": "Hi"}], "thread_id": "conv_123456"} events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) - assert request_service_thread_id is None # type: ignore[attr-defined] (service_thread_id should be set) + assert request_service_session_id is None # type: ignore[attr-defined] (service_session_id should be set) -async def test_agent_with_use_service_thread_is_true(streaming_chat_client_stub): - """Test that when use_service_thread is True, the AgentThread used to run the agent is set to the service thread ID.""" +async def test_agent_with_use_service_session_is_true(streaming_chat_client_stub): + """Test that when use_service_session is True, the AgentSession used to run the agent is set to the service session ID.""" from agent_framework.ag_ui import AgentFrameworkAgent - request_service_thread_id: str | None = None + request_service_session_id: str | None = None async def stream_fn( - messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any + messages: MutableSequence[Message], chat_options: ChatOptions, **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: - nonlocal request_service_thread_id - thread = kwargs.get("thread") - request_service_thread_id = thread.service_thread_id if thread else None + nonlocal request_service_session_id + session = kwargs.get("session") + request_service_session_id = session.service_session_id if session else None yield ChatResponseUpdate( contents=[Content.from_text(text="Response")], response_id="resp_67890", conversation_id="conv_12345" ) - agent = ChatAgent(chat_client=streaming_chat_client_stub(stream_fn)) - wrapper = AgentFrameworkAgent(agent=agent, use_service_thread=True) + agent = Agent(client=streaming_chat_client_stub(stream_fn)) + wrapper = AgentFrameworkAgent(agent=agent, use_service_session=True) input_data = {"messages": [{"role": "user", "content": "Hi"}], "thread_id": "conv_123456"} events: list[Any] = [] async for event in wrapper.run_agent(input_data): events.append(event) - request_service_thread_id = agent.chat_client.last_service_thread_id - assert request_service_thread_id == "conv_123456" # type: ignore[attr-defined] (service_thread_id should be set) + request_service_session_id = agent.client.last_service_session_id + assert request_service_session_id == "conv_123456" # type: ignore[attr-defined] (service_session_id should be set) async def test_function_approval_mode_executes_tool(streaming_chat_client_stub): @@ -679,15 +665,15 @@ async def test_function_approval_mode_executes_tool(streaming_chat_client_stub): return "2025/12/01 12:00:00" async def stream_fn( - messages: MutableSequence[ChatMessage], options: ChatOptions, **kwargs: Any + messages: MutableSequence[Message], options: ChatOptions, **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: # Capture the messages received by the chat client messages_received.clear() messages_received.extend(messages) yield ChatResponseUpdate(contents=[Content.from_text(text="Processing completed")]) - agent = ChatAgent( - chat_client=streaming_chat_client_stub(stream_fn), + agent = Agent( + client=streaming_chat_client_stub(stream_fn), name="test_agent", instructions="Test", tools=[get_datetime], @@ -770,17 +756,17 @@ async def test_function_approval_mode_rejection(streaming_chat_client_stub): return "All data deleted" async def stream_fn( - messages: MutableSequence[ChatMessage], options: ChatOptions, **kwargs: Any + messages: MutableSequence[Message], options: ChatOptions, **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: # Capture the messages received by the chat client messages_received.clear() messages_received.extend(messages) yield ChatResponseUpdate(contents=[Content.from_text(text="Operation cancelled")]) - agent = ChatAgent( + agent = Agent( name="test_agent", instructions="Test", - chat_client=streaming_chat_client_stub(stream_fn), + client=streaming_chat_client_stub(stream_fn), tools=[delete_all_data], ) wrapper = AgentFrameworkAgent(agent=agent) diff --git a/python/packages/ag-ui/tests/ag_ui/test_endpoint.py b/python/packages/ag-ui/tests/ag_ui/test_endpoint.py index 4c1f03a49d..dfdab0f07c 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_endpoint.py +++ b/python/packages/ag-ui/tests/ag_ui/test_endpoint.py @@ -5,7 +5,8 @@ import json import pytest -from agent_framework import ChatAgent, ChatResponseUpdate, Content +from agent_framework import Agent, ChatResponseUpdate, Content +from agent_framework.orchestrations import SequentialBuilder from fastapi import FastAPI, Header, HTTPException from fastapi.params import Depends from fastapi.testclient import TestClient @@ -28,7 +29,7 @@ def build_chat_client(streaming_chat_client_stub, stream_from_updates_fixture): async def test_add_endpoint_with_agent_protocol(build_chat_client): """Test adding endpoint with raw SupportsAgentRun.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent, path="/test-agent") @@ -42,7 +43,7 @@ async def test_add_endpoint_with_agent_protocol(build_chat_client): async def test_add_endpoint_with_wrapped_agent(build_chat_client): """Test adding endpoint with pre-wrapped AgentFrameworkAgent.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) wrapped_agent = AgentFrameworkAgent(agent=agent, name="wrapped") add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/wrapped-agent") @@ -57,7 +58,7 @@ async def test_add_endpoint_with_wrapped_agent(build_chat_client): async def test_endpoint_with_state_schema(build_chat_client): """Test endpoint with state_schema parameter.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) state_schema = {"document": {"type": "string"}} add_agent_framework_fastapi_endpoint(app, agent, path="/stateful", state_schema=state_schema) @@ -73,7 +74,7 @@ async def test_endpoint_with_state_schema(build_chat_client): async def test_endpoint_with_default_state_seed(build_chat_client): """Test endpoint seeds default state when client omits it.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) state_schema = {"proverbs": {"type": "array"}} default_state = {"proverbs": ["Keep the original."]} @@ -100,7 +101,7 @@ async def test_endpoint_with_default_state_seed(build_chat_client): async def test_endpoint_with_predict_state_config(build_chat_client): """Test endpoint with predict_state_config parameter.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) predict_config = {"document": {"tool": "write_doc", "tool_argument": "content"}} add_agent_framework_fastapi_endpoint(app, agent, path="/predictive", predict_state_config=predict_config) @@ -114,7 +115,7 @@ async def test_endpoint_with_predict_state_config(build_chat_client): async def test_endpoint_request_logging(build_chat_client): """Test that endpoint logs request details.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent, path="/logged") @@ -134,7 +135,7 @@ async def test_endpoint_request_logging(build_chat_client): async def test_endpoint_event_streaming(build_chat_client): """Test that endpoint streams events correctly.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client("Streamed response")) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client("Streamed response")) add_agent_framework_fastapi_endpoint(app, agent, path="/stream") @@ -165,10 +166,32 @@ async def test_endpoint_event_streaming(build_chat_client): assert found_run_finished +async def test_endpoint_with_workflow_as_agent_stream_output(build_chat_client): + """Test endpoint handles workflow-as-agent stream outputs.""" + app = FastAPI() + brainstorm_agent = Agent(name="brainstorm", instructions="Brainstorm ideas", client=build_chat_client("Idea")) + reviewer_agent = Agent(name="reviewer", instructions="Review ideas", client=build_chat_client("Review")) + agent = SequentialBuilder(participants=[brainstorm_agent, reviewer_agent]).build().as_agent() + + add_agent_framework_fastapi_endpoint(app, agent, path="/workflow-like") + + client = TestClient(app) + response = client.post("/workflow-like", json={"messages": [{"role": "user", "content": "Hello"}]}) + + assert response.status_code == 200 + content = response.content.decode("utf-8") + lines = [line for line in content.split("\n") if line.startswith("data: ")] + event_types = [json.loads(line[6:]).get("type") for line in lines] + + assert "RUN_STARTED" in event_types + assert "TEXT_MESSAGE_CONTENT" in event_types + assert "RUN_FINISHED" in event_types + + async def test_endpoint_error_handling(build_chat_client): """Test endpoint error handling during request parsing.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent, path="/failing") @@ -184,8 +207,8 @@ async def test_endpoint_error_handling(build_chat_client): async def test_endpoint_multiple_paths(build_chat_client): """Test adding multiple endpoints with different paths.""" app = FastAPI() - agent1 = ChatAgent(name="agent1", instructions="First agent", chat_client=build_chat_client("Response 1")) - agent2 = ChatAgent(name="agent2", instructions="Second agent", chat_client=build_chat_client("Response 2")) + agent1 = Agent(name="agent1", instructions="First agent", client=build_chat_client("Response 1")) + agent2 = Agent(name="agent2", instructions="Second agent", client=build_chat_client("Response 2")) add_agent_framework_fastapi_endpoint(app, agent1, path="/agent1") add_agent_framework_fastapi_endpoint(app, agent2, path="/agent2") @@ -202,7 +225,7 @@ async def test_endpoint_multiple_paths(build_chat_client): async def test_endpoint_default_path(build_chat_client): """Test endpoint with default path.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent) @@ -215,7 +238,7 @@ async def test_endpoint_default_path(build_chat_client): async def test_endpoint_response_headers(build_chat_client): """Test that endpoint sets correct response headers.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent, path="/headers") @@ -231,7 +254,7 @@ async def test_endpoint_response_headers(build_chat_client): async def test_endpoint_empty_messages(build_chat_client): """Test endpoint with empty messages list.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent, path="/empty") @@ -244,7 +267,7 @@ async def test_endpoint_empty_messages(build_chat_client): async def test_endpoint_complex_input(build_chat_client): """Test endpoint with complex input data.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent, path="/complex") @@ -269,7 +292,7 @@ async def test_endpoint_complex_input(build_chat_client): async def test_endpoint_openapi_schema(build_chat_client): """Test that endpoint generates proper OpenAPI schema with request model.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent, path="/schema-test") @@ -313,7 +336,7 @@ async def test_endpoint_openapi_schema(build_chat_client): async def test_endpoint_default_tags(build_chat_client): """Test that endpoint uses default 'AG-UI' tag.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent, path="/default-tags") @@ -331,7 +354,7 @@ async def test_endpoint_default_tags(build_chat_client): async def test_endpoint_custom_tags(build_chat_client): """Test that endpoint accepts custom tags.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent, path="/custom-tags", tags=["Custom", "Agent"]) @@ -349,7 +372,7 @@ async def test_endpoint_custom_tags(build_chat_client): async def test_endpoint_missing_required_field(build_chat_client): """Test that endpoint validates required fields with Pydantic.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) add_agent_framework_fastapi_endpoint(app, agent, path="/validation") @@ -368,7 +391,7 @@ async def test_endpoint_internal_error_handling(build_chat_client): from unittest.mock import patch app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) # Use default_state to trigger the code path that can raise an exception add_agent_framework_fastapi_endpoint(app, agent, path="/error-test", default_state={"key": "value"}) @@ -387,7 +410,7 @@ async def test_endpoint_internal_error_handling(build_chat_client): async def test_endpoint_with_dependencies_blocks_unauthorized(build_chat_client): """Test that endpoint blocks requests when authentication dependency fails.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) async def require_api_key(x_api_key: str | None = Header(None)): if x_api_key != "secret-key": @@ -406,7 +429,7 @@ async def test_endpoint_with_dependencies_blocks_unauthorized(build_chat_client) async def test_endpoint_with_dependencies_allows_authorized(build_chat_client): """Test that endpoint allows requests when authentication dependency passes.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) async def require_api_key(x_api_key: str | None = Header(None)): if x_api_key != "secret-key": @@ -429,7 +452,7 @@ async def test_endpoint_with_dependencies_allows_authorized(build_chat_client): async def test_endpoint_with_multiple_dependencies(build_chat_client): """Test that endpoint supports multiple dependencies.""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) execution_order: list[str] = [] @@ -457,7 +480,7 @@ async def test_endpoint_with_multiple_dependencies(build_chat_client): async def test_endpoint_without_dependencies_is_accessible(build_chat_client): """Test that endpoint without dependencies remains accessible (backward compatibility).""" app = FastAPI() - agent = ChatAgent(name="test", instructions="Test agent", chat_client=build_chat_client()) + agent = Agent(name="test", instructions="Test agent", client=build_chat_client()) # No dependencies parameter - should be accessible without auth add_agent_framework_fastapi_endpoint(app, agent, path="/open") diff --git a/python/packages/ag-ui/tests/ag_ui/test_helpers.py b/python/packages/ag-ui/tests/ag_ui/test_helpers.py index b4a7e9f047..7173f5c6b3 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_helpers.py +++ b/python/packages/ag-ui/tests/ag_ui/test_helpers.py @@ -2,7 +2,7 @@ """Tests for orchestration helper functions.""" -from agent_framework import ChatMessage, Content +from agent_framework import Content, Message from agent_framework_ag_ui._orchestration._helpers import ( approval_steps, @@ -29,8 +29,8 @@ class TestPendingToolCallIds: def test_no_tool_calls(self): """Returns empty set when no tool calls in messages.""" messages = [ - ChatMessage(role="user", contents=[Content.from_text("Hello")]), - ChatMessage(role="assistant", contents=[Content.from_text("Hi there")]), + Message(role="user", contents=[Content.from_text("Hello")]), + Message(role="assistant", contents=[Content.from_text("Hi there")]), ] result = pending_tool_call_ids(messages) assert result == set() @@ -38,7 +38,7 @@ class TestPendingToolCallIds: def test_pending_tool_call(self): """Returns pending tool call ID when no result exists.""" messages = [ - ChatMessage( + Message( role="assistant", contents=[Content.from_function_call(call_id="call_123", name="get_weather", arguments="{}")], ), @@ -49,11 +49,11 @@ class TestPendingToolCallIds: def test_resolved_tool_call(self): """Returns empty set when tool call has result.""" messages = [ - ChatMessage( + Message( role="assistant", contents=[Content.from_function_call(call_id="call_123", name="get_weather", arguments="{}")], ), - ChatMessage( + Message( role="tool", contents=[Content.from_function_result(call_id="call_123", result="sunny")], ), @@ -64,7 +64,7 @@ class TestPendingToolCallIds: def test_multiple_tool_calls_some_resolved(self): """Returns only unresolved tool call IDs.""" messages = [ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call(call_id="call_1", name="tool_a", arguments="{}"), @@ -72,11 +72,11 @@ class TestPendingToolCallIds: Content.from_function_call(call_id="call_3", name="tool_c", arguments="{}"), ], ), - ChatMessage( + Message( role="tool", contents=[Content.from_function_result(call_id="call_1", result="result_a")], ), - ChatMessage( + Message( role="tool", contents=[Content.from_function_result(call_id="call_3", result="result_c")], ), @@ -90,7 +90,7 @@ class TestIsStateContextMessage: def test_state_context_message(self): """Returns True for state context message.""" - message = ChatMessage( + message = Message( role="system", contents=[Content.from_text("Current state of the application: {}")], ) @@ -98,7 +98,7 @@ class TestIsStateContextMessage: def test_non_system_message(self): """Returns False for non-system message.""" - message = ChatMessage( + message = Message( role="user", contents=[Content.from_text("Current state of the application: {}")], ) @@ -106,7 +106,7 @@ class TestIsStateContextMessage: def test_system_message_without_state_prefix(self): """Returns False for system message without state prefix.""" - message = ChatMessage( + message = Message( role="system", contents=[Content.from_text("You are a helpful assistant.")], ) @@ -114,7 +114,7 @@ class TestIsStateContextMessage: def test_empty_contents(self): """Returns False for message with empty contents.""" - message = ChatMessage(role="system", contents=[]) + message = Message(role="system", contents=[]) assert is_state_context_message(message) is False @@ -342,7 +342,7 @@ class TestLatestApprovalResponse: def test_no_approval_response(self): """Returns None when no approval response in last message.""" messages = [ - ChatMessage(role="assistant", contents=[Content.from_text("Hello")]), + Message(role="assistant", contents=[Content.from_text("Hello")]), ] result = latest_approval_response(messages) assert result is None @@ -357,7 +357,7 @@ class TestLatestApprovalResponse: function_call=fc, ) messages = [ - ChatMessage(role="user", contents=[approval_content]), + Message(role="user", contents=[approval_content]), ] result = latest_approval_response(messages) assert result is approval_content diff --git a/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py index 47970d7005..4a715bed15 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py +++ b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py @@ -5,7 +5,7 @@ import json import pytest -from agent_framework import ChatMessage, Content +from agent_framework import Content, Message from agent_framework_ag_ui._message_adapters import ( agent_framework_messages_to_agui, @@ -24,7 +24,7 @@ def sample_agui_message(): @pytest.fixture def sample_agent_framework_message(): """Create a sample Agent Framework message.""" - return ChatMessage(role="user", contents=[Content.from_text(text="Hello")], message_id="msg-123") + return Message(role="user", contents=[Content.from_text(text="Hello")], message_id="msg-123") def test_agui_to_agent_framework_basic(sample_agui_message): @@ -100,7 +100,7 @@ def test_agui_tool_result_to_agent_framework(): def test_agui_tool_approval_updates_tool_call_arguments(): """Tool approval updates matching tool call arguments for snapshots and agent context. - The LLM context (ChatMessage) should contain only enabled steps, so the LLM + The LLM context (Message) should contain only enabled steps, so the LLM generates responses based on what was actually approved/executed. The raw messages (for MESSAGES_SNAPSHOT) should contain all steps with status, @@ -446,7 +446,7 @@ def test_agui_with_tool_calls_to_agent_framework(): def test_agent_framework_to_agui_with_tool_calls(): """Test converting Agent Framework message with tool calls to AG-UI.""" - msg = ChatMessage( + msg = Message( role="assistant", contents=[ Content.from_text(text="Calling tool"), @@ -471,7 +471,7 @@ def test_agent_framework_to_agui_with_tool_calls(): def test_agent_framework_to_agui_multiple_text_contents(): """Test concatenating multiple text contents.""" - msg = ChatMessage( + msg = Message( role="assistant", contents=[Content.from_text(text="Part 1 "), Content.from_text(text="Part 2")], ) @@ -484,7 +484,7 @@ def test_agent_framework_to_agui_multiple_text_contents(): def test_agent_framework_to_agui_no_message_id(): """Test message without message_id - should auto-generate ID.""" - msg = ChatMessage(role="user", contents=[Content.from_text(text="Hello")]) + msg = Message(role="user", contents=[Content.from_text(text="Hello")]) messages = agent_framework_messages_to_agui([msg]) @@ -496,7 +496,7 @@ def test_agent_framework_to_agui_no_message_id(): def test_agent_framework_to_agui_system_role(): """Test system role conversion.""" - msg = ChatMessage(role="system", contents=[Content.from_text(text="System")]) + msg = Message(role="system", contents=[Content.from_text(text="System")]) messages = agent_framework_messages_to_agui([msg]) @@ -541,9 +541,9 @@ def test_extract_text_from_custom_contents(): def test_agent_framework_to_agui_function_result_dict(): """Test converting FunctionResultContent with dict result to AG-UI.""" - msg = ChatMessage( + msg = Message( role="tool", - contents=[Content.from_function_result(call_id="call-123", result={"key": "value", "count": 42})], + contents=[Content.from_function_result(call_id="call-123", result='{"key": "value", "count": 42}')], message_id="msg-789", ) @@ -558,7 +558,7 @@ def test_agent_framework_to_agui_function_result_dict(): def test_agent_framework_to_agui_function_result_none(): """Test converting FunctionResultContent with None result to AG-UI.""" - msg = ChatMessage( + msg = Message( role="tool", contents=[Content.from_function_result(call_id="call-123", result=None)], message_id="msg-789", @@ -568,13 +568,13 @@ def test_agent_framework_to_agui_function_result_none(): assert len(messages) == 1 agui_msg = messages[0] - # None serializes as JSON null - assert agui_msg["content"] == "null" + # None result maps to empty string (FunctionTool.invoke returns "" for None) + assert agui_msg["content"] == "" def test_agent_framework_to_agui_function_result_string(): """Test converting FunctionResultContent with string result to AG-UI.""" - msg = ChatMessage( + msg = Message( role="tool", contents=[Content.from_function_result(call_id="call-123", result="plain text result")], message_id="msg-789", @@ -589,9 +589,9 @@ def test_agent_framework_to_agui_function_result_string(): def test_agent_framework_to_agui_function_result_empty_list(): """Test converting FunctionResultContent with empty list result to AG-UI.""" - msg = ChatMessage( + msg = Message( role="tool", - contents=[Content.from_function_result(call_id="call-123", result=[])], + contents=[Content.from_function_result(call_id="call-123", result="[]")], message_id="msg-789", ) @@ -604,16 +604,10 @@ def test_agent_framework_to_agui_function_result_empty_list(): def test_agent_framework_to_agui_function_result_single_text_content(): - """Test converting FunctionResultContent with single TextContent-like item.""" - from dataclasses import dataclass - - @dataclass - class MockTextContent: - text: str - - msg = ChatMessage( + """Test converting FunctionResultContent with single TextContent-like item (pre-parsed).""" + msg = Message( role="tool", - contents=[Content.from_function_result(call_id="call-123", result=[MockTextContent("Hello from MCP!")])], + contents=[Content.from_function_result(call_id="call-123", result='["Hello from MCP!"]')], message_id="msg-789", ) @@ -626,19 +620,13 @@ def test_agent_framework_to_agui_function_result_single_text_content(): def test_agent_framework_to_agui_function_result_multiple_text_contents(): - """Test converting FunctionResultContent with multiple TextContent-like items.""" - from dataclasses import dataclass - - @dataclass - class MockTextContent: - text: str - - msg = ChatMessage( + """Test converting FunctionResultContent with multiple TextContent-like items (pre-parsed).""" + msg = Message( role="tool", contents=[ Content.from_function_result( call_id="call-123", - result=[MockTextContent("First result"), MockTextContent("Second result")], + result='["First result", "Second result"]', ) ], message_id="msg-789", diff --git a/python/packages/ag-ui/tests/ag_ui/test_message_hygiene.py b/python/packages/ag-ui/tests/ag_ui/test_message_hygiene.py index d1773bf10c..ed8526e592 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_message_hygiene.py +++ b/python/packages/ag-ui/tests/ag_ui/test_message_hygiene.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. -from agent_framework import ChatMessage, Content +from agent_framework import Content, Message from agent_framework_ag_ui._message_adapters import _deduplicate_messages, _sanitize_tool_history @@ -13,7 +13,7 @@ def test_sanitize_tool_history_filters_out_confirm_changes_only_message() -> Non tool for the approval UI flow that shouldn't be sent to the LLM. """ messages = [ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call( @@ -23,7 +23,7 @@ def test_sanitize_tool_history_filters_out_confirm_changes_only_message() -> Non ) ], ), - ChatMessage( + Message( role="user", contents=[Content.from_text(text='{"accepted": true}')], ), @@ -44,11 +44,11 @@ def test_sanitize_tool_history_filters_out_confirm_changes_only_message() -> Non def test_deduplicate_messages_prefers_non_empty_tool_results() -> None: messages = [ - ChatMessage( + Message( role="tool", contents=[Content.from_function_result(call_id="call1", result="")], ), - ChatMessage( + Message( role="tool", contents=[Content.from_function_result(call_id="call1", result="result data")], ), @@ -71,13 +71,13 @@ def test_convert_approval_results_to_tool_messages() -> None: # Simulate what happens after _resolve_approval_responses: # A user message contains function_result content (the executed tool result) messages = [ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call(call_id="call_123", name="my_mcp_tool", arguments="{}"), ], ), - ChatMessage( + Message( role="user", contents=[ Content.from_function_result(call_id="call_123", result="tool execution result"), @@ -109,13 +109,13 @@ def test_convert_approval_results_preserves_other_user_content() -> None: from agent_framework_ag_ui._run import _convert_approval_results_to_tool_messages messages = [ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call(call_id="call_123", name="my_tool", arguments="{}"), ], ), - ChatMessage( + Message( role="user", contents=[ Content.from_text(text="User also said something"), @@ -152,12 +152,12 @@ def test_sanitize_tool_history_filters_confirm_changes_keeps_other_tools() -> No """ messages = [ # User asks something - ChatMessage( + Message( role="user", contents=[Content.from_text(text="What time is it?")], ), # Assistant calls MCP tool + confirm_changes - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call(call_id="call_1", name="get_datetime", arguments="{}"), @@ -165,12 +165,12 @@ def test_sanitize_tool_history_filters_confirm_changes_keeps_other_tools() -> No ], ), # Tool result for the actual MCP tool - ChatMessage( + Message( role="tool", contents=[Content.from_function_result(call_id="call_1", result="2024-01-01 12:00:00")], ), # User asks something else - ChatMessage( + Message( role="user", contents=[Content.from_text(text="What's the date?")], ), @@ -204,12 +204,12 @@ def test_sanitize_tool_history_filters_confirm_changes_from_assistant_messages() respond with "Here's your 5-step plan" instead of "Here's your 2-step plan". """ messages = [ - ChatMessage( + Message( role="user", contents=[Content.from_text(text="Build a robot")], ), # Assistant message with both generate_task_steps and confirm_changes - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call( @@ -225,7 +225,7 @@ def test_sanitize_tool_history_filters_confirm_changes_from_assistant_messages() ], ), # Approval response - ChatMessage( + Message( role="user", contents=[ Content.from_function_approval_response( diff --git a/python/packages/ag-ui/tests/ag_ui/test_run.py b/python/packages/ag-ui/tests/ag_ui/test_run.py index 6428180fc0..8cee6e4338 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run.py @@ -2,11 +2,13 @@ """Tests for _run.py helper functions and FlowState.""" +import pytest from ag_ui.core import ( TextMessageEndEvent, TextMessageStartEvent, ) -from agent_framework import ChatMessage, Content +from agent_framework import AgentResponseUpdate, Content, Message, ResponseStream +from agent_framework.exceptions import AgentExecutionException from agent_framework_ag_ui._run import ( FlowState, @@ -16,6 +18,7 @@ from agent_framework_ag_ui._run import ( _emit_tool_result, _has_only_tool_calls, _inject_state_context, + _normalize_response_stream, _should_suppress_intermediate_snapshot, ) @@ -179,6 +182,54 @@ class TestFlowState: assert result[0]["id"] == "call_2" +class TestNormalizeResponseStream: + """Tests for _normalize_response_stream helper.""" + + async def test_accepts_response_stream(self): + """Accept standard ResponseStream values.""" + + async def _stream(): + yield AgentResponseUpdate(contents=[Content.from_text("hello")], role="assistant") + + stream = await _normalize_response_stream(ResponseStream(_stream())) + updates = [update async for update in stream] + + assert len(updates) == 1 + assert updates[0].contents[0].text == "hello" + + async def test_accepts_async_iterable(self): + """Accept workflow-style async generator streams.""" + + async def _stream(): + yield AgentResponseUpdate(contents=[Content.from_text("hello")], role="assistant") + + stream = await _normalize_response_stream(_stream()) + updates = [update async for update in stream] + + assert len(updates) == 1 + assert updates[0].contents[0].text == "hello" + + async def test_accepts_awaitable_resolving_to_async_iterable(self): + """Accept awaitables that resolve to async iterable streams.""" + + async def _stream(): + yield AgentResponseUpdate(contents=[Content.from_text("hello")], role="assistant") + + async def _resolve(): + return _stream() + + stream = await _normalize_response_stream(_resolve()) + updates = [update async for update in stream] + + assert len(updates) == 1 + assert updates[0].contents[0].text == "hello" + + async def test_rejects_non_stream_values(self): + """Reject unsupported stream return values.""" + with pytest.raises(AgentExecutionException): + await _normalize_response_stream("not-a-stream") + + class TestCreateStateContextMessage: """Tests for _create_state_context_message function.""" @@ -212,7 +263,7 @@ class TestInjectStateContext: def test_no_state_message(self): """Returns original messages when no state context needed.""" - messages = [ChatMessage(role="user", contents=[Content.from_text("Hello")])] + messages = [Message(role="user", contents=[Content.from_text("Hello")])] result = _inject_state_context(messages, {}, {}) assert result == messages @@ -224,8 +275,8 @@ class TestInjectStateContext: def test_last_message_not_user(self): """Returns original messages when last message is not from user.""" messages = [ - ChatMessage(role="user", contents=[Content.from_text("Hello")]), - ChatMessage(role="assistant", contents=[Content.from_text("Hi")]), + Message(role="user", contents=[Content.from_text("Hello")]), + Message(role="assistant", contents=[Content.from_text("Hi")]), ] state = {"key": "value"} schema = {"properties": {"key": {"type": "string"}}} @@ -237,8 +288,8 @@ class TestInjectStateContext: """Injects state context before last user message.""" messages = [ - ChatMessage(role="system", contents=[Content.from_text("You are helpful")]), - ChatMessage(role="user", contents=[Content.from_text("Hello")]), + Message(role="system", contents=[Content.from_text("You are helpful")]), + Message(role="user", contents=[Content.from_text("Hello")]), ] state = {"document": "content"} schema = {"properties": {"document": {"type": "string"}}} @@ -405,7 +456,7 @@ def test_extract_approved_state_updates_no_handler(): """Test _extract_approved_state_updates returns empty with no handler.""" from agent_framework_ag_ui._run import _extract_approved_state_updates - messages = [ChatMessage(role="user", contents=[Content.from_text("Hello")])] + messages = [Message(role="user", contents=[Content.from_text("Hello")])] result = _extract_approved_state_updates(messages, None) assert result == {} @@ -416,7 +467,7 @@ def test_extract_approved_state_updates_no_approval(): from agent_framework_ag_ui._run import _extract_approved_state_updates handler = PredictiveStateHandler(predict_state_config={"doc": {"tool": "write", "tool_argument": "content"}}) - messages = [ChatMessage(role="user", contents=[Content.from_text("Hello")])] + messages = [Message(role="user", contents=[Content.from_text("Hello")])] result = _extract_approved_state_updates(messages, handler) assert result == {} diff --git a/python/packages/ag-ui/tests/ag_ui/test_structured_output.py b/python/packages/ag-ui/tests/ag_ui/test_structured_output.py index d1afdc971c..a8d9404a42 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_structured_output.py +++ b/python/packages/ag-ui/tests/ag_ui/test_structured_output.py @@ -6,7 +6,7 @@ import json from collections.abc import AsyncIterator, MutableSequence from typing import Any -from agent_framework import ChatAgent, ChatMessage, ChatOptions, ChatResponseUpdate, Content +from agent_framework import Agent, ChatOptions, ChatResponseUpdate, Content, Message from pydantic import BaseModel @@ -35,13 +35,13 @@ async def test_structured_output_with_recipe(streaming_chat_client_stub, stream_ from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: ChatOptions, **kwargs: Any + messages: MutableSequence[Message], options: ChatOptions, **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate( contents=[Content.from_text(text='{"recipe": {"name": "Pasta"}, "message": "Here is your recipe"}')] ) - agent = ChatAgent(name="test", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test", instructions="Test", client=streaming_chat_client_stub(stream_fn)) agent.default_options = ChatOptions(response_format=RecipeOutput) wrapper = AgentFrameworkAgent( @@ -73,7 +73,7 @@ async def test_structured_output_with_steps(streaming_chat_client_stub, stream_f from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: ChatOptions, **kwargs: Any + messages: MutableSequence[Message], options: ChatOptions, **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: steps_data = { "steps": [ @@ -83,7 +83,7 @@ async def test_structured_output_with_steps(streaming_chat_client_stub, stream_f } yield ChatResponseUpdate(contents=[Content.from_text(text=json.dumps(steps_data))]) - agent = ChatAgent(name="test", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test", instructions="Test", client=streaming_chat_client_stub(stream_fn)) agent.default_options = ChatOptions(response_format=StepsOutput) wrapper = AgentFrameworkAgent( @@ -116,8 +116,8 @@ async def test_structured_output_with_no_schema_match(streaming_chat_client_stub ChatResponseUpdate(contents=[Content.from_text(text='{"data": {"key": "value"}}')]), ] - agent = ChatAgent( - name="test", instructions="Test", chat_client=streaming_chat_client_stub(stream_from_updates_fixture(updates)) + agent = Agent( + name="test", instructions="Test", client=streaming_chat_client_stub(stream_from_updates_fixture(updates)) ) agent.default_options = ChatOptions(response_format=GenericOutput) @@ -149,11 +149,11 @@ async def test_structured_output_without_schema(streaming_chat_client_stub, stre info: str async def stream_fn( - messages: MutableSequence[ChatMessage], options: ChatOptions, **kwargs: Any + messages: MutableSequence[Message], options: ChatOptions, **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text(text='{"data": {"key": "value"}, "info": "processed"}')]) - agent = ChatAgent(name="test", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test", instructions="Test", client=streaming_chat_client_stub(stream_fn)) agent.default_options = ChatOptions(response_format=DataOutput) wrapper = AgentFrameworkAgent( @@ -182,10 +182,10 @@ async def test_no_structured_output_when_no_response_format(streaming_chat_clien updates = [ChatResponseUpdate(contents=[Content.from_text(text="Regular text")])] - agent = ChatAgent( + agent = Agent( name="test", instructions="Test", - chat_client=streaming_chat_client_stub(stream_from_updates_fixture(updates)), + client=streaming_chat_client_stub(stream_from_updates_fixture(updates)), ) # No response_format set @@ -208,12 +208,12 @@ async def test_structured_output_with_message_field(streaming_chat_client_stub, from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: ChatOptions, **kwargs: Any + messages: MutableSequence[Message], options: ChatOptions, **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: output_data = {"recipe": {"name": "Salad"}, "message": "Fresh salad recipe ready"} yield ChatResponseUpdate(contents=[Content.from_text(text=json.dumps(output_data))]) - agent = ChatAgent(name="test", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test", instructions="Test", client=streaming_chat_client_stub(stream_fn)) agent.default_options = ChatOptions(response_format=RecipeOutput) wrapper = AgentFrameworkAgent( @@ -243,12 +243,12 @@ async def test_empty_updates_no_structured_processing(streaming_chat_client_stub from agent_framework.ag_ui import AgentFrameworkAgent async def stream_fn( - messages: MutableSequence[ChatMessage], options: ChatOptions, **kwargs: Any + messages: MutableSequence[Message], options: ChatOptions, **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: if False: yield ChatResponseUpdate(contents=[]) - agent = ChatAgent(name="test", instructions="Test", chat_client=streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test", instructions="Test", client=streaming_chat_client_stub(stream_fn)) agent.default_options = ChatOptions(response_format=RecipeOutput) wrapper = AgentFrameworkAgent(agent=agent) diff --git a/python/packages/ag-ui/tests/ag_ui/test_tooling.py b/python/packages/ag-ui/tests/ag_ui/test_tooling.py index 242f5fd668..e8567a586d 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_tooling.py +++ b/python/packages/ag-ui/tests/ag_ui/test_tooling.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from agent_framework import ChatAgent, tool +from agent_framework import Agent, tool from agent_framework_ag_ui._orchestration._tooling import ( collect_server_tools, @@ -31,14 +31,14 @@ def regular_tool() -> str: return "result" -def _create_chat_agent_with_tool(tool_name: str = "regular_tool") -> ChatAgent: - """Create a ChatAgent with a mocked chat client and a simple tool. +def _create_chat_agent_with_tool(tool_name: str = "regular_tool") -> Agent: + """Create a Agent with a mocked chat client and a simple tool. Note: tool_name parameter is kept for API compatibility but the tool will always be named 'regular_tool' since tool uses the function name. """ mock_chat_client = MagicMock() - return ChatAgent(chat_client=mock_chat_client, tools=[regular_tool]) + return Agent(client=mock_chat_client, tools=[regular_tool]) def test_merge_tools_filters_duplicates() -> None: @@ -59,7 +59,7 @@ def test_register_additional_client_tools_assigns_when_configured() -> None: mock_chat_client = MagicMock(spec=BaseChatClient) mock_chat_client.function_invocation_configuration = normalize_function_invocation_configuration(None) - agent = ChatAgent(chat_client=mock_chat_client) + agent = Agent(client=mock_chat_client) tools = [DummyTool("x")] register_additional_client_tools(agent, tools) @@ -148,14 +148,14 @@ def test_collect_server_tools_no_default_options() -> None: def test_register_additional_client_tools_no_tools() -> None: """register_additional_client_tools does nothing with None tools.""" mock_chat_client = MagicMock() - agent = ChatAgent(chat_client=mock_chat_client) + agent = Agent(client=mock_chat_client) # Should not raise register_additional_client_tools(agent, None) def test_register_additional_client_tools_no_chat_client() -> None: - """register_additional_client_tools does nothing when agent has no chat_client.""" + """register_additional_client_tools does nothing when agent has no client.""" from agent_framework_ag_ui._orchestration._tooling import register_additional_client_tools class MockAgent: diff --git a/python/packages/ag-ui/tests/ag_ui/test_utils.py b/python/packages/ag-ui/tests/ag_ui/test_utils.py index 4b680d4b71..0f453132f7 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_utils.py +++ b/python/packages/ag-ui/tests/ag_ui/test_utils.py @@ -404,11 +404,11 @@ def test_safe_json_parse_with_none(): def test_get_role_value_with_enum(): """Test get_role_value with enum role.""" - from agent_framework import ChatMessage, Content + from agent_framework import Content, Message from agent_framework_ag_ui._utils import get_role_value - message = ChatMessage(role="user", contents=[Content.from_text("test")]) + message = Message(role="user", contents=[Content.from_text("test")]) result = get_role_value(message) assert result == "user" diff --git a/python/packages/anthropic/README.md b/python/packages/anthropic/README.md index f8c8af674f..2507837d2c 100644 --- a/python/packages/anthropic/README.md +++ b/python/packages/anthropic/README.md @@ -12,7 +12,7 @@ The Anthropic integration enables communication with the Anthropic API, allowing ### Basic Usage Example -See the [Anthropic agent examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/anthropic/) which demonstrate: +See the [Anthropic agent examples](../../samples/02-agents/providers/anthropic/) which demonstrate: - Connecting to a Anthropic endpoint with an agent - Streaming and non-streaming responses diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 641eb52444..d3ea19dfa0 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -11,7 +11,6 @@ from agent_framework import ( Annotation, BaseChatClient, ChatAndFunctionMiddlewareTypes, - ChatMessage, ChatMiddlewareLayer, ChatOptions, ChatResponse, @@ -21,16 +20,13 @@ from agent_framework import ( FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool, - HostedCodeInterpreterTool, - HostedMCPTool, - HostedWebSearchTool, + Message, ResponseStream, TextSpanRegion, UsageDetails, 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 @@ -50,7 +46,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 @@ -78,7 +74,7 @@ ANTHROPIC_DEFAULT_MAX_TOKENS: Final[int] = 1024 BETA_FLAGS: Final[list[str]] = ["mcp-client-2025-04-04", "code-execution-2025-08-25"] STRUCTURED_OUTPUTS_BETA_FLAG: Final[str] = "structured-outputs-2025-11-13" -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None) +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) # region Anthropic Chat Options TypedDict @@ -102,7 +98,7 @@ class ThinkingConfig(TypedDict, total=False): budget_tokens: int -class AnthropicChatOptions(ChatOptions[TResponseModel], Generic[TResponseModel], total=False): +class AnthropicChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): """Anthropic-specific chat options. Extends ChatOptions with options specific to Anthropic's Messages API. @@ -160,8 +156,8 @@ class AnthropicChatOptions(ChatOptions[TResponseModel], Generic[TResponseModel], conversation_id: None # type: ignore[misc] -TAnthropicOptions = TypeVar( - "TAnthropicOptions", +AnthropicOptionsT = TypeVar( + "AnthropicOptionsT", bound=TypedDict, # type: ignore[valid-type] default="AnthropicChatOptions", covariant=True, @@ -195,48 +191,28 @@ 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( - ChatMiddlewareLayer[TAnthropicOptions], - FunctionInvocationLayer[TAnthropicOptions], - ChatTelemetryLayer[TAnthropicOptions], - BaseChatClient[TAnthropicOptions], - Generic[TAnthropicOptions], + ChatMiddlewareLayer[AnthropicOptionsT], + FunctionInvocationLayer[AnthropicOptionsT], + ChatTelemetryLayer[AnthropicOptionsT], + BaseChatClient[AnthropicOptionsT], + Generic[AnthropicOptionsT], ): """Anthropic Chat client with middleware, telemetry, and function invocation support.""" @@ -314,25 +290,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}, ) @@ -346,17 +321,120 @@ 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 + # region Static factory methods for hosted tools + + @staticmethod + def get_code_interpreter_tool( + *, + type_name: str | None = None, + ) -> dict[str, Any]: + """Create a code interpreter tool configuration for Anthropic. + + Keyword Args: + type_name: Override the tool type name. Defaults to "code_execution_20250825". + + Returns: + A dict-based tool configuration ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.anthropic import AnthropicClient + + tool = AnthropicClient.get_code_interpreter_tool() + agent = AnthropicClient().as_agent(tools=[tool]) + """ + return {"type": type_name or "code_execution_20250825"} + + @staticmethod + def get_web_search_tool( + *, + type_name: str | None = None, + ) -> dict[str, Any]: + """Create a web search tool configuration for Anthropic. + + Keyword Args: + type_name: Override the tool type name. Defaults to "web_search_20250305". + + Returns: + A dict-based tool configuration ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.anthropic import AnthropicClient + + tool = AnthropicClient.get_web_search_tool() + agent = AnthropicClient().as_agent(tools=[tool]) + """ + return {"type": type_name or "web_search_20250305"} + + @staticmethod + def get_mcp_tool( + *, + name: str, + url: str, + allowed_tools: list[str] | None = None, + authorization_token: str | None = None, + ) -> dict[str, Any]: + """Create a hosted MCP tool configuration for Anthropic. + + This configures an MCP (Model Context Protocol) server that will be called + by Anthropic's service. The tools from this MCP server are executed remotely + by Anthropic, not locally by your application. + + Note: + For local MCP execution where your application calls the MCP server + directly, use the MCP client tools instead of this method. + + Keyword Args: + name: A label/name for the MCP server. + url: The URL of the MCP server. + allowed_tools: List of tool names that are allowed to be used from this MCP server. + authorization_token: Authorization token for the MCP server (e.g., Bearer token). + + Returns: + A dict-based tool configuration ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.anthropic import AnthropicClient + + tool = AnthropicClient.get_mcp_tool( + name="GitHub", + url="https://api.githubcopilot.com/mcp/", + authorization_token="Bearer ghp_xxx", + ) + agent = AnthropicClient().as_agent(tools=[tool]) + """ + result: dict[str, Any] = { + "type": "mcp", + "server_label": name.replace(" ", "_"), + "server_url": url, + } + + if allowed_tools: + result["allowed_tools"] = allowed_tools + + if authorization_token: + result["headers"] = {"authorization": authorization_token} + + return result + + # endregion + # region Get response methods @override def _inner_get_response( self, *, - messages: Sequence[ChatMessage], + messages: Sequence[Message], options: Mapping[str, Any], stream: bool = False, **kwargs: Any, @@ -385,7 +463,7 @@ class AnthropicClient( def _prepare_options( self, - messages: Sequence[ChatMessage], + messages: Sequence[Message], options: Mapping[str, Any], **kwargs: Any, ) -> dict[str, Any]: @@ -430,7 +508,7 @@ class AnthropicClient( run_options["messages"] = self._prepare_messages_for_anthropic(messages) # system message - first system message is passed as instructions - if messages and isinstance(messages[0], ChatMessage) and messages[0].role == "system": + if messages and isinstance(messages[0], Message) and messages[0].role == "system": run_options["system"] = messages[0].text # betas @@ -516,22 +594,22 @@ class AnthropicClient( "schema": schema, } - def _prepare_messages_for_anthropic(self, messages: Sequence[ChatMessage]) -> list[dict[str, Any]]: + def _prepare_messages_for_anthropic(self, messages: Sequence[Message]) -> list[dict[str, Any]]: """Prepare a list of ChatMessages for the Anthropic client. This skips the first message if it is a system message, as Anthropic expects system instructions as a separate parameter. """ # first system message is passed as instructions - if messages and isinstance(messages[0], ChatMessage) and messages[0].role == "system": + if messages and isinstance(messages[0], Message) and messages[0].role == "system": return [self._prepare_message_for_anthropic(msg) for msg in messages[1:]] return [self._prepare_message_for_anthropic(msg) for msg in messages] - def _prepare_message_for_anthropic(self, message: ChatMessage) -> dict[str, Any]: - """Prepare a ChatMessage for the Anthropic client. + def _prepare_message_for_anthropic(self, message: Message) -> dict[str, Any]: + """Prepare a Message for the Anthropic client. Args: - message: The ChatMessage to convert. + message: The Message to convert. Returns: A dictionary representing the message in Anthropic format. @@ -574,7 +652,7 @@ class AnthropicClient( a_content.append({ "type": "tool_result", "tool_use_id": content.call_id, - "content": prepare_function_call_results(content.result), + "content": content.result if content.result is not None else "", "is_error": content.exception is not None, }) case "text_reasoning": @@ -590,6 +668,9 @@ class AnthropicClient( def _prepare_tools_for_anthropic(self, options: Mapping[str, Any]) -> dict[str, Any] | None: """Prepare tools and tool choice configuration for the Anthropic API request. + Converts FunctionTool to Anthropic format. MCP tools are routed to separate + mcp_servers parameter. All other tools pass through unchanged. + Args: options: The options dict containing tools and tool choice settings. @@ -603,46 +684,32 @@ class AnthropicClient( # Process tools if tools: - tool_list: list[MutableMapping[str, Any]] = [] - mcp_server_list: list[MutableMapping[str, Any]] = [] + tool_list: list[Any] = [] + mcp_server_list: list[Any] = [] for tool in tools: - match tool: - case MutableMapping(): - tool_list.append(tool) - case FunctionTool(): - tool_list.append({ - "type": "custom", - "name": tool.name, - "description": tool.description, - "input_schema": tool.parameters(), - }) - case HostedWebSearchTool(): - search_tool: dict[str, Any] = { - "type": "web_search_20250305", - "name": "web_search", - } - if tool.additional_properties: - search_tool.update(tool.additional_properties) - tool_list.append(search_tool) - case HostedCodeInterpreterTool(): - code_tool: dict[str, Any] = { - "type": "code_execution_20250825", - "name": "code_execution", - } - tool_list.append(code_tool) - case HostedMCPTool(): - server_def: dict[str, Any] = { - "type": "url", - "name": tool.name, - "url": str(tool.url), - } - if tool.allowed_tools: - server_def["tool_configuration"] = {"allowed_tools": list(tool.allowed_tools)} - if tool.headers and (auth := tool.headers.get("authorization")): - server_def["authorization_token"] = auth - mcp_server_list.append(server_def) - case _: - logger.debug(f"Ignoring unsupported tool type: {type(tool)} for now") + if isinstance(tool, FunctionTool): + tool_list.append({ + "type": "custom", + "name": tool.name, + "description": tool.description, + "input_schema": tool.parameters(), + }) + elif isinstance(tool, MutableMapping) and tool.get("type") == "mcp": + # MCP servers must be routed to separate mcp_servers parameter + server_def: dict[str, Any] = { + "type": "url", + "name": tool.get("server_label", ""), + "url": tool.get("server_url", ""), + } + if allowed_tools := tool.get("allowed_tools"): + server_def["tool_configuration"] = {"allowed_tools": list(allowed_tools)} + headers = tool.get("headers") + if isinstance(headers, dict) and (auth := headers.get("authorization")): + server_def["authorization_token"] = auth + mcp_server_list.append(server_def) + else: + # Pass through all other tools (dicts, SDK types) unchanged + tool_list.append(tool) if tool_list: result["tools"] = tool_list @@ -693,7 +760,7 @@ class AnthropicClient( return ChatResponse( response_id=message.id, messages=[ - ChatMessage( + Message( role="assistant", contents=self._parse_contents_from_anthropic(message.content), raw_representation=message, diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index 7106f8adb0..2a773cc628 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "anthropic>=0.70.0,<1", ] diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 75c2144258..ff9234f60b 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -6,16 +6,14 @@ from unittest.mock import MagicMock, patch import pytest from agent_framework import ( - ChatClientProtocol, - ChatMessage, ChatOptions, ChatResponseUpdate, Content, - HostedCodeInterpreterTool, - HostedMCPTool, - HostedWebSearchTool, + Message, + SupportsChatGetResponse, tool, ) +from agent_framework._settings import load_settings from agent_framework.exceptions import ServiceInitializationError from anthropic.types.beta import ( BetaMessage, @@ -23,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 @@ -44,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 @@ -53,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 @@ -67,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 @@ -98,11 +104,11 @@ def test_anthropic_settings_missing_api_key(anthropic_unit_test_env: dict[str, s def test_anthropic_client_init_with_client(mock_anthropic_client: MagicMock) -> None: """Test AnthropicClient initialization with existing anthropic_client.""" - chat_client = create_test_anthropic_client(mock_anthropic_client, model_id="claude-3-5-sonnet-20241022") + client = create_test_anthropic_client(mock_anthropic_client, model_id="claude-3-5-sonnet-20241022") - assert chat_client.anthropic_client is mock_anthropic_client - assert chat_client.model_id == "claude-3-5-sonnet-20241022" - assert isinstance(chat_client, ChatClientProtocol) + assert client.anthropic_client is mock_anthropic_client + assert client.model_id == "claude-3-5-sonnet-20241022" + assert isinstance(client, SupportsChatGetResponse) def test_anthropic_client_init_auto_create_client(anthropic_unit_test_env: dict[str, str]) -> None: @@ -119,27 +125,17 @@ 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.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) - assert chat_client.service_url() == "https://api.anthropic.com" + client = create_test_anthropic_client(mock_anthropic_client) + assert client.service_url() == "https://api.anthropic.com" # Message Conversion Tests @@ -147,10 +143,10 @@ def test_anthropic_client_service_url(mock_anthropic_client: MagicMock) -> None: def test_prepare_message_for_anthropic_text(mock_anthropic_client: MagicMock) -> None: """Test converting text message to Anthropic format.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) - message = ChatMessage(role="user", text="Hello, world!") + client = create_test_anthropic_client(mock_anthropic_client) + message = Message(role="user", text="Hello, world!") - result = chat_client._prepare_message_for_anthropic(message) + result = client._prepare_message_for_anthropic(message) assert result["role"] == "user" assert len(result["content"]) == 1 @@ -160,8 +156,8 @@ def test_prepare_message_for_anthropic_text(mock_anthropic_client: MagicMock) -> def test_prepare_message_for_anthropic_function_call(mock_anthropic_client: MagicMock) -> None: """Test converting function call message to Anthropic format.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) - message = ChatMessage( + client = create_test_anthropic_client(mock_anthropic_client) + message = Message( role="assistant", contents=[ Content.from_function_call( @@ -172,7 +168,7 @@ def test_prepare_message_for_anthropic_function_call(mock_anthropic_client: Magi ], ) - result = chat_client._prepare_message_for_anthropic(message) + result = client._prepare_message_for_anthropic(message) assert result["role"] == "assistant" assert len(result["content"]) == 1 @@ -184,8 +180,8 @@ def test_prepare_message_for_anthropic_function_call(mock_anthropic_client: Magi def test_prepare_message_for_anthropic_function_result(mock_anthropic_client: MagicMock) -> None: """Test converting function result message to Anthropic format.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) - message = ChatMessage( + client = create_test_anthropic_client(mock_anthropic_client) + message = Message( role="tool", contents=[ Content.from_function_result( @@ -195,7 +191,7 @@ def test_prepare_message_for_anthropic_function_result(mock_anthropic_client: Ma ], ) - result = chat_client._prepare_message_for_anthropic(message) + result = client._prepare_message_for_anthropic(message) assert result["role"] == "user" assert len(result["content"]) == 1 @@ -209,13 +205,13 @@ def test_prepare_message_for_anthropic_function_result(mock_anthropic_client: Ma def test_prepare_message_for_anthropic_text_reasoning(mock_anthropic_client: MagicMock) -> None: """Test converting text reasoning message to Anthropic format.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) - message = ChatMessage( + client = create_test_anthropic_client(mock_anthropic_client) + message = Message( role="assistant", contents=[Content.from_text_reasoning(text="Let me think about this...")], ) - result = chat_client._prepare_message_for_anthropic(message) + result = client._prepare_message_for_anthropic(message) assert result["role"] == "assistant" assert len(result["content"]) == 1 @@ -225,13 +221,13 @@ def test_prepare_message_for_anthropic_text_reasoning(mock_anthropic_client: Mag def test_prepare_messages_for_anthropic_with_system(mock_anthropic_client: MagicMock) -> None: """Test converting messages list with system message.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) messages = [ - ChatMessage(role="system", text="You are a helpful assistant."), - ChatMessage(role="user", text="Hello!"), + Message(role="system", text="You are a helpful assistant."), + Message(role="user", text="Hello!"), ] - result = chat_client._prepare_messages_for_anthropic(messages) + result = client._prepare_messages_for_anthropic(messages) # System message should be skipped assert len(result) == 1 @@ -241,13 +237,13 @@ def test_prepare_messages_for_anthropic_with_system(mock_anthropic_client: Magic def test_prepare_messages_for_anthropic_without_system(mock_anthropic_client: MagicMock) -> None: """Test converting messages list without system message.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) messages = [ - ChatMessage(role="user", text="Hello!"), - ChatMessage(role="assistant", text="Hi there!"), + Message(role="user", text="Hello!"), + Message(role="assistant", text="Hi there!"), ] - result = chat_client._prepare_messages_for_anthropic(messages) + result = client._prepare_messages_for_anthropic(messages) assert len(result) == 2 assert result[0]["role"] == "user" @@ -259,7 +255,7 @@ def test_prepare_messages_for_anthropic_without_system(mock_anthropic_client: Ma def test_prepare_tools_for_anthropic_tool(mock_anthropic_client: MagicMock) -> None: """Test converting FunctionTool to Anthropic format.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) @tool(approval_mode="never_require") def get_weather(location: Annotated[str, Field(description="Location to get weather for")]) -> str: @@ -267,7 +263,7 @@ def test_prepare_tools_for_anthropic_tool(mock_anthropic_client: MagicMock) -> N return f"Weather for {location}" chat_options = ChatOptions(tools=[get_weather]) - result = chat_client._prepare_tools_for_anthropic(chat_options) + result = client._prepare_tools_for_anthropic(chat_options) assert result is not None assert "tools" in result @@ -278,39 +274,37 @@ def test_prepare_tools_for_anthropic_tool(mock_anthropic_client: MagicMock) -> N def test_prepare_tools_for_anthropic_web_search(mock_anthropic_client: MagicMock) -> None: - """Test converting HostedWebSearchTool to Anthropic format.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) - chat_options = ChatOptions(tools=[HostedWebSearchTool()]) + """Test converting web_search dict tool to Anthropic format.""" + client = create_test_anthropic_client(mock_anthropic_client) + chat_options = ChatOptions(tools=[client.get_web_search_tool()]) - result = chat_client._prepare_tools_for_anthropic(chat_options) + result = client._prepare_tools_for_anthropic(chat_options) assert result is not None assert "tools" in result assert len(result["tools"]) == 1 assert result["tools"][0]["type"] == "web_search_20250305" - assert result["tools"][0]["name"] == "web_search" def test_prepare_tools_for_anthropic_code_interpreter(mock_anthropic_client: MagicMock) -> None: - """Test converting HostedCodeInterpreterTool to Anthropic format.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) - chat_options = ChatOptions(tools=[HostedCodeInterpreterTool()]) + """Test converting code_interpreter dict tool to Anthropic format.""" + client = create_test_anthropic_client(mock_anthropic_client) + chat_options = ChatOptions(tools=[client.get_code_interpreter_tool()]) - result = chat_client._prepare_tools_for_anthropic(chat_options) + result = client._prepare_tools_for_anthropic(chat_options) assert result is not None assert "tools" in result assert len(result["tools"]) == 1 assert result["tools"][0]["type"] == "code_execution_20250825" - assert result["tools"][0]["name"] == "code_execution" def test_prepare_tools_for_anthropic_mcp_tool(mock_anthropic_client: MagicMock) -> None: - """Test converting HostedMCPTool to Anthropic format.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) - chat_options = ChatOptions(tools=[HostedMCPTool(name="test-mcp", url="https://example.com/mcp")]) + """Test converting MCP dict tool to Anthropic format.""" + client = create_test_anthropic_client(mock_anthropic_client) + chat_options = ChatOptions(tools=[client.get_mcp_tool(name="test-mcp", url="https://example.com/mcp")]) - result = chat_client._prepare_tools_for_anthropic(chat_options) + result = client._prepare_tools_for_anthropic(chat_options) assert result is not None assert "mcp_servers" in result @@ -321,33 +315,31 @@ def test_prepare_tools_for_anthropic_mcp_tool(mock_anthropic_client: MagicMock) def test_prepare_tools_for_anthropic_mcp_with_auth(mock_anthropic_client: MagicMock) -> None: - """Test converting HostedMCPTool with authorization headers.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) - chat_options = ChatOptions( - tools=[ - HostedMCPTool( - name="test-mcp", - url="https://example.com/mcp", - headers={"authorization": "Bearer token123"}, - ) - ] + """Test converting MCP dict tool with authorization token.""" + client = create_test_anthropic_client(mock_anthropic_client) + # Use the static method with authorization_token + mcp_tool = client.get_mcp_tool( + name="test-mcp", + url="https://example.com/mcp", + authorization_token="Bearer token123", ) + chat_options = ChatOptions(tools=[mcp_tool]) - result = chat_client._prepare_tools_for_anthropic(chat_options) + result = client._prepare_tools_for_anthropic(chat_options) assert result is not None assert "mcp_servers" in result - # The authorization header is converted to authorization_token + # The authorization_token should be passed through assert "authorization_token" in result["mcp_servers"][0] assert result["mcp_servers"][0]["authorization_token"] == "Bearer token123" def test_prepare_tools_for_anthropic_dict_tool(mock_anthropic_client: MagicMock) -> None: """Test converting dict tool to Anthropic format.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) chat_options = ChatOptions(tools=[{"type": "custom", "name": "custom_tool", "description": "A custom tool"}]) - result = chat_client._prepare_tools_for_anthropic(chat_options) + result = client._prepare_tools_for_anthropic(chat_options) assert result is not None assert "tools" in result @@ -357,10 +349,10 @@ def test_prepare_tools_for_anthropic_dict_tool(mock_anthropic_client: MagicMock) def test_prepare_tools_for_anthropic_none(mock_anthropic_client: MagicMock) -> None: """Test converting None tools.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) chat_options = ChatOptions() - result = chat_client._prepare_tools_for_anthropic(chat_options) + result = client._prepare_tools_for_anthropic(chat_options) assert result is None @@ -370,14 +362,14 @@ def test_prepare_tools_for_anthropic_none(mock_anthropic_client: MagicMock) -> N async def test_prepare_options_basic(mock_anthropic_client: MagicMock) -> None: """Test _prepare_options with basic ChatOptions.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] chat_options = ChatOptions(max_tokens=100, temperature=0.7) - run_options = chat_client._prepare_options(messages, chat_options) + run_options = client._prepare_options(messages, chat_options) - assert run_options["model"] == chat_client.model_id + assert run_options["model"] == client.model_id assert run_options["max_tokens"] == 100 assert run_options["temperature"] == 0.7 assert "messages" in run_options @@ -385,15 +377,15 @@ async def test_prepare_options_basic(mock_anthropic_client: MagicMock) -> None: async def test_prepare_options_with_system_message(mock_anthropic_client: MagicMock) -> None: """Test _prepare_options with system message.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) messages = [ - ChatMessage(role="system", text="You are helpful."), - ChatMessage(role="user", text="Hello"), + Message(role="system", text="You are helpful."), + Message(role="user", text="Hello"), ] chat_options = ChatOptions() - run_options = chat_client._prepare_options(messages, chat_options) + run_options = client._prepare_options(messages, chat_options) assert run_options["system"] == "You are helpful." assert len(run_options["messages"]) == 1 # System message not in messages list @@ -401,25 +393,25 @@ async def test_prepare_options_with_system_message(mock_anthropic_client: MagicM async def test_prepare_options_with_tool_choice_auto(mock_anthropic_client: MagicMock) -> None: """Test _prepare_options with auto tool choice.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] chat_options = ChatOptions(tool_choice="auto") - run_options = chat_client._prepare_options(messages, chat_options) + run_options = client._prepare_options(messages, chat_options) assert run_options["tool_choice"]["type"] == "auto" async def test_prepare_options_with_tool_choice_required(mock_anthropic_client: MagicMock) -> None: """Test _prepare_options with required tool choice.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] # For required with specific function, need to pass as dict chat_options = ChatOptions(tool_choice={"mode": "required", "required_function_name": "get_weather"}) - run_options = chat_client._prepare_options(messages, chat_options) + run_options = client._prepare_options(messages, chat_options) assert run_options["tool_choice"]["type"] == "tool" assert run_options["tool_choice"]["name"] == "get_weather" @@ -427,29 +419,29 @@ async def test_prepare_options_with_tool_choice_required(mock_anthropic_client: async def test_prepare_options_with_tool_choice_none(mock_anthropic_client: MagicMock) -> None: """Test _prepare_options with none tool choice.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] chat_options = ChatOptions(tool_choice="none") - run_options = chat_client._prepare_options(messages, chat_options) + run_options = client._prepare_options(messages, chat_options) assert run_options["tool_choice"]["type"] == "none" async def test_prepare_options_with_tools(mock_anthropic_client: MagicMock) -> None: """Test _prepare_options with tools.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) @tool(approval_mode="never_require") def get_weather(location: str) -> str: """Get weather for a location.""" return f"Weather for {location}" - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] chat_options = ChatOptions(tools=[get_weather]) - run_options = chat_client._prepare_options(messages, chat_options) + run_options = client._prepare_options(messages, chat_options) assert "tools" in run_options assert len(run_options["tools"]) == 1 @@ -457,24 +449,24 @@ async def test_prepare_options_with_tools(mock_anthropic_client: MagicMock) -> N async def test_prepare_options_with_stop_sequences(mock_anthropic_client: MagicMock) -> None: """Test _prepare_options with stop sequences.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] chat_options = ChatOptions(stop=["STOP", "END"]) - run_options = chat_client._prepare_options(messages, chat_options) + run_options = client._prepare_options(messages, chat_options) assert run_options["stop_sequences"] == ["STOP", "END"] async def test_prepare_options_with_top_p(mock_anthropic_client: MagicMock) -> None: """Test _prepare_options with top_p.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] chat_options = ChatOptions(top_p=0.9) - run_options = chat_client._prepare_options(messages, chat_options) + run_options = client._prepare_options(messages, chat_options) assert run_options["top_p"] == 0.9 @@ -485,9 +477,9 @@ async def test_prepare_options_filters_internal_kwargs(mock_anthropic_client: Ma Internal kwargs like _function_middleware_pipeline, thread, and middleware should be filtered out before being passed to the Anthropic API. """ - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] chat_options: ChatOptions = {} # Simulate internal kwargs that get passed through the middleware pipeline @@ -499,7 +491,7 @@ async def test_prepare_options_filters_internal_kwargs(mock_anthropic_client: Ma "middleware": [object()], } - run_options = chat_client._prepare_options(messages, chat_options, **internal_kwargs) + run_options = client._prepare_options(messages, chat_options, **internal_kwargs) # Internal kwargs should be filtered out assert "_function_middleware_pipeline" not in run_options @@ -514,7 +506,7 @@ async def test_prepare_options_filters_internal_kwargs(mock_anthropic_client: Ma def test_process_message_basic(mock_anthropic_client: MagicMock) -> None: """Test _process_message with basic text response.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) mock_message = MagicMock(spec=BetaMessage) mock_message.id = "msg_123" @@ -523,7 +515,7 @@ def test_process_message_basic(mock_anthropic_client: MagicMock) -> None: mock_message.usage = BetaUsage(input_tokens=10, output_tokens=5) mock_message.stop_reason = "end_turn" - response = chat_client._process_message(mock_message, {}) + response = client._process_message(mock_message, {}) assert response.response_id == "msg_123" assert response.model_id == "claude-3-5-sonnet-20241022" @@ -540,7 +532,7 @@ def test_process_message_basic(mock_anthropic_client: MagicMock) -> None: def test_process_message_with_tool_use(mock_anthropic_client: MagicMock) -> None: """Test _process_message with tool use.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) mock_message = MagicMock(spec=BetaMessage) mock_message.id = "msg_123" @@ -556,7 +548,7 @@ def test_process_message_with_tool_use(mock_anthropic_client: MagicMock) -> None mock_message.usage = BetaUsage(input_tokens=10, output_tokens=5) mock_message.stop_reason = "tool_use" - response = chat_client._process_message(mock_message, {}) + response = client._process_message(mock_message, {}) assert len(response.messages[0].contents) == 1 assert response.messages[0].contents[0].type == "function_call" @@ -567,10 +559,10 @@ def test_process_message_with_tool_use(mock_anthropic_client: MagicMock) -> None def test_parse_usage_from_anthropic_basic(mock_anthropic_client: MagicMock) -> None: """Test _parse_usage_from_anthropic with basic usage.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) usage = BetaUsage(input_tokens=10, output_tokens=5) - result = chat_client._parse_usage_from_anthropic(usage) + result = client._parse_usage_from_anthropic(usage) assert result is not None assert result["input_token_count"] == 10 @@ -579,19 +571,19 @@ def test_parse_usage_from_anthropic_basic(mock_anthropic_client: MagicMock) -> N def test_parse_usage_from_anthropic_none(mock_anthropic_client: MagicMock) -> None: """Test _parse_usage_from_anthropic with None usage.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) - result = chat_client._parse_usage_from_anthropic(None) + result = client._parse_usage_from_anthropic(None) assert result is None def test_parse_contents_from_anthropic_text(mock_anthropic_client: MagicMock) -> None: """Test _parse_contents_from_anthropic with text content.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) content = [BetaTextBlock(type="text", text="Hello!")] - result = chat_client._parse_contents_from_anthropic(content) + result = client._parse_contents_from_anthropic(content) assert len(result) == 1 assert result[0].type == "text" @@ -600,7 +592,7 @@ def test_parse_contents_from_anthropic_text(mock_anthropic_client: MagicMock) -> def test_parse_contents_from_anthropic_tool_use(mock_anthropic_client: MagicMock) -> None: """Test _parse_contents_from_anthropic with tool use.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) content = [ BetaToolUseBlock( @@ -610,7 +602,7 @@ def test_parse_contents_from_anthropic_tool_use(mock_anthropic_client: MagicMock input={"location": "SF"}, ) ] - result = chat_client._parse_contents_from_anthropic(content) + result = client._parse_contents_from_anthropic(content) assert len(result) == 1 assert result[0].type == "function_call" @@ -625,7 +617,7 @@ def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name(mock_a and subsequent input_json_delta events should have name="" to prevent ag-ui from emitting duplicate ToolCallStartEvents. """ - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) # First, simulate a tool_use event that sets _last_call_id_name tool_use_content = MagicMock() @@ -634,7 +626,7 @@ def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name(mock_a tool_use_content.name = "get_weather" tool_use_content.input = {} - result = chat_client._parse_contents_from_anthropic([tool_use_content]) + result = client._parse_contents_from_anthropic([tool_use_content]) assert len(result) == 1 assert result[0].type == "function_call" assert result[0].call_id == "call_123" @@ -645,7 +637,7 @@ def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name(mock_a delta_content_1.type = "input_json_delta" delta_content_1.partial_json = '{"location":' - result = chat_client._parse_contents_from_anthropic([delta_content_1]) + result = client._parse_contents_from_anthropic([delta_content_1]) assert len(result) == 1 assert result[0].type == "function_call" assert result[0].call_id == "call_123" @@ -657,7 +649,7 @@ def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name(mock_a delta_content_2.type = "input_json_delta" delta_content_2.partial_json = '"San Francisco"}' - result = chat_client._parse_contents_from_anthropic([delta_content_2]) + result = client._parse_contents_from_anthropic([delta_content_2]) assert len(result) == 1 assert result[0].type == "function_call" assert result[0].call_id == "call_123" @@ -670,13 +662,13 @@ def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name(mock_a def test_process_stream_event_simple(mock_anthropic_client: MagicMock) -> None: """Test _process_stream_event with simple mock event.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) # Test with a basic mock event - the actual implementation will handle real events mock_event = MagicMock() mock_event.type = "message_stop" - result = chat_client._process_stream_event(mock_event) + result = client._process_stream_event(mock_event) # message_stop events return None assert result is None @@ -684,7 +676,7 @@ def test_process_stream_event_simple(mock_anthropic_client: MagicMock) -> None: async def test_inner_get_response(mock_anthropic_client: MagicMock) -> None: """Test _inner_get_response method.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) # Create a mock message response mock_message = MagicMock(spec=BetaMessage) @@ -696,10 +688,10 @@ async def test_inner_get_response(mock_anthropic_client: MagicMock) -> None: mock_anthropic_client.beta.messages.create.return_value = mock_message - messages = [ChatMessage(role="user", text="Hi")] + messages = [Message(role="user", text="Hi")] chat_options = ChatOptions(max_tokens=10) - response = await chat_client._inner_get_response( # type: ignore[attr-defined] + response = await client._inner_get_response( # type: ignore[attr-defined] messages=messages, options=chat_options ) @@ -710,7 +702,7 @@ async def test_inner_get_response(mock_anthropic_client: MagicMock) -> None: async def test_inner_get_response_streaming(mock_anthropic_client: MagicMock) -> None: """Test _inner_get_response method with streaming.""" - chat_client = create_test_anthropic_client(mock_anthropic_client) + client = create_test_anthropic_client(mock_anthropic_client) # Create mock streaming response async def mock_stream(): @@ -720,11 +712,11 @@ async def test_inner_get_response_streaming(mock_anthropic_client: MagicMock) -> mock_anthropic_client.beta.messages.create.return_value = mock_stream() - messages = [ChatMessage(role="user", text="Hi")] + messages = [Message(role="user", text="Hi")] chat_options = ChatOptions(max_tokens=10) chunks: list[ChatResponseUpdate] = [] - async for chunk in chat_client._inner_get_response( # type: ignore[attr-defined] + async for chunk in client._inner_get_response( # type: ignore[attr-defined] messages=messages, options=chat_options, stream=True ): if chunk: @@ -751,7 +743,7 @@ async def test_anthropic_client_integration_basic_chat() -> None: """Integration test for basic chat completion.""" client = AnthropicClient() - messages = [ChatMessage(role="user", text="Say 'Hello, World!' and nothing else.")] + messages = [Message(role="user", text="Say 'Hello, World!' and nothing else.")] response = await client.get_response(messages=messages, options={"max_tokens": 50}) @@ -768,7 +760,7 @@ async def test_anthropic_client_integration_streaming_chat() -> None: """Integration test for streaming chat completion.""" client = AnthropicClient() - messages = [ChatMessage(role="user", text="Count from 1 to 5.")] + messages = [Message(role="user", text="Count from 1 to 5.")] chunks = [] async for chunk in client.get_response(messages=messages, stream=True, options={"max_tokens": 50}): @@ -784,7 +776,7 @@ async def test_anthropic_client_integration_function_calling() -> None: """Integration test for function calling.""" client = AnthropicClient() - messages = [ChatMessage(role="user", text="What's the weather in San Francisco?")] + messages = [Message(role="user", text="What's the weather in San Francisco?")] tools = [get_weather] response = await client.get_response( @@ -804,14 +796,13 @@ async def test_anthropic_client_integration_hosted_tools() -> None: """Integration test for hosted tools.""" client = AnthropicClient() - messages = [ChatMessage(role="user", text="What tools do you have available?")] + messages = [Message(role="user", text="What tools do you have available?")] tools = [ - HostedWebSearchTool(), - HostedCodeInterpreterTool(), - HostedMCPTool( + AnthropicClient.get_web_search_tool(), + AnthropicClient.get_code_interpreter_tool(), + AnthropicClient.get_mcp_tool( name="example-mcp", url="https://learn.microsoft.com/api/mcp", - approval_mode="never_require", ), ] @@ -831,8 +822,8 @@ async def test_anthropic_client_integration_with_system_message() -> None: client = AnthropicClient() messages = [ - ChatMessage(role="system", text="You are a pirate. Always respond like a pirate."), - ChatMessage(role="user", text="Hello!"), + Message(role="system", text="You are a pirate. Always respond like a pirate."), + Message(role="user", text="Hello!"), ] response = await client.get_response(messages=messages, options={"max_tokens": 50}) @@ -847,7 +838,7 @@ async def test_anthropic_client_integration_temperature_control() -> None: """Integration test with temperature control.""" client = AnthropicClient() - messages = [ChatMessage(role="user", text="Say hello.")] + messages = [Message(role="user", text="Say hello.")] response = await client.get_response( messages=messages, @@ -865,11 +856,11 @@ async def test_anthropic_client_integration_ordering() -> None: client = AnthropicClient() messages = [ - ChatMessage(role="user", text="Say hello."), - ChatMessage(role="user", text="Then say goodbye."), - ChatMessage(role="assistant", text="Thank you for chatting!"), - ChatMessage(role="assistant", text="Let me know if I can help."), - ChatMessage(role="user", text="Just testing things."), + Message(role="user", text="Say hello."), + Message(role="user", text="Then say goodbye."), + Message(role="assistant", text="Thank you for chatting!"), + Message(role="assistant", text="Let me know if I can help."), + Message(role="user", text="Just testing things."), ] response = await client.get_response(messages=messages) @@ -890,7 +881,7 @@ async def test_anthropic_client_integration_images() -> None: image_bytes = img_file.read() messages = [ - ChatMessage( + Message( role="user", contents=[ Content.from_text(text="Describe this image"), diff --git a/python/packages/azure-ai-search/AGENTS.md b/python/packages/azure-ai-search/AGENTS.md index 14e8f65e96..114ee9d9ab 100644 --- a/python/packages/azure-ai-search/AGENTS.md +++ b/python/packages/azure-ai-search/AGENTS.md @@ -16,7 +16,7 @@ provider = AzureAISearchContextProvider( endpoint="https://your-search.search.windows.net", index_name="your-index", ) -agent = ChatAgent(..., context_provider=provider) +agent = Agent(..., context_provider=provider) ``` ## Import Path diff --git a/python/packages/azure-ai-search/README.md b/python/packages/azure-ai-search/README.md index 06853ae09e..c24677b3b0 100644 --- a/python/packages/azure-ai-search/README.md +++ b/python/packages/azure-ai-search/README.md @@ -15,7 +15,7 @@ The Azure AI Search integration provides context providers for RAG (Retrieval Au ### Basic Usage Example -See the [Azure AI Search context provider examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/azure_ai/) which demonstrate: +See the [Azure AI Search context provider examples](../../samples/02-agents/providers/azure_ai/) which demonstrate: - Semantic search with hybrid (vector + keyword) queries - Agentic mode with Knowledge Bases for complex multi-hop reasoning diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/__init__.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/__init__.py index fedfb05bcd..9610be5774 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/__init__.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/__init__.py @@ -2,7 +2,7 @@ import importlib.metadata -from ._search_provider import AzureAISearchContextProvider, AzureAISearchSettings +from ._context_provider import AzureAISearchContextProvider, AzureAISearchSettings try: __version__ = importlib.metadata.version(__name__) 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 new file mode 100644 index 0000000000..9edf73fdc3 --- /dev/null +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py @@ -0,0 +1,626 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""New-pattern Azure AI Search context provider using BaseContextProvider. + +This module provides ``AzureAISearchContextProvider``, built on the new +:class:`BaseContextProvider` hooks pattern. +""" + +from __future__ import annotations + +import sys +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict + +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 SecretString, load_settings +from agent_framework.exceptions import ServiceInitializationError +from azure.core.credentials import AzureKeyCredential +from azure.core.credentials_async import AsyncTokenCredential +from azure.core.exceptions import ResourceNotFoundError +from azure.search.documents.aio import SearchClient +from azure.search.documents.indexes.aio import SearchIndexClient +from azure.search.documents.indexes.models import ( + AzureOpenAIVectorizerParameters, + KnowledgeBase, + KnowledgeBaseAzureOpenAIModel, + KnowledgeRetrievalLowReasoningEffort, + KnowledgeRetrievalMediumReasoningEffort, + KnowledgeRetrievalMinimalReasoningEffort, + KnowledgeRetrievalOutputMode, + KnowledgeRetrievalReasoningEffort, + KnowledgeSourceReference, + SearchIndexKnowledgeSource, + SearchIndexKnowledgeSourceParameters, +) +from azure.search.documents.models import ( + QueryCaptionType, + QueryType, + VectorizableTextQuery, + VectorizedQuery, +) + +if TYPE_CHECKING: + from agent_framework._agents import SupportsAgentRun + from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient + from azure.search.documents.knowledgebases.models import ( + KnowledgeBaseMessage, + KnowledgeBaseMessageTextContent, + KnowledgeBaseRetrievalRequest, + KnowledgeRetrievalIntent, + KnowledgeRetrievalSemanticIntent, + ) + from azure.search.documents.knowledgebases.models import ( + KnowledgeRetrievalLowReasoningEffort as KBRetrievalLowReasoningEffort, + ) + from azure.search.documents.knowledgebases.models import ( + KnowledgeRetrievalMediumReasoningEffort as KBRetrievalMediumReasoningEffort, + ) + from azure.search.documents.knowledgebases.models import ( + KnowledgeRetrievalMinimalReasoningEffort as KBRetrievalMinimalReasoningEffort, + ) + from azure.search.documents.knowledgebases.models import ( + KnowledgeRetrievalOutputMode as KBRetrievalOutputMode, + ) + from azure.search.documents.knowledgebases.models import ( + KnowledgeRetrievalReasoningEffort as KBRetrievalReasoningEffort, + ) + +if sys.version_info >= (3, 11): + from typing import Self # pragma: no cover +else: + from typing_extensions import Self # pragma: no cover + +# Runtime imports for agentic mode (optional dependency) +try: + from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient + from azure.search.documents.knowledgebases.models import ( + KnowledgeBaseMessage, + KnowledgeBaseMessageTextContent, + KnowledgeBaseRetrievalRequest, + KnowledgeRetrievalIntent, + KnowledgeRetrievalSemanticIntent, + ) + from azure.search.documents.knowledgebases.models import ( + KnowledgeRetrievalLowReasoningEffort as KBRetrievalLowReasoningEffort, + ) + from azure.search.documents.knowledgebases.models import ( + KnowledgeRetrievalMediumReasoningEffort as KBRetrievalMediumReasoningEffort, + ) + from azure.search.documents.knowledgebases.models import ( + KnowledgeRetrievalMinimalReasoningEffort as KBRetrievalMinimalReasoningEffort, + ) + from azure.search.documents.knowledgebases.models import ( + KnowledgeRetrievalOutputMode as KBRetrievalOutputMode, + ) + from azure.search.documents.knowledgebases.models import ( + KnowledgeRetrievalReasoningEffort as KBRetrievalReasoningEffort, + ) + + _agentic_retrieval_available = True +except ImportError: + _agentic_retrieval_available = False + +logger = get_logger(__name__) + +_DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT = 10 + + +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_'. + If the environment variables are not found, the settings can be loaded from a .env file. + + Keys: + endpoint: Azure AI Search endpoint URL. + Can be set via environment variable AZURE_SEARCH_ENDPOINT. + index_name: Name of the search index. + Can be set via environment variable AZURE_SEARCH_INDEX_NAME. + knowledge_base_name: Name of an existing Knowledge Base (for agentic mode). + Can be set via environment variable AZURE_SEARCH_KNOWLEDGE_BASE_NAME. + api_key: API key for authentication (optional, use managed identity if not provided). + Can be set via environment variable AZURE_SEARCH_API_KEY. + """ + + endpoint: str | None + index_name: str | None + knowledge_base_name: str | None + api_key: SecretString | None + + +class AzureAISearchContextProvider(BaseContextProvider): + """Azure AI Search context provider using the new BaseContextProvider hooks pattern. + + Retrieves relevant context from Azure AI Search using semantic or agentic search + modes. + """ + + _DEFAULT_SEARCH_CONTEXT_PROMPT: ClassVar[str] = "Use the following context to answer the question:" + + def __init__( + self, + source_id: str, + endpoint: str | None = None, + index_name: str | None = None, + api_key: str | AzureKeyCredential | None = None, + credential: AsyncTokenCredential | None = None, + *, + mode: Literal["semantic", "agentic"] = "semantic", + top_k: int = 5, + semantic_configuration_name: str | None = None, + vector_field_name: str | None = None, + embedding_function: Callable[[str], Awaitable[list[float]]] | None = None, + context_prompt: str | None = None, + azure_openai_resource_url: str | None = None, + model_deployment_name: str | None = None, + model_name: str | None = None, + knowledge_base_name: str | None = None, + retrieval_instructions: str | None = None, + azure_openai_api_key: str | None = None, + knowledge_base_output_mode: Literal["extractive_data", "answer_synthesis"] = "extractive_data", + retrieval_reasoning_effort: Literal["minimal", "medium", "low"] = "minimal", + agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize Azure AI Search Context Provider. + + Args: + source_id: Unique identifier for this provider instance. + endpoint: Azure AI Search endpoint URL. + index_name: Name of the search index to query. + api_key: API key for authentication. + credential: AsyncTokenCredential for managed identity authentication. + mode: Search mode - "semantic" or "agentic". Default: "semantic". + top_k: Maximum number of documents to retrieve. Default: 5. + semantic_configuration_name: Name of semantic configuration in the index. + vector_field_name: Name of the vector field in the index. + embedding_function: Async function to generate embeddings. + context_prompt: Custom prompt to prepend to retrieved context. + azure_openai_resource_url: Azure OpenAI resource URL for Knowledge Base. + model_deployment_name: Model deployment name in Azure OpenAI. + model_name: The underlying model name. + knowledge_base_name: Name of an existing Knowledge Base to use. + retrieval_instructions: Custom instructions for Knowledge Base retrieval. + azure_openai_api_key: Azure OpenAI API key. + knowledge_base_output_mode: Output mode for Knowledge Base retrieval. + retrieval_reasoning_effort: Reasoning effort for Knowledge Base query planning. + agentic_message_history_count: Number of recent messages for agentic mode. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + """ + super().__init__(source_id) + + # Determine which fields are required based on mode + required: list[str | tuple[str, ...]] = ["endpoint"] + if mode == "semantic": + required.append("index_name") + elif mode == "agentic": + required.append(("index_name", "knowledge_base_name")) + + # Load settings from environment/file + settings = load_settings( + AzureAISearchSettings, + env_prefix="AZURE_SEARCH_", + required_fields=required, + 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 mode == "agentic" and 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." + ) + + resolved_credential: AzureKeyCredential | AsyncTokenCredential + if credential: + resolved_credential = credential + elif isinstance(api_key, AzureKeyCredential): + resolved_credential = api_key + 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: 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 + self.semantic_configuration_name = semantic_configuration_name + self.vector_field_name = vector_field_name + self.embedding_function = embedding_function + self.context_prompt = context_prompt or self._DEFAULT_SEARCH_CONTEXT_PROMPT + + 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.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 + self.retrieval_reasoning_effort = retrieval_reasoning_effort + self.agentic_message_history_count = agentic_message_history_count + + self._use_existing_knowledge_base = False + if mode == "agentic": + if settings.get("knowledge_base_name"): + self._use_existing_knowledge_base = True + else: + self.knowledge_base_name = f"{settings.get('index_name', '')}-kb" + + self._auto_discovered_vector_field = False + self._use_vectorizable_query = False + + if vector_field_name and not embedding_function: + raise ValueError("embedding_function is required when vector_field_name is specified") + + if mode == "agentic": + if not _agentic_retrieval_available: + raise ImportError( + "Agentic retrieval requires azure-search-documents >= 11.7.0b1 with Knowledge Base support." + ) + if not self._use_existing_knowledge_base and not self.azure_openai_resource_url: + raise ValueError( + "azure_openai_resource_url is required for agentic mode when creating Knowledge Base from index." + ) + + self._search_client: SearchClient | None = None + if self.index_name: + self._search_client = SearchClient( + endpoint=self.endpoint, + index_name=self.index_name, + credential=self.credential, + user_agent=AGENT_FRAMEWORK_USER_AGENT, + ) + + self._index_client: SearchIndexClient | None = None + self._retrieval_client: KnowledgeBaseRetrievalClient | None = None + if mode == "agentic": + self._index_client = SearchIndexClient( + endpoint=self.endpoint, + credential=self.credential, + user_agent=AGENT_FRAMEWORK_USER_AGENT, + ) + + self._knowledge_base_initialized = False + + async def __aenter__(self) -> Self: + """Async context manager entry.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: Any, + ) -> None: + """Async context manager exit - cleanup clients.""" + if self._retrieval_client is not None: + await self._retrieval_client.close() + self._retrieval_client = None + + # -- Hooks pattern --------------------------------------------------------- + + async def before_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Retrieve relevant context from Azure AI Search and add to session context.""" + messages_list = list(context.input_messages) + + def get_role_value(role: str | Any) -> str: + return role.value if hasattr(role, "value") else str(role) + + filtered_messages = [ + msg + for msg in messages_list + if msg and msg.text and msg.text.strip() and get_role_value(msg.role) in ["user", "assistant"] + ] + if not filtered_messages: + return + + if self.mode == "semantic": + query = "\n".join(msg.text for msg in filtered_messages) + search_result_parts = await self._semantic_search(query) + else: + recent_messages = filtered_messages[-self.agentic_message_history_count :] + search_result_parts = await self._agentic_search(recent_messages) + + if not search_result_parts: + return + + context_messages = [Message(role="user", text=self.context_prompt)] + context_messages.extend([Message(role="user", text=part) for part in search_result_parts]) + context.extend_messages(self.source_id, context_messages) + + # -- Internal methods (ported from AzureAISearchContextProvider) ----------- + + def _find_vector_fields(self, index: Any) -> list[str]: + """Find all fields that can store vectors.""" + return [ + field.name + for field in index.fields + if field.vector_search_dimensions is not None and field.vector_search_dimensions > 0 + ] + + def _find_vectorizable_fields(self, index: Any, vector_fields: list[str]) -> list[str]: + """Find vector fields that have auto-vectorization configured.""" + vectorizable_fields: list[str] = [] + if not index.vector_search or not index.vector_search.profiles: + return vectorizable_fields + for field in index.fields: + if field.name in vector_fields and field.vector_search_profile_name: + profile = next( + (p for p in index.vector_search.profiles if p.name == field.vector_search_profile_name), None + ) + if profile and hasattr(profile, "vectorizer_name") and profile.vectorizer_name: + vectorizable_fields.append(field.name) + return vectorizable_fields + + async def _auto_discover_vector_field(self) -> None: + """Auto-discover vector field from index schema.""" + if self._auto_discovered_vector_field or self.vector_field_name: + return + + try: + if not self._index_client: + self._index_client = SearchIndexClient( + endpoint=self.endpoint, + credential=self.credential, + user_agent=AGENT_FRAMEWORK_USER_AGENT, + ) + if not self.index_name: + logger.warning("Cannot auto-discover vector field: index_name is not set.") + self._auto_discovered_vector_field = True + return + + index = await self._index_client.get_index(self.index_name) + vector_fields = self._find_vector_fields(index) + if not vector_fields: + logger.info(f"No vector fields found in index '{self.index_name}'. Using keyword-only search.") + self._auto_discovered_vector_field = True + return + + vectorizable_fields = self._find_vectorizable_fields(index, vector_fields) + if vectorizable_fields: + if len(vectorizable_fields) == 1: + self.vector_field_name = vectorizable_fields[0] + self._auto_discovered_vector_field = True + self._use_vectorizable_query = True + logger.info( + f"Auto-discovered vectorizable field '{self.vector_field_name}' with server-side vectorization." + ) + else: + logger.warning( + f"Multiple vectorizable fields found: {vectorizable_fields}. " + f"Please specify vector_field_name explicitly." + ) + elif len(vector_fields) == 1: + self.vector_field_name = vector_fields[0] + self._auto_discovered_vector_field = True + self._use_vectorizable_query = False + if not self.embedding_function: + logger.warning( + f"Auto-discovered vector field '{self.vector_field_name}' without server-side vectorization. " + f"Provide embedding_function for vector search." + ) + self.vector_field_name = None + else: + logger.warning( + f"Multiple vector fields found: {vector_fields}. Please specify vector_field_name explicitly." + ) + except Exception as e: + logger.warning(f"Failed to auto-discover vector field: {e}. Using keyword-only search.") + + self._auto_discovered_vector_field = True + + async def _semantic_search(self, query: str) -> list[str]: + """Perform semantic hybrid search.""" + await self._auto_discover_vector_field() + + vector_queries: list[VectorizableTextQuery | VectorizedQuery] = [] + if self.vector_field_name: + vector_k = max(self.top_k, 50) if self.semantic_configuration_name else self.top_k + if self._use_vectorizable_query: + vector_queries = [ + VectorizableTextQuery(text=query, k_nearest_neighbors=vector_k, fields=self.vector_field_name) + ] + elif self.embedding_function: + query_vector = await self.embedding_function(query) + vector_queries = [ + VectorizedQuery(vector=query_vector, k_nearest_neighbors=vector_k, fields=self.vector_field_name) + ] + + search_params: dict[str, Any] = {"search_text": query, "top": self.top_k} + if vector_queries: + search_params["vector_queries"] = vector_queries + if self.semantic_configuration_name: + search_params["query_type"] = QueryType.SEMANTIC + search_params["semantic_configuration_name"] = self.semantic_configuration_name + search_params["query_caption"] = QueryCaptionType.EXTRACTIVE + + if not self._search_client: + raise RuntimeError("Search client is not initialized.") + results = await self._search_client.search(**search_params) # type: ignore[reportUnknownVariableType] + + formatted_results: list[str] = [] + async for doc in results: # type: ignore[reportUnknownVariableType] + doc_id = doc.get("id") or doc.get("@search.id") # type: ignore[reportUnknownVariableType] + doc_text: str = self._extract_document_text(doc, doc_id=doc_id) # type: ignore[reportUnknownArgumentType] + if doc_text: + formatted_results.append(doc_text) # type: ignore[reportUnknownArgumentType] + return formatted_results + + async def _ensure_knowledge_base(self) -> None: + """Ensure Knowledge Base and knowledge source are created or use existing KB.""" + if self._knowledge_base_initialized: + return + + if not self.knowledge_base_name: + raise ValueError("knowledge_base_name is required for agentic mode") + + knowledge_base_name = self.knowledge_base_name + + if self._use_existing_knowledge_base: + if _agentic_retrieval_available and self._retrieval_client is None: + self._retrieval_client = KnowledgeBaseRetrievalClient( + endpoint=self.endpoint, + knowledge_base_name=knowledge_base_name, + credential=self.credential, + user_agent=AGENT_FRAMEWORK_USER_AGENT, + ) + self._knowledge_base_initialized = True + return + + if not self._index_client: + raise ValueError("Index client is required when creating Knowledge Base from index") + if not self.azure_openai_resource_url: + raise ValueError("azure_openai_resource_url is required when creating Knowledge Base from index") + if not self.azure_openai_deployment_name: + raise ValueError("model_deployment_name is required when creating Knowledge Base from index") + if not self.index_name: + raise ValueError("index_name is required when creating Knowledge Base from index") + + knowledge_source_name = f"{self.index_name}-source" + try: + await self._index_client.get_knowledge_source(knowledge_source_name) + except ResourceNotFoundError: + knowledge_source = SearchIndexKnowledgeSource( + name=knowledge_source_name, + description=f"Knowledge source for {self.index_name} search index", + search_index_parameters=SearchIndexKnowledgeSourceParameters( + search_index_name=self.index_name, + ), + ) + await self._index_client.create_knowledge_source(knowledge_source) + + aoai_params = AzureOpenAIVectorizerParameters( + resource_url=self.azure_openai_resource_url, + deployment_name=self.azure_openai_deployment_name, + model_name=self.model_name, + api_key=self.azure_openai_api_key, + ) + + output_mode = ( + KnowledgeRetrievalOutputMode.EXTRACTIVE_DATA + if self.knowledge_base_output_mode == "extractive_data" + else KnowledgeRetrievalOutputMode.ANSWER_SYNTHESIS + ) + reasoning_effort_map: dict[str, KnowledgeRetrievalReasoningEffort] = { + "minimal": KnowledgeRetrievalMinimalReasoningEffort(), + "medium": KnowledgeRetrievalMediumReasoningEffort(), + "low": KnowledgeRetrievalLowReasoningEffort(), + } + reasoning_effort = reasoning_effort_map[self.retrieval_reasoning_effort] + + knowledge_base = KnowledgeBase( + name=knowledge_base_name, + description=f"Knowledge Base for multi-hop retrieval across {self.index_name}", + knowledge_sources=[KnowledgeSourceReference(name=knowledge_source_name)], + models=[KnowledgeBaseAzureOpenAIModel(azure_open_ai_parameters=aoai_params)], + output_mode=output_mode, + retrieval_reasoning_effort=reasoning_effort, + ) + await self._index_client.create_or_update_knowledge_base(knowledge_base) + self._knowledge_base_initialized = True + + if _agentic_retrieval_available and self._retrieval_client is None: + self._retrieval_client = KnowledgeBaseRetrievalClient( + endpoint=self.endpoint, + knowledge_base_name=knowledge_base_name, + credential=self.credential, + user_agent=AGENT_FRAMEWORK_USER_AGENT, + ) + + async def _agentic_search(self, messages: list[Message]) -> list[str]: + """Perform agentic retrieval with multi-hop reasoning.""" + await self._ensure_knowledge_base() + + reasoning_effort_map: dict[str, KBRetrievalReasoningEffort] = { + "minimal": KBRetrievalMinimalReasoningEffort(), + "medium": KBRetrievalMediumReasoningEffort(), + "low": KBRetrievalLowReasoningEffort(), + } + reasoning_effort = reasoning_effort_map[self.retrieval_reasoning_effort] + + output_mode = ( + KBRetrievalOutputMode.EXTRACTIVE_DATA + if self.knowledge_base_output_mode == "extractive_data" + else KBRetrievalOutputMode.ANSWER_SYNTHESIS + ) + + if self.retrieval_reasoning_effort == "minimal": + query = "\n".join(msg.text for msg in messages if msg.text) + intents: list[KnowledgeRetrievalIntent] = [KnowledgeRetrievalSemanticIntent(search=query)] + retrieval_request = KnowledgeBaseRetrievalRequest( + intents=intents, + retrieval_reasoning_effort=reasoning_effort, + output_mode=output_mode, + include_activity=True, + ) + else: + kb_messages = [ + KnowledgeBaseMessage( + role=msg.role if hasattr(msg.role, "value") else str(msg.role), + content=[KnowledgeBaseMessageTextContent(text=msg.text)], + ) + for msg in messages + if msg.text + ] + retrieval_request = KnowledgeBaseRetrievalRequest( + messages=kb_messages, + retrieval_reasoning_effort=reasoning_effort, + output_mode=output_mode, + include_activity=True, + ) + + if not self._retrieval_client: + raise RuntimeError("Retrieval client not initialized.") + retrieval_result = await self._retrieval_client.retrieve(retrieval_request=retrieval_request) + + if retrieval_result.response and len(retrieval_result.response) > 0: + assistant_message = retrieval_result.response[-1] + if assistant_message.content: + answer_parts: list[str] = [] + for content_item in assistant_message.content: + if isinstance(content_item, KnowledgeBaseMessageTextContent) and content_item.text: + answer_parts.append(content_item.text) + if answer_parts: + return answer_parts + + return ["No results found from Knowledge Base."] + + def _extract_document_text(self, doc: dict[str, Any], doc_id: str | None = None) -> str: + """Extract readable text from a search document with optional citation.""" + text = "" + for field in ["content", "text", "description", "body", "chunk"]: + if doc.get(field): + text = str(doc[field]) + break + if not text: + text_parts: list[str] = [] + for key, value in doc.items(): + if isinstance(value, str) and not key.startswith("@") and key != "id": + text_parts.append(f"{key}: {value}") + text = " | ".join(text_parts) if text_parts else "" + if doc_id and text: + return f"[Source: {doc_id}] {text}" + return text + + +__all__ = ["AzureAISearchContextProvider"] 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 deleted file mode 100644 index 734d6c08e7..0000000000 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py +++ /dev/null @@ -1,995 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - - -from __future__ import annotations - -import sys -from collections.abc import Awaitable, Callable, MutableSequence -from typing import TYPE_CHECKING, Any, ClassVar, Literal - -from agent_framework import AGENT_FRAMEWORK_USER_AGENT, ChatMessage, Context, ContextProvider -from agent_framework._logging import get_logger -from agent_framework._pydantic import AFBaseSettings -from agent_framework.exceptions import ServiceInitializationError -from azure.core.credentials import AzureKeyCredential -from azure.core.credentials_async import AsyncTokenCredential -from azure.core.exceptions import ResourceNotFoundError -from azure.search.documents.aio import SearchClient -from azure.search.documents.indexes.aio import SearchIndexClient -from azure.search.documents.indexes.models import ( - AzureOpenAIVectorizerParameters, - KnowledgeBase, - KnowledgeBaseAzureOpenAIModel, - KnowledgeRetrievalLowReasoningEffort, - KnowledgeRetrievalMediumReasoningEffort, - KnowledgeRetrievalMinimalReasoningEffort, - KnowledgeRetrievalOutputMode, - KnowledgeRetrievalReasoningEffort, - KnowledgeSourceReference, - SearchIndexKnowledgeSource, - SearchIndexKnowledgeSourceParameters, -) -from azure.search.documents.models import ( - QueryCaptionType, - QueryType, - VectorizableTextQuery, - VectorizedQuery, -) -from pydantic import SecretStr, ValidationError - -# Type checking imports for optional agentic mode dependencies -if TYPE_CHECKING: - from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient - from azure.search.documents.knowledgebases.models import ( - KnowledgeBaseMessage, - KnowledgeBaseMessageTextContent, - KnowledgeBaseRetrievalRequest, - KnowledgeRetrievalIntent, - KnowledgeRetrievalSemanticIntent, - ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalLowReasoningEffort as KBRetrievalLowReasoningEffort, - ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalMediumReasoningEffort as KBRetrievalMediumReasoningEffort, - ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalMinimalReasoningEffort as KBRetrievalMinimalReasoningEffort, - ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalOutputMode as KBRetrievalOutputMode, - ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalReasoningEffort as KBRetrievalReasoningEffort, - ) - -# Runtime imports for agentic mode (optional dependency) -try: - from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient - from azure.search.documents.knowledgebases.models import ( - KnowledgeBaseMessage, - KnowledgeBaseMessageTextContent, - KnowledgeBaseRetrievalRequest, - KnowledgeRetrievalIntent, - KnowledgeRetrievalSemanticIntent, - ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalLowReasoningEffort as KBRetrievalLowReasoningEffort, - ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalMediumReasoningEffort as KBRetrievalMediumReasoningEffort, - ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalMinimalReasoningEffort as KBRetrievalMinimalReasoningEffort, - ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalOutputMode as KBRetrievalOutputMode, - ) - from azure.search.documents.knowledgebases.models import ( - KnowledgeRetrievalReasoningEffort as KBRetrievalReasoningEffort, - ) - - _agentic_retrieval_available = True -except ImportError: - _agentic_retrieval_available = False - -if sys.version_info >= (3, 12): - from typing import override # type: ignore # pragma: no cover -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 -else: - from typing_extensions import Self # pragma: no cover - -"""Azure AI Search Context Provider for Agent Framework. - -This module provides context providers for Azure AI Search integration with two modes: -- Agentic: Recommended for most scenarios. Uses Knowledge Bases for query planning and - multi-hop reasoning. Slightly slower with more token consumption, but more accurate. -- Semantic: Fast hybrid search (vector + keyword) with semantic ranker. Best for simple - queries where speed is critical. - -See: https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/foundry-iq-boost-response-relevance-by-36-with-agentic-retrieval/4470720 -""" - - -# Module-level constants -logger = get_logger("agent_framework.azure") -_DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT = 10 - - -class AzureAISearchSettings(AFBaseSettings): - """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_'. - If the environment variables are not found, the settings can be loaded from a .env file. - - Keyword Args: - endpoint: Azure AI Search endpoint URL. - Can be set via environment variable AZURE_SEARCH_ENDPOINT. - index_name: Name of the search index. - Can be set via environment variable AZURE_SEARCH_INDEX_NAME. - knowledge_base_name: Name of an existing Knowledge Base (for agentic mode). - Can be set via environment variable AZURE_SEARCH_KNOWLEDGE_BASE_NAME. - api_key: API key for authentication (optional, use managed identity if not provided). - Can be set via environment variable AZURE_SEARCH_API_KEY. - 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_aisearch import AzureAISearchSettings - - # Using environment variables - # Set AZURE_SEARCH_ENDPOINT=https://mysearch.search.windows.net - # Set AZURE_SEARCH_INDEX_NAME=my-index - settings = AzureAISearchSettings() - - # Or passing parameters directly - settings = AzureAISearchSettings( - endpoint="https://mysearch.search.windows.net", - index_name="my-index", - ) - - # Or loading from a .env file - 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 - - -class AzureAISearchContextProvider(ContextProvider): - """Azure AI Search Context Provider with hybrid search and semantic ranking. - - This provider retrieves relevant documents from Azure AI Search to provide context - to the AI agent. It supports two modes: - - - **agentic**: Recommended for most scenarios. Uses Knowledge Bases for query planning - and multi-hop reasoning. Slightly slower with more token consumption, but provides - more accurate results (up to 36% improvement in response relevance). - - **semantic** (default): Fast hybrid search combining vector and keyword search - with semantic reranking. Best for simple queries where speed is critical. - - Examples: - Using environment variables (recommended): - - .. code-block:: python - - from agent_framework_aisearch import AzureAISearchContextProvider - from azure.identity.aio import DefaultAzureCredential - - # Set AZURE_SEARCH_ENDPOINT and AZURE_SEARCH_INDEX_NAME in environment - search_provider = AzureAISearchContextProvider(credential=DefaultAzureCredential()) - - Semantic hybrid search with API key: - - .. code-block:: python - - # Direct API key string - search_provider = AzureAISearchContextProvider( - endpoint="https://mysearch.search.windows.net", - index_name="my-index", - api_key="my-api-key", - mode="semantic", - ) - - Loading from .env file: - - .. code-block:: python - - # Load settings from a .env file - search_provider = AzureAISearchContextProvider( - credential=DefaultAzureCredential(), env_file_path="path/to/.env" - ) - - Agentic retrieval for complex queries: - - .. code-block:: python - - # Use agentic mode for multi-hop reasoning - # Note: azure_openai_resource_url is the OpenAI endpoint for Knowledge Base model calls, - # which is different from azure_ai_project_endpoint (the AI Foundry project endpoint) - search_provider = AzureAISearchContextProvider( - endpoint="https://mysearch.search.windows.net", - index_name="my-index", - credential=DefaultAzureCredential(), - mode="agentic", - azure_openai_resource_url="https://myresource.openai.azure.com", - model_deployment_name="gpt-4o", - knowledge_base_name="my-knowledge-base", - ) - """ - - _DEFAULT_SEARCH_CONTEXT_PROMPT = "Use the following context to answer the question:" - - def __init__( - self, - endpoint: str | None = None, - index_name: str | None = None, - api_key: str | AzureKeyCredential | None = None, - credential: AsyncTokenCredential | None = None, - *, - mode: Literal["semantic", "agentic"] = "semantic", - top_k: int = 5, - semantic_configuration_name: str | None = None, - vector_field_name: str | None = None, - embedding_function: Callable[[str], Awaitable[list[float]]] | None = None, - context_prompt: str | None = None, - # Agentic mode parameters (Knowledge Base) - azure_openai_resource_url: str | None = None, - model_deployment_name: str | None = None, - model_name: str | None = None, - knowledge_base_name: str | None = None, - retrieval_instructions: str | None = None, - azure_openai_api_key: str | None = None, - knowledge_base_output_mode: Literal["extractive_data", "answer_synthesis"] = "extractive_data", - retrieval_reasoning_effort: Literal["minimal", "medium", "low"] = "minimal", - agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize Azure AI Search Context Provider. - - Args: - endpoint: Azure AI Search endpoint URL. - Can also be set via environment variable AZURE_SEARCH_ENDPOINT. - index_name: Name of the search index to query. - Can also be set via environment variable AZURE_SEARCH_INDEX_NAME. - api_key: API key for authentication (string or AzureKeyCredential). - Can also be set via environment variable AZURE_SEARCH_API_KEY. - credential: AsyncTokenCredential for managed identity authentication. - Use this for Entra ID authentication instead of api_key. - mode: Search mode - "semantic" for hybrid search with semantic ranking (fast) - or "agentic" for multi-hop reasoning (slower). Default: "semantic". - top_k: Maximum number of documents to retrieve. Only applies to semantic mode. - In agentic mode, the server-side Knowledge Base determines retrieval based on - query complexity and reasoning effort. Default: 5. - semantic_configuration_name: Name of semantic configuration in the index. - Required for semantic ranking. If None, uses index default. - vector_field_name: Name of the vector field in the index for hybrid search. - Required if using vector search. Default: None (keyword search only). - embedding_function: Async function to generate embeddings for vector search. - Signature: async def embed(text: str) -> list[float] - Required if vector_field_name is specified and no server-side vectorization. - context_prompt: Custom prompt to prepend to retrieved context. - Default: "Use the following context to answer the question:" - azure_openai_resource_url: Azure OpenAI resource URL for Knowledge Base model calls. - Required when using agentic mode with index_name (to auto-create Knowledge Base). - Not required when using an existing knowledge_base_name. - Example: "https://myresource.openai.azure.com" - model_deployment_name: Model deployment name in Azure OpenAI for Knowledge Base. - Required when using agentic mode with index_name (to auto-create Knowledge Base). - Not required when using an existing knowledge_base_name. - model_name: The underlying model name (e.g., "gpt-4o", "gpt-4o-mini"). - If not provided, defaults to model_deployment_name. Used for Knowledge Base configuration. - knowledge_base_name: Name of an existing Knowledge Base to use. - Required for agentic mode if not providing index_name. - Supports KBs with any source type (web, blob, index, etc.). - retrieval_instructions: Custom instructions for the Knowledge Base's - retrieval planning. Only used in agentic mode. - azure_openai_api_key: Azure OpenAI API key for Knowledge Base to call the model. - Only needed when using API key authentication instead of managed identity. - knowledge_base_output_mode: Output mode for Knowledge Base retrieval. Only used in agentic mode. - "extractive_data": Returns raw chunks without synthesis (default, recommended for agent integration). - "answer_synthesis": Returns synthesized answer from the LLM. - Some knowledge sources require answer_synthesis mode. Default: "extractive_data". - retrieval_reasoning_effort: Reasoning effort for Knowledge Base query planning. Only used in agentic mode. - "minimal": Fastest, basic query planning. - "medium": Moderate reasoning with some query decomposition. - "low": Lower reasoning effort than medium. - Default: "minimal". - agentic_message_history_count: Number of recent messages from conversation history to send to - the Knowledge Base. This context helps with query planning in agentic mode, allowing the - Knowledge Base to understand the conversation flow and generate better retrieval queries. - There is no technical limit - adjust based on your use case. Default: 10. - env_file_path: Path to environment file for loading settings. - env_file_encoding: Encoding of the environment file. - - Examples: - .. code-block:: python - - from agent_framework_aisearch import AzureAISearchContextProvider - from azure.identity.aio import DefaultAzureCredential - - # Using environment variables - # Set AZURE_SEARCH_ENDPOINT=https://mysearch.search.windows.net - # Set AZURE_SEARCH_INDEX_NAME=my-index - credential = DefaultAzureCredential() - provider = AzureAISearchContextProvider(credential=credential) - - # Or passing parameters directly - provider = AzureAISearchContextProvider( - endpoint="https://mysearch.search.windows.net", - index_name="my-index", - credential=credential, - ) - - # Or loading from a .env file - 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 - - # Validate required parameters - if not settings.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) - if mode == "semantic": - # Semantic mode: always requires index_name - if not settings.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: - 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: - raise ServiceInitializationError( - "For agentic mode, provide either 'index_name' (to auto-create Knowledge Base) " - "or 'knowledge_base_name' (to use existing Knowledge Base). " - "Set via parameters or environment variables " - "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: - 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." - ) - - # Determine the credential to use - resolved_credential: AzureKeyCredential | AsyncTokenCredential - if credential: - # AsyncTokenCredential takes precedence - 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()) - 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.credential = resolved_credential - self.mode = mode - self.top_k = top_k - self.semantic_configuration_name = semantic_configuration_name - self.vector_field_name = vector_field_name - self.embedding_function = embedding_function - self.context_prompt = context_prompt or self._DEFAULT_SEARCH_CONTEXT_PROMPT - - # Agentic mode parameters (Knowledge Base) - self.azure_openai_resource_url = azure_openai_resource_url - self.azure_openai_deployment_name = model_deployment_name - # 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.retrieval_instructions = retrieval_instructions - self.azure_openai_api_key = azure_openai_api_key - self.knowledge_base_output_mode = knowledge_base_output_mode - self.retrieval_reasoning_effort = retrieval_reasoning_effort - self.agentic_message_history_count = agentic_message_history_count - - # Determine if using existing Knowledge Base or auto-creating from index - # Since validation ensures exactly one of index_name/knowledge_base_name for agentic mode: - # - knowledge_base_name provided: use existing KB - # - index_name provided: auto-create KB from index - self._use_existing_knowledge_base = False - if mode == "agentic": - if settings.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" - - # Auto-discover vector field if not specified - self._auto_discovered_vector_field = False - self._use_vectorizable_query = False # Will be set to True if server-side vectorization detected - if not vector_field_name and mode == "semantic": - # Attempt to auto-discover vector field from index schema - # This will be done lazily on first search to avoid blocking initialization - pass - - # Validation - if vector_field_name and not embedding_function: - raise ValueError("embedding_function is required when vector_field_name is specified") - - if mode == "agentic": - if not _agentic_retrieval_available: - raise ImportError( - "Agentic retrieval requires azure-search-documents >= 11.7.0b1 with Knowledge Base support. " - "Please upgrade: pip install azure-search-documents>=11.7.0b1" - ) - # Only require OpenAI resource URL if NOT using existing KB - # (existing KB already has its model configuration) - # Note: model_deployment_name is already validated at initialization - if not self._use_existing_knowledge_base and not self.azure_openai_resource_url: - raise ValueError( - "azure_openai_resource_url is required for agentic mode when creating Knowledge Base from index. " - "This should be your Azure OpenAI endpoint (e.g., 'https://myresource.openai.azure.com')" - ) - - # Create search client for semantic mode (only if index_name is available) - self._search_client: SearchClient | None = None - if self.index_name: - self._search_client = SearchClient( - endpoint=self.endpoint, - index_name=self.index_name, - credential=self.credential, - user_agent=AGENT_FRAMEWORK_USER_AGENT, - ) - - # Create index client and retrieval client for agentic mode (Knowledge Base) - self._index_client: SearchIndexClient | None = None - self._retrieval_client: KnowledgeBaseRetrievalClient | None = None - if mode == "agentic": - self._index_client = SearchIndexClient( - endpoint=self.endpoint, - credential=self.credential, - user_agent=AGENT_FRAMEWORK_USER_AGENT, - ) - # Retrieval client will be created after Knowledge Base initialization - - self._knowledge_base_initialized = False - - async def __aenter__(self) -> Self: - """Async context manager entry.""" - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: Any, - ) -> None: - """Async context manager exit - cleanup clients. - - Args: - exc_type: Exception type if an error occurred. - exc_val: Exception value if an error occurred. - exc_tb: Exception traceback if an error occurred. - """ - # Close retrieval client if it was created - if self._retrieval_client is not None: - await self._retrieval_client.close() - self._retrieval_client = None - - @override - async def invoking( - self, - messages: ChatMessage | MutableSequence[ChatMessage], - **kwargs: Any, - ) -> Context: - """Retrieve relevant context from Azure AI Search before model invocation. - - Args: - messages: User messages to use for context retrieval. - **kwargs: Additional arguments (unused). - - Returns: - Context object with retrieved documents as messages. - """ - # Convert to list and filter to USER/ASSISTANT messages with text only - messages_list = [messages] if isinstance(messages, ChatMessage) else list(messages) - - def get_role_value(role: str | Any) -> str: - return role.value if hasattr(role, "value") else str(role) - - filtered_messages = [ - msg - for msg in messages_list - if msg and msg.text and msg.text.strip() and get_role_value(msg.role) in ["user", "assistant"] - ] - - if not filtered_messages: - return Context() - - # Perform search based on mode - if self.mode == "semantic": - # Semantic mode: flatten messages to single query - query = "\n".join(msg.text for msg in filtered_messages) - search_result_parts = await self._semantic_search(query) - else: # agentic - # Agentic mode: pass recent messages as conversation history - recent_messages = filtered_messages[-self.agentic_message_history_count :] - search_result_parts = await self._agentic_search(recent_messages) - - # Format results as context - return multiple messages for each result part - if not search_result_parts: - return Context() - - # Create context messages: first message with prompt, then one message per result part - context_messages = [ChatMessage(role="user", text=self.context_prompt)] - context_messages.extend([ChatMessage(role="user", text=part) for part in search_result_parts]) - - return Context(messages=context_messages) - - def _find_vector_fields(self, index: Any) -> list[str]: - """Find all fields that can store vectors (have dimensions defined). - - Args: - index: SearchIndex object from Azure Search. - - Returns: - List of vector field names. - """ - return [ - field.name - for field in index.fields - if field.vector_search_dimensions is not None and field.vector_search_dimensions > 0 - ] - - def _find_vectorizable_fields(self, index: Any, vector_fields: list[str]) -> list[str]: - """Find vector fields that have auto-vectorization configured. - - These are fields that have a vectorizer in their profile, meaning the index - can automatically vectorize text queries without needing a client-side embedding function. - - Args: - index: SearchIndex object from Azure Search. - vector_fields: List of vector field names. - - Returns: - List of vectorizable field names (subset of vector_fields). - """ - vectorizable_fields: list[str] = [] - - # Check if index has vector search configuration - if not index.vector_search or not index.vector_search.profiles: - return vectorizable_fields - - # For each vector field, check if it has a vectorizer configured - for field in index.fields: - if field.name in vector_fields and field.vector_search_profile_name: - # Find the profile for this field - profile = next( - (p for p in index.vector_search.profiles if p.name == field.vector_search_profile_name), None - ) - - if profile and hasattr(profile, "vectorizer_name") and profile.vectorizer_name: - # This field has server-side vectorization configured - vectorizable_fields.append(field.name) - - return vectorizable_fields - - async def _auto_discover_vector_field(self) -> None: - """Auto-discover vector field from index schema. - - Attempts to find vector fields in the index and detect which have server-side - vectorization configured. Prioritizes vectorizable fields (which can auto-embed text) - over regular vector fields (which require client-side embedding). - """ - if self._auto_discovered_vector_field or self.vector_field_name: - return # Already discovered or manually specified - - try: - # Use existing index client or create temporary one - if not self._index_client: - self._index_client = SearchIndexClient( - endpoint=self.endpoint, - credential=self.credential, - user_agent=AGENT_FRAMEWORK_USER_AGENT, - ) - index_client = self._index_client - - # Get index schema (index_name is guaranteed to be set for semantic mode) - if not self.index_name: - logger.warning("Cannot auto-discover vector field: index_name is not set.") - self._auto_discovered_vector_field = True - return - - index = await index_client.get_index(self.index_name) - - # Step 1: Find all vector fields - vector_fields = self._find_vector_fields(index) - - if not vector_fields: - # No vector fields found - keyword search only - logger.info(f"No vector fields found in index '{self.index_name}'. Using keyword-only search.") - self._auto_discovered_vector_field = True - return - - # Step 2: Find which vector fields have server-side vectorization - vectorizable_fields = self._find_vectorizable_fields(index, vector_fields) - - # Step 3: Decide which field to use - if vectorizable_fields: - # Prefer vectorizable fields (server-side embedding) - if len(vectorizable_fields) == 1: - self.vector_field_name = vectorizable_fields[0] - self._auto_discovered_vector_field = True - self._use_vectorizable_query = True # Use VectorizableTextQuery - logger.info( - f"Auto-discovered vectorizable field '{self.vector_field_name}' " - f"with server-side vectorization. No embedding_function needed." - ) - else: - # Multiple vectorizable fields - logger.warning( - f"Multiple vectorizable fields found: {vectorizable_fields}. " - f"Please specify vector_field_name explicitly. Using keyword-only search." - ) - elif len(vector_fields) == 1: - # Single vector field without vectorizer - needs client-side embedding - self.vector_field_name = vector_fields[0] - self._auto_discovered_vector_field = True - self._use_vectorizable_query = False - - if not self.embedding_function: - logger.warning( - f"Auto-discovered vector field '{self.vector_field_name}' without server-side vectorization. " - f"Provide embedding_function for vector search, or it will fall back to keyword-only search." - ) - self.vector_field_name = None - else: - # Multiple vector fields without vectorizers - logger.warning( - f"Multiple vector fields found: {vector_fields}. " - f"Please specify vector_field_name explicitly. Using keyword-only search." - ) - - except Exception as e: - # Log warning but continue with keyword search - logger.warning(f"Failed to auto-discover vector field: {e}. Using keyword-only search.") - - self._auto_discovered_vector_field = True # Mark as attempted - - async def _semantic_search(self, query: str) -> list[str]: - """Perform semantic hybrid search with semantic ranking. - - This is the recommended mode for most use cases. It combines: - - Vector search (if embedding_function provided) - - Keyword search (BM25) - - Semantic reranking (if semantic_configuration_name provided) - - Args: - query: Search query text. - - Returns: - List of formatted search result strings, one per document. - """ - # Auto-discover vector field if not already done - await self._auto_discover_vector_field() - - vector_queries: list[VectorizableTextQuery | VectorizedQuery] = [] - - # Build vector query based on server-side vectorization or client-side embedding - if self.vector_field_name: - # Use larger k for vector query when semantic reranker is enabled for better ranking quality - vector_k = max(self.top_k, 50) if self.semantic_configuration_name else self.top_k - - if self._use_vectorizable_query: - # Server-side vectorization: Index will auto-embed the text query - vector_queries = [ - VectorizableTextQuery( - text=query, - k_nearest_neighbors=vector_k, - fields=self.vector_field_name, - ) - ] - elif self.embedding_function: - # Client-side embedding: We provide the vector - query_vector = await self.embedding_function(query) - vector_queries = [ - VectorizedQuery( - vector=query_vector, - k_nearest_neighbors=vector_k, - fields=self.vector_field_name, - ) - ] - # else: vector_field_name is set but no vectorization available - skip vector search - - # Build search parameters - search_params: dict[str, Any] = { - "search_text": query, - "top": self.top_k, - } - - if vector_queries: - search_params["vector_queries"] = vector_queries - - # Add semantic ranking if configured - if self.semantic_configuration_name: - search_params["query_type"] = QueryType.SEMANTIC - search_params["semantic_configuration_name"] = self.semantic_configuration_name - search_params["query_caption"] = QueryCaptionType.EXTRACTIVE - - # Execute search (search client is guaranteed to exist for semantic mode) - if not self._search_client: - raise RuntimeError("Search client is not initialized. This should not happen in semantic mode.") - - results = await self._search_client.search(**search_params) # type: ignore[reportUnknownVariableType] - - # Format results with citations - formatted_results: list[str] = [] - async for doc in results: # type: ignore[reportUnknownVariableType] - # Extract document ID for citation - doc_id = doc.get("id") or doc.get("@search.id") # type: ignore[reportUnknownVariableType] - - # Use full document chunks with citation - doc_text: str = self._extract_document_text(doc, doc_id=doc_id) # type: ignore[reportUnknownArgumentType] - if doc_text: - formatted_results.append(doc_text) # type: ignore[reportUnknownArgumentType] - - return formatted_results - - async def _ensure_knowledge_base(self) -> None: - """Ensure Knowledge Base and knowledge source are created or use existing KB. - - This method is idempotent - it will only create resources if they don't exist. - - Note: Azure SDK uses KnowledgeAgent classes internally, but the feature - is marketed as "Knowledge Bases" in Azure AI Search. - """ - if self._knowledge_base_initialized: - return - - # Runtime validation - if not self.knowledge_base_name: - raise ValueError("knowledge_base_name is required for agentic mode") - - knowledge_base_name = self.knowledge_base_name - - # Path 1: Use existing Knowledge Base directly (no index needed) - # This supports KB with any source type (web, blob, index, etc.) - if self._use_existing_knowledge_base: - # Just create the retrieval client - KB already exists with its own sources - if _agentic_retrieval_available and self._retrieval_client is None: - self._retrieval_client = KnowledgeBaseRetrievalClient( - endpoint=self.endpoint, - knowledge_base_name=knowledge_base_name, - credential=self.credential, - user_agent=AGENT_FRAMEWORK_USER_AGENT, - ) - self._knowledge_base_initialized = True - return - - # Path 2: Auto-create Knowledge Base from search index - # Requires index_client and OpenAI configuration - if not self._index_client: - raise ValueError("Index client is required when creating Knowledge Base from index") - if not self.azure_openai_resource_url: - raise ValueError("azure_openai_resource_url is required when creating Knowledge Base from index") - if not self.azure_openai_deployment_name: - raise ValueError("model_deployment_name is required when creating Knowledge Base from index") - if not self.index_name: - raise ValueError("index_name is required when creating Knowledge Base from index") - - # Step 1: Create or get knowledge source from index - knowledge_source_name = f"{self.index_name}-source" - - try: - # Try to get existing knowledge source - await self._index_client.get_knowledge_source(knowledge_source_name) - except ResourceNotFoundError: - # Create new knowledge source if it doesn't exist - knowledge_source = SearchIndexKnowledgeSource( - name=knowledge_source_name, - description=f"Knowledge source for {self.index_name} search index", - search_index_parameters=SearchIndexKnowledgeSourceParameters( - search_index_name=self.index_name, - ), - ) - await self._index_client.create_knowledge_source(knowledge_source) - - # Step 2: Create or update Knowledge Base - # Always create/update to ensure configuration is current - aoai_params = AzureOpenAIVectorizerParameters( - resource_url=self.azure_openai_resource_url, - deployment_name=self.azure_openai_deployment_name, - model_name=self.model_name, - api_key=self.azure_openai_api_key, - ) - - # Map output mode string to SDK enum - output_mode = ( - KnowledgeRetrievalOutputMode.EXTRACTIVE_DATA - if self.knowledge_base_output_mode == "extractive_data" - else KnowledgeRetrievalOutputMode.ANSWER_SYNTHESIS - ) - - # Map reasoning effort string to SDK class - reasoning_effort_map: dict[str, KnowledgeRetrievalReasoningEffort] = { - "minimal": KnowledgeRetrievalMinimalReasoningEffort(), - "medium": KnowledgeRetrievalMediumReasoningEffort(), - "low": KnowledgeRetrievalLowReasoningEffort(), - } - reasoning_effort = reasoning_effort_map[self.retrieval_reasoning_effort] - - knowledge_base = KnowledgeBase( - name=knowledge_base_name, - description=f"Knowledge Base for multi-hop retrieval across {self.index_name}", - knowledge_sources=[ - KnowledgeSourceReference( - name=knowledge_source_name, - ) - ], - models=[KnowledgeBaseAzureOpenAIModel(azure_open_ai_parameters=aoai_params)], - output_mode=output_mode, - retrieval_reasoning_effort=reasoning_effort, - ) - await self._index_client.create_or_update_knowledge_base(knowledge_base) - - self._knowledge_base_initialized = True - - # Create retrieval client now that Knowledge Base is initialized - if _agentic_retrieval_available and self._retrieval_client is None: - self._retrieval_client = KnowledgeBaseRetrievalClient( - endpoint=self.endpoint, - knowledge_base_name=knowledge_base_name, - credential=self.credential, - user_agent=AGENT_FRAMEWORK_USER_AGENT, - ) - - async def _agentic_search(self, messages: list[ChatMessage]) -> list[str]: - """Perform agentic retrieval with multi-hop reasoning using Knowledge Bases. - - This mode uses query planning and is slightly slower than semantic search, - but provides more accurate results through intelligent retrieval. - - This method uses Azure AI Search Knowledge Bases which: - 1. Analyze the query and plan sub-queries - 2. Retrieve relevant documents across multiple sources - 3. Perform multi-hop reasoning with an LLM - 4. Synthesize a comprehensive answer with references - - Args: - messages: Conversation history to use for retrieval context. - - Returns: - List of answer parts from the Knowledge Base, one per content item. - """ - # Ensure Knowledge Base is initialized - await self._ensure_knowledge_base() - - # Map reasoning effort string to SDK class (for retrieval requests) - reasoning_effort_map: dict[str, KBRetrievalReasoningEffort] = { - "minimal": KBRetrievalMinimalReasoningEffort(), - "medium": KBRetrievalMediumReasoningEffort(), - "low": KBRetrievalLowReasoningEffort(), - } - reasoning_effort = reasoning_effort_map[self.retrieval_reasoning_effort] - - # Map output mode string to SDK enum (for retrieval requests) - output_mode = ( - KBRetrievalOutputMode.EXTRACTIVE_DATA - if self.knowledge_base_output_mode == "extractive_data" - else KBRetrievalOutputMode.ANSWER_SYNTHESIS - ) - - # For minimal reasoning, use intents API; for medium/low, use messages API - if self.retrieval_reasoning_effort == "minimal": - # Minimal reasoning uses intents with a single search query - query = "\n".join(msg.text for msg in messages if msg.text) - intents: list[KnowledgeRetrievalIntent] = [KnowledgeRetrievalSemanticIntent(search=query)] - retrieval_request = KnowledgeBaseRetrievalRequest( - intents=intents, - retrieval_reasoning_effort=reasoning_effort, - output_mode=output_mode, - include_activity=True, - ) - else: - # Medium/low reasoning uses messages with conversation history - kb_messages = [ - KnowledgeBaseMessage( - role=msg.role if hasattr(msg.role, "value") else str(msg.role), - content=[KnowledgeBaseMessageTextContent(text=msg.text)], - ) - for msg in messages - if msg.text - ] - retrieval_request = KnowledgeBaseRetrievalRequest( - messages=kb_messages, - retrieval_reasoning_effort=reasoning_effort, - output_mode=output_mode, - include_activity=True, - ) - - # Use reusable retrieval client - if not self._retrieval_client: - raise RuntimeError("Retrieval client not initialized. Ensure Knowledge Base is set up correctly.") - - # Perform retrieval via Knowledge Base - retrieval_result = await self._retrieval_client.retrieve(retrieval_request=retrieval_request) - - # Extract answer parts from response - if retrieval_result.response and len(retrieval_result.response) > 0: - # Get the assistant's response (last message) - assistant_message = retrieval_result.response[-1] - if assistant_message.content: - # Extract all text content items as separate parts - answer_parts: list[str] = [] - for content_item in assistant_message.content: - # Check if this is a text content item - if isinstance(content_item, KnowledgeBaseMessageTextContent) and content_item.text: - answer_parts.append(content_item.text) - - if answer_parts: - return answer_parts - - # Fallback if no answer generated - return ["No results found from Knowledge Base."] - - def _extract_document_text(self, doc: dict[str, Any], doc_id: str | None = None) -> str: - """Extract readable text from a search document with optional citation. - - Args: - doc: Search result document. - doc_id: Optional document ID for citation. - - Returns: - Formatted document text with citation if doc_id provided. - """ - # Try common text field names - text = "" - for field in ["content", "text", "description", "body", "chunk"]: - if doc.get(field): - text = str(doc[field]) - break - - # Fallback: concatenate all string fields - if not text: - text_parts: list[str] = [] - for key, value in doc.items(): - if isinstance(value, str) and not key.startswith("@") and key != "id": - text_parts.append(f"{key}: {value}") - text = " | ".join(text_parts) if text_parts else "" - - # Add citation if document ID provided - if doc_id and text: - return f"[Source: {doc_id}] {text}" - return text diff --git a/python/packages/azure-ai-search/pyproject.toml b/python/packages/azure-ai-search/pyproject.toml index cfc7c4786e..b1ec7dcb57 100644 --- a/python/packages/azure-ai-search/pyproject.toml +++ b/python/packages/azure-ai-search/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "azure-search-documents==11.7.0b2", ] diff --git a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py new file mode 100644 index 0000000000..96ed975b54 --- /dev/null +++ b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py @@ -0,0 +1,293 @@ +# Copyright (c) Microsoft. All rights reserved. +# pyright: reportPrivateUsage=false + +import os +from unittest.mock import AsyncMock, patch + +import pytest +from agent_framework import Message +from agent_framework._sessions import AgentSession, SessionContext +from agent_framework.exceptions import ServiceInitializationError, SettingNotFoundError + +from agent_framework_azure_ai_search._context_provider import AzureAISearchContextProvider + +# -- Helpers ------------------------------------------------------------------- + + +class MockSearchResults: + """Async-iterable mock for Azure SearchClient.search() results.""" + + def __init__(self, docs: list[dict]): + self._docs = docs + self._index = 0 + + def __aiter__(self): + return self + + async def __anext__(self): + if self._index >= len(self._docs): + raise StopAsyncIteration + doc = self._docs[self._index] + self._index += 1 + return doc + + +@pytest.fixture +def mock_search_client() -> AsyncMock: + """Create a mock SearchClient that returns one document.""" + client = AsyncMock() + + async def _search(**kwargs): + return MockSearchResults([{"id": "doc1", "content": "test document"}]) + + client.search = AsyncMock(side_effect=_search) + return client + + +@pytest.fixture +def mock_search_client_empty() -> AsyncMock: + """Create a mock SearchClient that returns no results.""" + client = AsyncMock() + + async def _search(**kwargs): + return MockSearchResults([]) + + client.search = AsyncMock(side_effect=_search) + return client + + +def _make_provider(**overrides) -> AzureAISearchContextProvider: + """Create a semantic-mode provider with mocked internals (skips auto-discovery).""" + defaults = { + "source_id": "aisearch", + "endpoint": "https://test.search.windows.net", + "index_name": "test-index", + "api_key": "test-key", + } + defaults.update(overrides) + provider = AzureAISearchContextProvider(**defaults) + provider._auto_discovered_vector_field = True # skip auto-discovery + return provider + + +# -- Initialization: semantic mode --------------------------------------------- + + +class TestInitSemantic: + """Initialization tests for semantic mode.""" + + def test_valid_init(self) -> None: + provider = _make_provider() + assert provider.source_id == "aisearch" + assert provider.endpoint == "https://test.search.windows.net" + assert provider.index_name == "test-index" + assert provider.mode == "semantic" + + def test_source_id_set(self) -> None: + provider = _make_provider(source_id="my-source") + assert provider.source_id == "my-source" + + def test_missing_endpoint_raises(self) -> None: + with patch.dict(os.environ, {}, clear=True), pytest.raises(SettingNotFoundError, match="endpoint"): + AzureAISearchContextProvider( + source_id="s", + endpoint=None, + index_name="idx", + api_key="key", + ) + + def test_missing_index_name_semantic_raises(self) -> None: + with pytest.raises(SettingNotFoundError, match="index_name"): + AzureAISearchContextProvider( + source_id="s", + endpoint="https://test.search.windows.net", + index_name=None, + api_key="key", + ) + + def test_env_variable_fallback(self) -> None: + env = { + "AZURE_SEARCH_ENDPOINT": "https://env.search.windows.net", + "AZURE_SEARCH_INDEX_NAME": "env-index", + "AZURE_SEARCH_API_KEY": "env-key", + } + with patch.dict(os.environ, env, clear=False): + provider = AzureAISearchContextProvider(source_id="env-test") + assert provider.endpoint == "https://env.search.windows.net" + assert provider.index_name == "env-index" + + +# -- Initialization: agentic mode validation ----------------------------------- + + +class TestInitAgenticValidation: + """Initialization validation tests for agentic mode.""" + + def test_both_index_and_kb_raises(self) -> None: + with pytest.raises(SettingNotFoundError, match="multiple were set"): + AzureAISearchContextProvider( + source_id="s", + endpoint="https://test.search.windows.net", + index_name="idx", + knowledge_base_name="kb", + api_key="key", + mode="agentic", + model_deployment_name="deploy", + azure_openai_resource_url="https://aoai.openai.azure.com", + ) + + def test_neither_index_nor_kb_raises(self) -> None: + with pytest.raises(SettingNotFoundError, match="none was set"): + AzureAISearchContextProvider( + source_id="s", + endpoint="https://test.search.windows.net", + api_key="key", + mode="agentic", + ) + + def test_missing_model_deployment_name_raises(self) -> None: + with pytest.raises(ServiceInitializationError, match="model_deployment_name"): + AzureAISearchContextProvider( + source_id="s", + endpoint="https://test.search.windows.net", + index_name="idx", + api_key="key", + mode="agentic", + azure_openai_resource_url="https://aoai.openai.azure.com", + ) + + def test_vector_field_without_embedding_raises(self) -> None: + with pytest.raises(ValueError, match="embedding_function"): + AzureAISearchContextProvider( + source_id="s", + endpoint="https://test.search.windows.net", + index_name="idx", + api_key="key", + vector_field_name="embedding", + ) + + +# -- before_run: semantic mode ------------------------------------------------- + + +class TestBeforeRunSemantic: + """Tests for before_run in semantic mode.""" + + async def test_results_added_to_context(self, mock_search_client: AsyncMock) -> None: + provider = _make_provider() + provider._search_client = mock_search_client + + session = AgentSession(session_id="test-session") + ctx = SessionContext( + input_messages=[Message(role="user", contents=["test query"])], + session_id="s1", + ) + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + mock_search_client.search.assert_awaited_once() + msgs = ctx.context_messages.get("aisearch", []) + assert len(msgs) >= 2 # context_prompt + at least one result + assert msgs[0].text == provider.context_prompt + + async def test_empty_input_no_search(self, mock_search_client: AsyncMock) -> None: + provider = _make_provider() + provider._search_client = mock_search_client + + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[], session_id="s1") + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + mock_search_client.search.assert_not_awaited() + assert ctx.context_messages.get("aisearch") is None + + async def test_no_results_no_messages(self, mock_search_client_empty: AsyncMock) -> None: + provider = _make_provider() + provider._search_client = mock_search_client_empty + + session = AgentSession(session_id="test-session") + ctx = SessionContext( + input_messages=[Message(role="user", contents=["test query"])], + session_id="s1", + ) + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + mock_search_client_empty.search.assert_awaited_once() + assert ctx.context_messages.get("aisearch") is None + + async def test_context_prompt_prepended(self, mock_search_client: AsyncMock) -> None: + custom_prompt = "Custom search context:" + provider = _make_provider(context_prompt=custom_prompt) + provider._search_client = mock_search_client + + session = AgentSession(session_id="test-session") + ctx = SessionContext( + input_messages=[Message(role="user", contents=["test query"])], + session_id="s1", + ) + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + msgs = ctx.context_messages["aisearch"] + assert msgs[0].text == custom_prompt + + +# -- before_run: message filtering --------------------------------------------- + + +class TestBeforeRunFiltering: + """Tests that only user/assistant messages are used for search.""" + + async def test_filters_non_user_assistant(self, mock_search_client: AsyncMock) -> None: + provider = _make_provider() + provider._search_client = mock_search_client + + session = AgentSession(session_id="test-session") + ctx = SessionContext( + input_messages=[ + Message(role="system", contents=["system prompt"]), + Message(role="user", contents=["actual question"]), + ], + session_id="s1", + ) + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + mock_search_client.search.assert_awaited_once() + call_kwargs = mock_search_client.search.call_args[1] + # The search text should contain only the user message, not the system message + assert "actual question" in call_kwargs["search_text"] + assert "system prompt" not in call_kwargs["search_text"] + + async def test_only_system_messages_no_search(self, mock_search_client: AsyncMock) -> None: + provider = _make_provider() + provider._search_client = mock_search_client + + session = AgentSession(session_id="test-session") + ctx = SessionContext( + input_messages=[Message(role="system", contents=["system prompt"])], + session_id="s1", + ) + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + mock_search_client.search.assert_not_awaited() + + +# -- __aexit__ ----------------------------------------------------------------- + + +class TestAexit: + """Tests for async context manager cleanup.""" + + async def test_closes_retrieval_client(self) -> None: + provider = _make_provider() + mock_retrieval = AsyncMock() + provider._retrieval_client = mock_retrieval + + await provider.__aexit__(None, None, None) + + mock_retrieval.close.assert_awaited_once() + assert provider._retrieval_client is None + + async def test_no_retrieval_client_no_error(self) -> None: + provider = _make_provider() + assert provider._retrieval_client is None + + await provider.__aexit__(None, None, None) # should not raise diff --git a/python/packages/azure-ai-search/tests/test_search_provider.py b/python/packages/azure-ai-search/tests/test_search_provider.py deleted file mode 100644 index 4e118df02e..0000000000 --- a/python/packages/azure-ai-search/tests/test_search_provider.py +++ /dev/null @@ -1,1017 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -# pyright: reportPrivateUsage=false - -import os -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from agent_framework import ChatMessage, Context -from agent_framework.azure import AzureAISearchContextProvider, AzureAISearchSettings -from agent_framework.exceptions import ServiceInitializationError -from azure.core.credentials import AzureKeyCredential -from azure.core.exceptions import ResourceNotFoundError - - -@pytest.fixture -def mock_search_client() -> AsyncMock: - """Create a mock SearchClient.""" - mock_client = AsyncMock() - mock_client.search = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock() - return mock_client - - -@pytest.fixture -def mock_index_client() -> AsyncMock: - """Create a mock SearchIndexClient.""" - mock_client = AsyncMock() - mock_client.get_knowledge_source = AsyncMock() - mock_client.create_knowledge_source = AsyncMock() - mock_client.get_agent = AsyncMock() - mock_client.create_agent = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock() - return mock_client - - -@pytest.fixture -def sample_messages() -> list[ChatMessage]: - """Create sample chat messages for testing.""" - return [ - ChatMessage(role="user", text="What is in the documents?"), - ] - - -class TestAzureAISearchSettings: - """Test AzureAISearchSettings configuration.""" - - def test_settings_with_direct_values(self) -> None: - """Test settings with direct values.""" - settings = AzureAISearchSettings( - 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" - - def test_settings_with_env_file_path(self) -> None: - """Test settings with env_file_path parameter.""" - settings = AzureAISearchSettings( - 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" - - def test_provider_uses_settings_from_env(self) -> None: - """Test that provider creates settings internally from env.""" - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - ) - assert provider.endpoint == "https://test.search.windows.net" - assert provider.index_name == "test-index" - - def test_provider_missing_endpoint_raises_error(self) -> None: - """Test that provider raises ServiceInitializationError without endpoint.""" - # Use patch.dict to clear environment and pass env_file_path="" to prevent .env file loading - clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} - with ( - patch.dict(os.environ, clean_env, clear=True), - pytest.raises(ServiceInitializationError, match="endpoint is required"), - ): - AzureAISearchContextProvider( - index_name="test-index", - api_key="test-key", - env_file_path="", # Disable .env file loading - ) - - def test_provider_missing_index_name_raises_error(self) -> None: - """Test that provider raises ServiceInitializationError without index_name.""" - # Use patch.dict to clear environment and pass env_file_path="" to prevent .env file loading - clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} - with ( - patch.dict(os.environ, clean_env, clear=True), - pytest.raises(ServiceInitializationError, match="index name is required"), - ): - AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - api_key="test-key", - env_file_path="", # Disable .env file loading - ) - - def test_provider_missing_credential_raises_error(self) -> None: - """Test that provider raises ServiceInitializationError without credential.""" - # Use patch.dict to clear environment and pass env_file_path="" to prevent .env file loading - clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} - with ( - patch.dict(os.environ, clean_env, clear=True), - pytest.raises(ServiceInitializationError, match="credential is required"), - ): - AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - env_file_path="", # Disable .env file loading - ) - - -class TestSearchProviderInitialization: - """Test initialization and configuration of AzureAISearchContextProvider.""" - - def test_init_semantic_mode_minimal(self) -> None: - """Test initialization with minimal semantic mode parameters.""" - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="semantic", - ) - assert provider.endpoint == "https://test.search.windows.net" - assert provider.index_name == "test-index" - assert provider.mode == "semantic" - assert provider.top_k == 5 - - def test_init_semantic_mode_with_vector_field_requires_embedding_function(self) -> None: - """Test that vector_field_name requires embedding_function.""" - with pytest.raises(ValueError, match="embedding_function is required"): - AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="semantic", - vector_field_name="embedding", - ) - - def test_init_agentic_mode_with_kb_only(self) -> None: - """Test agentic mode with existing knowledge_base_name (simplest path).""" - # Clear environment to ensure no env vars interfere - clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} - with patch.dict(os.environ, clean_env, clear=True): - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - api_key="test-key", - mode="agentic", - knowledge_base_name="test-kb", - env_file_path="", # Disable .env file loading - ) - assert provider.mode == "agentic" - assert provider.knowledge_base_name == "test-kb" - assert provider._use_existing_knowledge_base is True - - def test_init_agentic_mode_with_index_requires_model(self) -> None: - """Test that agentic mode with index_name requires model_deployment_name.""" - # Clear environment to ensure no env vars interfere - clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} - with ( - patch.dict(os.environ, clean_env, clear=True), - pytest.raises(ServiceInitializationError, match="model_deployment_name"), - ): - AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - env_file_path="", # Disable .env file loading - ) - - def test_init_agentic_mode_with_index_and_model(self) -> None: - """Test agentic mode with index_name (auto-create KB path).""" - # Clear environment to ensure no env vars interfere - clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} - with patch.dict(os.environ, clean_env, clear=True): - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - model_deployment_name="gpt-4o", - azure_openai_resource_url="https://test.openai.azure.com", - env_file_path="", # Disable .env file loading - ) - assert provider.mode == "agentic" - assert provider.index_name == "test-index" - assert provider.knowledge_base_name == "test-index-kb" # Auto-generated - assert provider._use_existing_knowledge_base is False - - def test_init_agentic_mode_rejects_both_index_and_kb(self) -> None: - """Test that agentic mode rejects both index_name AND knowledge_base_name.""" - # Clear environment to ensure no env vars interfere - clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} - with ( - patch.dict(os.environ, clean_env, clear=True), - pytest.raises(ServiceInitializationError, match="either 'index_name' OR 'knowledge_base_name', not both"), - ): - AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - knowledge_base_name="test-kb", - model_deployment_name="gpt-4o", - azure_openai_resource_url="https://test.openai.azure.com", - env_file_path="", # Disable .env file loading - ) - - def test_init_agentic_mode_requires_index_or_kb(self) -> None: - """Test that agentic mode requires either index_name or knowledge_base_name.""" - # Clear environment to ensure no env vars interfere - clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} - with ( - patch.dict(os.environ, clean_env, clear=True), - pytest.raises(ServiceInitializationError, match="provide either 'index_name'.*or 'knowledge_base_name'"), - ): - AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - api_key="test-key", - mode="agentic", - env_file_path="", # Disable .env file loading - ) - - def test_init_model_name_defaults_to_deployment_name(self) -> None: - """Test that model_name defaults to deployment_name if not provided.""" - # Clear environment to ensure no env vars interfere - clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} - with patch.dict(os.environ, clean_env, clear=True): - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - api_key="test-key", - mode="agentic", - knowledge_base_name="test-kb", - model_deployment_name="gpt-4o", - env_file_path="", # Disable .env file loading - ) - assert provider.model_name == "gpt-4o" - - def test_init_with_custom_context_prompt(self) -> None: - """Test initialization with custom context prompt.""" - custom_prompt = "Use the following information:" - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="semantic", - context_prompt=custom_prompt, - ) - assert provider.context_prompt == custom_prompt - - def test_init_uses_default_context_prompt(self) -> None: - """Test that default context prompt is used when not provided.""" - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="semantic", - ) - assert provider.context_prompt == provider._DEFAULT_SEARCH_CONTEXT_PROMPT - - -class TestSemanticSearch: - """Test semantic search functionality.""" - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_semantic_search_basic( - self, mock_search_class: MagicMock, sample_messages: list[ChatMessage] - ) -> None: - """Test basic semantic search without vector search.""" - # Setup mock - mock_search_client = AsyncMock() - mock_results = AsyncMock() - mock_results.__aiter__.return_value = iter([{"content": "Test document content"}]) - mock_search_client.search.return_value = mock_results - mock_search_class.return_value = mock_search_client - - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="semantic", - ) - - context = await provider.invoking(sample_messages) - - assert isinstance(context, Context) - assert len(context.messages) > 1 # First message is prompt, rest are results - # First message should be the context prompt - assert "Use the following context" in context.messages[0].text - # Second message should contain the search result - assert "Test document content" in context.messages[1].text - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_semantic_search_empty_query(self, mock_search_class: MagicMock) -> None: - """Test that empty queries return empty context.""" - mock_search_client = AsyncMock() - mock_search_class.return_value = mock_search_client - - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="semantic", - ) - - # Empty message - context = await provider.invoking([ChatMessage(role="user", text="")]) - - assert isinstance(context, Context) - assert len(context.messages) == 0 - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_semantic_search_with_vector_query( - self, mock_search_class: MagicMock, sample_messages: list[ChatMessage] - ) -> None: - """Test semantic search with vector query.""" - # Setup mock - mock_search_client = AsyncMock() - mock_results = AsyncMock() - mock_results.__aiter__.return_value = iter([{"content": "Vector search result"}]) - mock_search_client.search.return_value = mock_results - mock_search_class.return_value = mock_search_client - - # Mock embedding function - async def mock_embed(text: str) -> list[float]: - return [0.1, 0.2, 0.3] - - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="semantic", - vector_field_name="embedding", - embedding_function=mock_embed, - ) - - context = await provider.invoking(sample_messages) - - assert isinstance(context, Context) - assert len(context.messages) > 0 - # Verify that search was called - mock_search_client.search.assert_called_once() - - -class TestKnowledgeBaseSetup: - """Test Knowledge Base setup for agentic mode.""" - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_ensure_knowledge_base_creates_when_not_exists( - self, mock_search_class: MagicMock, mock_index_class: MagicMock - ) -> None: - """Test that Knowledge Base is created when it doesn't exist (index_name path).""" - # Setup mocks - mock_index_client = AsyncMock() - mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found") - mock_index_client.create_knowledge_source = AsyncMock() - mock_index_client.get_knowledge_base.side_effect = ResourceNotFoundError("Not found") - mock_index_client.create_or_update_knowledge_base = AsyncMock() - mock_index_class.return_value = mock_index_client - - mock_search_client = AsyncMock() - mock_search_class.return_value = mock_search_client - - # Clear environment to ensure no env vars interfere - clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} - with patch.dict(os.environ, clean_env, clear=True): - # Use index_name path (auto-create KB) - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="agentic", - model_deployment_name="gpt-4o", - azure_openai_resource_url="https://test.openai.azure.com", - env_file_path="", # Disable .env file loading - ) - - await provider._ensure_knowledge_base() - - # Verify knowledge source was created - mock_index_client.create_knowledge_source.assert_called_once() - # Verify Knowledge Base was created - mock_index_client.create_or_update_knowledge_base.assert_called_once() - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_ensure_knowledge_base_skips_when_using_existing_kb( - self, mock_search_class: MagicMock, mock_index_class: MagicMock - ) -> None: - """Test that KB setup is skipped when using existing knowledge_base_name.""" - # Setup mocks - mock_index_client = AsyncMock() - mock_index_class.return_value = mock_index_client - - mock_search_client = AsyncMock() - mock_search_class.return_value = mock_search_client - - # Clear environment to ensure no env vars interfere - clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} - with patch.dict(os.environ, clean_env, clear=True): - # Use knowledge_base_name path (existing KB) - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - api_key="test-key", - mode="agentic", - knowledge_base_name="test-kb", - env_file_path="", # Disable .env file loading - ) - - await provider._ensure_knowledge_base() - - # Verify nothing was created (using existing KB) - mock_index_client.create_knowledge_source.assert_not_called() - mock_index_client.create_or_update_knowledge_base.assert_not_called() - - -class TestContextProviderLifecycle: - """Test context provider lifecycle methods.""" - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_context_manager(self, mock_search_class: MagicMock) -> None: - """Test that provider can be used as async context manager.""" - mock_search_client = AsyncMock() - mock_search_class.return_value = mock_search_client - - async with AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="semantic", - ) as provider: - assert provider is not None - assert isinstance(provider, AzureAISearchContextProvider) - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.KnowledgeBaseRetrievalClient") - @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_context_manager_agentic_cleanup( - self, mock_search_class: MagicMock, mock_index_class: MagicMock, mock_retrieval_class: MagicMock - ) -> None: - """Test that agentic mode provider cleans up retrieval client.""" - mock_search_client = AsyncMock() - mock_search_class.return_value = mock_search_client - - mock_index_client = AsyncMock() - mock_index_class.return_value = mock_index_client - - mock_retrieval_client = AsyncMock() - mock_retrieval_client.close = AsyncMock() - mock_retrieval_class.return_value = mock_retrieval_client - - # Clear environment to ensure no env vars interfere - clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} - with patch.dict(os.environ, clean_env, clear=True): - # Use knowledge_base_name path (existing KB) - async with AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - api_key="test-key", - mode="agentic", - knowledge_base_name="test-kb", - env_file_path="", # Disable .env file loading - ) as provider: - # Simulate retrieval client being created - provider._retrieval_client = mock_retrieval_client - - # Verify cleanup was called - mock_retrieval_client.close.assert_called_once() - - def test_string_api_key_conversion(self) -> None: - """Test that string api_key is converted to AzureKeyCredential.""" - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="my-api-key", # String api_key - mode="semantic", - ) - assert isinstance(provider.credential, AzureKeyCredential) - - -class TestMessageFiltering: - """Test message filtering functionality.""" - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_filters_non_user_assistant_messages(self, mock_search_class: MagicMock) -> None: - """Test that only USER and ASSISTANT messages are processed.""" - # Setup mock - mock_search_client = AsyncMock() - mock_results = AsyncMock() - mock_results.__aiter__.return_value = iter([{"content": "Test result"}]) - mock_search_client.search.return_value = mock_results - mock_search_class.return_value = mock_search_client - - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="semantic", - ) - - # Mix of message types - messages = [ - ChatMessage(role="system", text="System message"), - ChatMessage(role="user", text="User message"), - ChatMessage(role="assistant", text="Assistant message"), - ChatMessage(role="tool", text="Tool message"), - ] - - context = await provider.invoking(messages) - - # Should have processed only USER and ASSISTANT messages - assert isinstance(context, Context) - mock_search_client.search.assert_called_once() - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_filters_empty_messages(self, mock_search_class: MagicMock) -> None: - """Test that empty/whitespace messages are filtered out.""" - mock_search_client = AsyncMock() - mock_search_class.return_value = mock_search_client - - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="semantic", - ) - - # Messages with empty/whitespace text - messages = [ - ChatMessage(role="user", text=""), - ChatMessage(role="user", text=" "), - ChatMessage(role="user", text=""), # ChatMessage with None text becomes empty string - ] - - context = await provider.invoking(messages) - - # Should return empty context - assert len(context.messages) == 0 - - -class TestCitations: - """Test citation functionality.""" - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_citations_included_in_semantic_search(self, mock_search_class: MagicMock) -> None: - """Test that citations are included in semantic search results.""" - # Setup mock with document ID - mock_search_client = AsyncMock() - mock_results = AsyncMock() - mock_doc = {"id": "doc123", "content": "Test document content"} - mock_results.__aiter__.return_value = iter([mock_doc]) - mock_search_client.search.return_value = mock_results - mock_search_class.return_value = mock_search_client - - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="semantic", - ) - - context = await provider.invoking([ChatMessage(role="user", text="test query")]) - - # Check that citation is included - assert isinstance(context, Context) - assert len(context.messages) > 1 # First message is prompt, rest are results - # Citation should be in the result message (second message) - assert "[Source: doc123]" in context.messages[1].text - assert "Test document content" in context.messages[1].text - - -class TestAgenticSearch: - """Test agentic search functionality.""" - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.KnowledgeBaseRetrievalClient") - @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_agentic_search_basic( - self, - mock_search_class: MagicMock, - mock_index_class: MagicMock, - mock_retrieval_class: MagicMock, - sample_messages: list[ChatMessage], - ) -> None: - """Test basic agentic search with Knowledge Base retrieval.""" - # Setup search client mock - mock_search_client = AsyncMock() - mock_search_class.return_value = mock_search_client - - # Setup index client mock - mock_index_client = AsyncMock() - mock_index_class.return_value = mock_index_client - - # Setup retrieval client mock with response - mock_retrieval_client = AsyncMock() - mock_response = MagicMock() - mock_message = MagicMock() - mock_content = MagicMock() - mock_content.text = "Agentic search result" - # Make it pass isinstance check - from agent_framework_azure_ai_search._search_provider import _agentic_retrieval_available - - if _agentic_retrieval_available: - from azure.search.documents.knowledgebases.models import KnowledgeBaseMessageTextContent - - mock_content.__class__ = KnowledgeBaseMessageTextContent - mock_message.content = [mock_content] - mock_response.response = [mock_message] - mock_retrieval_client.retrieve.return_value = mock_response - mock_retrieval_client.close = AsyncMock() - mock_retrieval_class.return_value = mock_retrieval_client - - # Clear environment to ensure no env vars interfere - clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} - with patch.dict(os.environ, clean_env, clear=True): - # Use knowledge_base_name path (existing KB) - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - api_key="test-key", - mode="agentic", - knowledge_base_name="test-kb", - env_file_path="", # Disable .env file loading - ) - - context = await provider.invoking(sample_messages) - - assert isinstance(context, Context) - # Should have at least the prompt message - assert len(context.messages) >= 1 - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.KnowledgeBaseRetrievalClient") - @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_agentic_search_no_results( - self, - mock_search_class: MagicMock, - mock_index_class: MagicMock, - mock_retrieval_class: MagicMock, - sample_messages: list[ChatMessage], - ) -> None: - """Test agentic search when no results are returned.""" - # Setup mocks - mock_search_client = AsyncMock() - mock_search_class.return_value = mock_search_client - - mock_index_client = AsyncMock() - mock_index_class.return_value = mock_index_client - - # Empty response - mock_retrieval_client = AsyncMock() - mock_response = MagicMock() - mock_response.response = [] - mock_retrieval_client.retrieve.return_value = mock_response - mock_retrieval_client.close = AsyncMock() - mock_retrieval_class.return_value = mock_retrieval_client - - # Clear environment to ensure no env vars interfere - clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} - with patch.dict(os.environ, clean_env, clear=True): - # Use knowledge_base_name path (existing KB) - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - api_key="test-key", - mode="agentic", - knowledge_base_name="test-kb", - env_file_path="", # Disable .env file loading - ) - - context = await provider.invoking(sample_messages) - - assert isinstance(context, Context) - # Should have fallback message - assert len(context.messages) >= 1 - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.KnowledgeBaseRetrievalClient") - @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_agentic_search_with_medium_reasoning( - self, - mock_search_class: MagicMock, - mock_index_class: MagicMock, - mock_retrieval_class: MagicMock, - sample_messages: list[ChatMessage], - ) -> None: - """Test agentic search with medium reasoning effort.""" - # Setup mocks - mock_search_client = AsyncMock() - mock_search_class.return_value = mock_search_client - - mock_index_client = AsyncMock() - mock_index_class.return_value = mock_index_client - - mock_retrieval_client = AsyncMock() - mock_response = MagicMock() - mock_message = MagicMock() - mock_content = MagicMock() - mock_content.text = "Medium reasoning result" - from agent_framework_azure_ai_search._search_provider import _agentic_retrieval_available - - if _agentic_retrieval_available: - from azure.search.documents.knowledgebases.models import KnowledgeBaseMessageTextContent - - mock_content.__class__ = KnowledgeBaseMessageTextContent - mock_message.content = [mock_content] - mock_response.response = [mock_message] - mock_retrieval_client.retrieve.return_value = mock_response - mock_retrieval_client.close = AsyncMock() - mock_retrieval_class.return_value = mock_retrieval_client - - # Clear environment to ensure no env vars interfere - clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")} - with patch.dict(os.environ, clean_env, clear=True): - # Use knowledge_base_name path (existing KB) - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - api_key="test-key", - mode="agentic", - knowledge_base_name="test-kb", - retrieval_reasoning_effort="medium", # Test medium reasoning - env_file_path="", # Disable .env file loading - ) - - context = await provider.invoking(sample_messages) - - assert isinstance(context, Context) - assert len(context.messages) >= 1 - - -class TestVectorFieldAutoDiscovery: - """Test vector field auto-discovery functionality.""" - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_auto_discovers_single_vector_field( - self, mock_search_class: MagicMock, mock_index_class: MagicMock - ) -> None: - """Test that single vector field is auto-discovered.""" - # Setup search client mock - mock_search_client = AsyncMock() - mock_search_class.return_value = mock_search_client - - # Setup index client mock - mock_index_client = AsyncMock() - mock_index = MagicMock() - - # Create mock field with vector_search_dimensions attribute - mock_vector_field = MagicMock() - mock_vector_field.name = "embedding_vector" - mock_vector_field.vector_search_dimensions = 1536 - - mock_index.fields = [mock_vector_field] - mock_index_client.get_index.return_value = mock_index - mock_index_client.close = AsyncMock() - mock_index_class.return_value = mock_index_client - - # Create provider without specifying vector_field_name - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="semantic", - ) - - # Trigger auto-discovery - await provider._auto_discover_vector_field() - - # Vector field should be auto-discovered but not used without embedding function - assert provider._auto_discovered_vector_field is True - # Should be cleared since no embedding function - assert provider.vector_field_name is None - - @pytest.mark.asyncio - async def test_vector_detection_accuracy(self) -> None: - """Test that vector field detection logic correctly identifies vector fields.""" - from azure.search.documents.indexes.models import SearchField - - # Create real SearchField objects to test the detection logic - vector_field = SearchField( - name="embedding_vector", type="Collection(Edm.Single)", vector_search_dimensions=1536, searchable=True - ) - - string_field = SearchField(name="content", type="Edm.String", searchable=True) - - number_field = SearchField(name="price", type="Edm.Double", filterable=True) - - # Test detection logic directly - is_vector_1 = vector_field.vector_search_dimensions is not None and vector_field.vector_search_dimensions > 0 - is_vector_2 = string_field.vector_search_dimensions is not None and string_field.vector_search_dimensions > 0 - is_vector_3 = number_field.vector_search_dimensions is not None and number_field.vector_search_dimensions > 0 - - # Only the vector field should be detected - assert is_vector_1 is True - assert is_vector_2 is False - assert is_vector_3 is False - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_no_false_positives_on_string_fields( - self, mock_search_class: MagicMock, mock_index_class: MagicMock - ) -> None: - """Test that regular string fields are not detected as vector fields.""" - # Setup search client mock - mock_search_client = AsyncMock() - mock_search_class.return_value = mock_search_client - - # Setup index with only string fields (no vectors) - mock_index_client = AsyncMock() - mock_index = MagicMock() - - # All fields have vector_search_dimensions = None - mock_fields = [] - for name in ["id", "title", "content", "category"]: - field = MagicMock() - field.name = name - field.vector_search_dimensions = None - field.vector_search_profile_name = None - mock_fields.append(field) - - mock_index.fields = mock_fields - mock_index_client.get_index.return_value = mock_index - mock_index_client.close = AsyncMock() - mock_index_class.return_value = mock_index_client - - # Create provider - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="semantic", - ) - - # Trigger auto-discovery - await provider._auto_discover_vector_field() - - # Should NOT detect any vector fields - assert provider.vector_field_name is None - assert provider._auto_discovered_vector_field is True - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_multiple_vector_fields_without_vectorizer( - self, mock_search_class: MagicMock, mock_index_class: MagicMock - ) -> None: - """Test that multiple vector fields without vectorizer logs warning and uses keyword search.""" - # Setup search client mock - mock_search_client = AsyncMock() - mock_search_class.return_value = mock_search_client - - # Setup index with multiple vector fields (no vectorizers) - mock_index_client = AsyncMock() - mock_index = MagicMock() - - # Multiple vector fields - mock_fields = [] - for name in ["embedding1", "embedding2"]: - field = MagicMock() - field.name = name - field.vector_search_dimensions = 1536 - field.vector_search_profile_name = None # No vectorizer - mock_fields.append(field) - - mock_index.fields = mock_fields - mock_index.vector_search = None # No vector search config - mock_index_client.get_index.return_value = mock_index - mock_index_client.close = AsyncMock() - mock_index_class.return_value = mock_index_client - - # Create provider - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="semantic", - ) - - # Trigger auto-discovery - await provider._auto_discover_vector_field() - - # Should NOT use any vector field (multiple fields, can't choose) - assert provider.vector_field_name is None - assert provider._auto_discovered_vector_field is True - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_multiple_vectorizable_fields( - self, mock_search_class: MagicMock, mock_index_class: MagicMock - ) -> None: - """Test that multiple vectorizable fields logs warning and uses keyword search.""" - # Setup search client mock - mock_search_client = AsyncMock() - mock_search_class.return_value = mock_search_client - - # Setup index with multiple vectorizable fields - mock_index_client = AsyncMock() - mock_index = MagicMock() - - # Multiple vector fields with vectorizers - mock_fields = [] - for name in ["embedding1", "embedding2"]: - field = MagicMock() - field.name = name - field.vector_search_dimensions = 1536 - field.vector_search_profile_name = f"{name}-profile" - mock_fields.append(field) - - mock_index.fields = mock_fields - - # Setup vector search config with profiles that have vectorizers - mock_profile1 = MagicMock() - mock_profile1.name = "embedding1-profile" - mock_profile1.vectorizer_name = "vectorizer1" - - mock_profile2 = MagicMock() - mock_profile2.name = "embedding2-profile" - mock_profile2.vectorizer_name = "vectorizer2" - - mock_index.vector_search = MagicMock() - mock_index.vector_search.profiles = [mock_profile1, mock_profile2] - - mock_index_client.get_index.return_value = mock_index - mock_index_client.close = AsyncMock() - mock_index_class.return_value = mock_index_client - - # Create provider - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="semantic", - ) - - # Trigger auto-discovery - await provider._auto_discover_vector_field() - - # Should NOT use any vector field (multiple vectorizable fields, can't choose) - assert provider.vector_field_name is None - assert provider._auto_discovered_vector_field is True - - @pytest.mark.asyncio - @patch("agent_framework_azure_ai_search._search_provider.SearchIndexClient") - @patch("agent_framework_azure_ai_search._search_provider.SearchClient") - async def test_single_vectorizable_field_detected( - self, mock_search_class: MagicMock, mock_index_class: MagicMock - ) -> None: - """Test that single vectorizable field is auto-detected for server-side vectorization.""" - # Setup search client mock - mock_search_client = AsyncMock() - mock_search_class.return_value = mock_search_client - - # Setup index with single vectorizable field - mock_index_client = AsyncMock() - mock_index = MagicMock() - - # Single vector field with vectorizer - mock_field = MagicMock() - mock_field.name = "embedding" - mock_field.vector_search_dimensions = 1536 - mock_field.vector_search_profile_name = "embedding-profile" - - mock_index.fields = [mock_field] - - # Setup vector search config with profile that has vectorizer - mock_profile = MagicMock() - mock_profile.name = "embedding-profile" - mock_profile.vectorizer_name = "openai-vectorizer" - - mock_index.vector_search = MagicMock() - mock_index.vector_search.profiles = [mock_profile] - - mock_index_client.get_index.return_value = mock_index - mock_index_client.close = AsyncMock() - mock_index_class.return_value = mock_index_client - - # Create provider - provider = AzureAISearchContextProvider( - endpoint="https://test.search.windows.net", - index_name="test-index", - api_key="test-key", - mode="semantic", - ) - - # Trigger auto-discovery - await provider._auto_discover_vector_field() - - # Should detect the vectorizable field - assert provider.vector_field_name == "embedding" - assert provider._auto_discovered_vector_field is True - assert provider._use_vectorizable_query is True # Server-side vectorization 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 afeb85ec86..f5c5201531 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 @@ -4,30 +4,28 @@ from __future__ import annotations import sys from collections.abc import Callable, MutableMapping, Sequence -from typing import TYPE_CHECKING, Any, Generic, cast +from typing import Any, Generic, cast from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, - ChatAgent, - ContextProvider, + Agent, + BaseContextProvider, FunctionTool, MiddlewareTypes, - ToolProtocol, 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, ResponseFormatJsonSchema, ResponseFormatJsonSchemaType +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 +from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions from ._shared import AzureAISettings, from_azure_ai_agent_tools, to_azure_ai_agent_tools -if TYPE_CHECKING: - from ._chat_client import AzureAIAgentOptions - if sys.version_info >= (3, 13): from typing import Self, TypeVar # type: ignore # pragma: no cover else: @@ -38,20 +36,20 @@ else: from typing_extensions import TypedDict # type: ignore # pragma: no cover -# Type variable for options - allows typed ChatAgent[TOptions] returns +# Type variable for options - allows typed Agent[TOptions] returns # Default matches AzureAIAgentClient's default options type -TOptions_co = TypeVar( - "TOptions_co", +OptionsCoT = TypeVar( + "OptionsCoT", bound=TypedDict, # type: ignore[valid-type] default="AzureAIAgentOptions", covariant=True, ) -class AzureAIAgentsProvider(Generic[TOptions_co]): +class AzureAIAgentsProvider(Generic[OptionsCoT]): """Provider for Azure AI Agent Service V1 (Persistent Agents API). - This provider enables creating, retrieving, and wrapping Azure AI agents as ChatAgent + This provider enables creating, retrieving, and wrapping Azure AI agents as Agent instances. It manages the underlying AgentsClient lifecycle and provides a high-level interface for agent operations. @@ -115,21 +113,21 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): 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." @@ -137,7 +135,7 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): 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, ) @@ -171,19 +169,19 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): model: str | None = None, instructions: str | None = None, description: str | None = None, - tools: ToolProtocol + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: - """Create a new agent on the Azure AI service and return a ChatAgent. + context_providers: Sequence[BaseContextProvider] | None = None, + ) -> Agent[OptionsCoT]: + """Create a new agent on the Azure AI service and return a Agent. This method creates a persistent agent on the Azure AI service with the specified - configuration and returns a local ChatAgent instance for interaction. + configuration and returns a local Agent instance for interaction. Args: name: The name for the agent. @@ -197,10 +195,10 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): default_options: A TypedDict containing default chat options for the agent. These options are applied to every run unless overridden. middleware: List of middleware to intercept agent and function invocations. - context_provider: Context provider to include during agent invocation. + context_providers: Context providers to include during agent invocation. Returns: - ChatAgent: A ChatAgent instance configured with the created agent. + Agent: A Agent instance configured with the created agent. Raises: ServiceInitializationError: If model deployment name is not available. @@ -214,7 +212,7 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): 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 " @@ -240,7 +238,7 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): args["response_format"] = self._create_response_format_config(response_format) # Normalize and convert tools - # Local MCP tools (MCPTool) are handled by ChatAgent at runtime, not stored on the Azure agent + # Local MCP tools (MCPTool) are handled by Agent at runtime, not stored on the Azure agent normalized_tools = normalize_tools(tools) if normalized_tools: # Only convert non-MCP tools to Azure AI format @@ -255,32 +253,32 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): # Create the agent on the service created_agent = await self._agents_client.create_agent(**args) - # Create ChatAgent wrapper + # Create Agent wrapper return self._to_chat_agent_from_agent( created_agent, normalized_tools, default_options=default_options, middleware=middleware, - context_provider=context_provider, + context_providers=context_providers, ) async def get_agent( self, id: str, *, - tools: ToolProtocol + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: - """Retrieve an existing agent from the service and return a ChatAgent. + context_providers: Sequence[BaseContextProvider] | None = None, + ) -> Agent[OptionsCoT]: + """Retrieve an existing agent from the service and return a Agent. This method fetches an agent by ID from the Azure AI service - and returns a local ChatAgent instance for interaction. + and returns a local Agent instance for interaction. Args: id: The ID of the agent to retrieve from the service. @@ -291,10 +289,10 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): default_options: A TypedDict containing default chat options for the agent. These options are applied to every run unless overridden. middleware: List of middleware to intercept agent and function invocations. - context_provider: Context provider to include during agent invocation. + context_providers: Context providers to include during agent invocation. Returns: - ChatAgent: A ChatAgent instance configured with the retrieved agent. + Agent: A Agent instance configured with the retrieved agent. Raises: ServiceInitializationError: If required function tools are not provided. @@ -318,22 +316,22 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): normalized_tools, default_options=default_options, middleware=middleware, - context_provider=context_provider, + context_providers=context_providers, ) def as_agent( self, - agent: Agent, - tools: ToolProtocol + agent: AzureAgent, + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: - """Wrap an existing Agent SDK object as a ChatAgent without making HTTP calls. + context_providers: Sequence[BaseContextProvider] | None = None, + ) -> Agent[OptionsCoT]: + """Wrap an existing Agent SDK object as a Agent without making HTTP calls. Use this method when you already have an Agent object from a previous SDK operation and want to use it with the Agent Framework. @@ -345,10 +343,10 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): default_options: A TypedDict containing default chat options for the agent. These options are applied to every run unless overridden. middleware: List of middleware to intercept agent and function invocations. - context_provider: Context provider to include during agent invocation. + context_providers: Context providers to include during agent invocation. Returns: - ChatAgent: A ChatAgent instance configured with the agent. + Agent: A Agent instance configured with the agent. Raises: ServiceInitializationError: If required function tools are not provided. @@ -363,7 +361,7 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): instructions="...", ) - # Wrap as ChatAgent + # Wrap as Agent chat_agent = provider.as_agent(sdk_agent) """ # Validate function tools @@ -375,18 +373,18 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): normalized_tools, default_options=default_options, middleware=middleware, - context_provider=context_provider, + context_providers=context_providers, ) def _to_chat_agent_from_agent( self, - agent: Agent, - provided_tools: Sequence[ToolProtocol | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + agent: AzureAgent, + provided_tools: Sequence[FunctionTool | MutableMapping[str, Any]] | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: - """Create a ChatAgent from an Agent SDK object. + context_providers: Sequence[BaseContextProvider] | None = None, + ) -> Agent[OptionsCoT]: + """Create a Agent from an Agent SDK object. Args: agent: The Agent SDK object. @@ -394,7 +392,7 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): default_options: A TypedDict containing default chat options for the agent. These options are applied to every run unless overridden. middleware: List of middleware to intercept agent and function invocations. - context_provider: Context provider to include during agent invocation. + context_providers: Context providers to include during agent invocation. """ # Create the underlying client client = AzureAIAgentClient( @@ -408,8 +406,8 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): # Merge tools: convert agent's hosted tools + user-provided function tools merged_tools = self._merge_tools(agent.tools, provided_tools) - return ChatAgent( # type: ignore[return-value] - chat_client=client, + return Agent( # type: ignore[return-value] + client=client, id=agent.id, name=agent.name, description=agent.description, @@ -418,14 +416,14 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): tools=merged_tools, default_options=default_options, # type: ignore[arg-type] middleware=middleware, - context_provider=context_provider, + context_providers=context_providers, ) def _merge_tools( self, agent_tools: Sequence[Any] | None, - provided_tools: Sequence[ToolProtocol | MutableMapping[str, Any]] | None, - ) -> list[ToolProtocol | dict[str, Any]]: + provided_tools: Sequence[FunctionTool | MutableMapping[str, Any]] | None, + ) -> list[FunctionTool | dict[str, Any]]: """Merge hosted tools from agent with user-provided function tools. Args: @@ -433,9 +431,9 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): provided_tools: User-provided tools (Agent Framework format). Returns: - Combined list of tools for the ChatAgent. + Combined list of tools for the Agent. """ - merged: list[ToolProtocol | dict[str, Any]] = [] + merged: list[FunctionTool | dict[str, Any]] = [] # Convert hosted tools from agent definition hosted_tools = from_azure_ai_agent_tools(agent_tools) @@ -452,7 +450,7 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): if provided_tools: for provided_tool in provided_tools: # FunctionTool - has implementation for function calling - # MCPTool - ChatAgent handles MCP connection and tool discovery at runtime + # MCPTool - Agent handles MCP connection and tool discovery at runtime if isinstance(provided_tool, (FunctionTool, MCPTool)): merged.append(provided_tool) # type: ignore[reportUnknownArgumentType] @@ -461,7 +459,7 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): def _validate_function_tools( self, agent_tools: Sequence[Any] | None, - provided_tools: Sequence[ToolProtocol | MutableMapping[str, Any]] | None, + provided_tools: Sequence[FunctionTool | MutableMapping[str, Any]] | None, ) -> None: """Validate that required function tools are provided. 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 dc013e30d7..22d77c76b8 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 @@ -12,39 +12,35 @@ from typing import Any, ClassVar, Generic, TypedDict from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, + Agent, Annotation, BaseChatClient, - ChatAgent, + BaseContextProvider, ChatAndFunctionMiddlewareTypes, - ChatMessage, - ChatMessageStoreProtocol, ChatMiddlewareLayer, ChatOptions, ChatResponse, ChatResponseUpdate, Content, - ContextProvider, FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool, - HostedCodeInterpreterTool, - HostedFileSearchTool, - HostedMCPTool, - HostedWebSearchTool, + Message, MiddlewareTypes, ResponseStream, Role, TextSpanRegion, - ToolProtocol, UsageDetails, 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 from azure.ai.agents.models import ( - Agent, + Agent as AzureAgent, +) +from azure.ai.agents.models import ( AgentsNamedToolChoice, AgentsNamedToolChoiceType, AgentsToolChoiceOptionMode, @@ -53,7 +49,7 @@ from azure.ai.agents.models import ( AsyncAgentRunStream, BingCustomSearchTool, BingGroundingTool, - CodeInterpreterToolDefinition, + CodeInterpreterTool, FileSearchTool, FunctionName, FunctionToolDefinition, @@ -88,7 +84,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 @@ -193,8 +189,8 @@ AZURE_AI_AGENT_OPTION_TRANSLATIONS: dict[str, str] = { } """Maps ChatOptions keys to Azure AI Agents API parameter names.""" -TAzureAIAgentOptions = TypeVar( - "TAzureAIAgentOptions", +AzureAIAgentOptionsT = TypeVar( + "AzureAIAgentOptionsT", bound=TypedDict, # type: ignore[valid-type] default="AzureAIAgentOptions", covariant=True, @@ -205,15 +201,208 @@ TAzureAIAgentOptions = TypeVar( class AzureAIAgentClient( - ChatMiddlewareLayer[TAzureAIAgentOptions], - FunctionInvocationLayer[TAzureAIAgentOptions], - ChatTelemetryLayer[TAzureAIAgentOptions], - BaseChatClient[TAzureAIAgentOptions], - Generic[TAzureAIAgentOptions], + ChatMiddlewareLayer[AzureAIAgentOptionsT], + FunctionInvocationLayer[AzureAIAgentOptionsT], + ChatTelemetryLayer[AzureAIAgentOptionsT], + BaseChatClient[AzureAIAgentOptionsT], + Generic[AzureAIAgentOptionsT], ): """Azure AI Agent Chat client with middleware, telemetry, and function invocation support.""" OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc] + STORES_BY_DEFAULT: ClassVar[bool] = True # type: ignore[reportIncompatibleVariableOverride, misc] + + # region Hosted Tool Factory Methods + + @staticmethod + def get_code_interpreter_tool() -> CodeInterpreterTool: + """Create a code interpreter tool configuration for Azure AI Agents. + + Returns: + A CodeInterpreterTool instance ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.azure import AzureAIAgentClient + + tool = AzureAIAgentClient.get_code_interpreter_tool() + agent = ChatAgent(client, tools=[tool]) + """ + return CodeInterpreterTool() + + @staticmethod + def get_file_search_tool( + *, + vector_store_ids: list[str], + ) -> FileSearchTool: + """Create a file search tool configuration for Azure AI Agents. + + Keyword Args: + vector_store_ids: List of vector store IDs to search within. + + Returns: + A FileSearchTool instance ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.azure import AzureAIAgentClient + + tool = AzureAIAgentClient.get_file_search_tool( + vector_store_ids=["vs_abc123"], + ) + agent = ChatAgent(client, tools=[tool]) + """ + return FileSearchTool(vector_store_ids=vector_store_ids) + + @staticmethod + def get_web_search_tool( + *, + bing_connection_id: str | None = None, + bing_custom_connection_id: str | None = None, + bing_custom_instance_id: str | None = None, + ) -> BingGroundingTool | BingCustomSearchTool: + """Create a web search tool configuration for Azure AI Agents. + + For Azure AI Agents, web search uses Bing Grounding or Bing Custom Search. + If no arguments are provided, attempts to read from environment variables. + If no connection IDs are found, raises ValueError. + + Keyword Args: + bing_connection_id: The Bing Grounding connection ID for standard web search. + Falls back to BING_CONNECTION_ID environment variable. + bing_custom_connection_id: The Bing Custom Search connection ID. + Falls back to BING_CUSTOM_CONNECTION_ID environment variable. + bing_custom_instance_id: The Bing Custom Search instance ID. + Falls back to BING_CUSTOM_INSTANCE_NAME environment variable. + + Returns: + A BingGroundingTool or BingCustomSearchTool instance ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.azure import AzureAIAgentClient + + # Bing Grounding (explicit) + tool = AzureAIAgentClient.get_web_search_tool( + bing_connection_id="conn_bing_123", + ) + + # Bing Grounding (from environment variable) + tool = AzureAIAgentClient.get_web_search_tool() + + # Bing Custom Search (explicit) + tool = AzureAIAgentClient.get_web_search_tool( + bing_custom_connection_id="conn_custom_123", + bing_custom_instance_id="instance_456", + ) + + # Bing Custom Search (from environment variables) + # Set BING_CUSTOM_CONNECTION_ID and BING_CUSTOM_INSTANCE_NAME + tool = AzureAIAgentClient.get_web_search_tool() + + agent = ChatAgent(client, tools=[tool]) + """ + # Try explicit Bing Custom Search parameters first, then environment variables + resolved_custom_connection = bing_custom_connection_id or os.environ.get("BING_CUSTOM_CONNECTION_ID") + resolved_custom_instance = bing_custom_instance_id or os.environ.get("BING_CUSTOM_INSTANCE_NAME") + + if resolved_custom_connection and resolved_custom_instance: + return BingCustomSearchTool( + connection_id=resolved_custom_connection, + instance_name=resolved_custom_instance, + ) + + # Try explicit Bing Grounding parameter first, then environment variable + resolved_connection_id = bing_connection_id or os.environ.get("BING_CONNECTION_ID") + if resolved_connection_id: + return BingGroundingTool(connection_id=resolved_connection_id) + + # Azure AI Agents requires Bing connection for web search + raise ValueError( + "Azure AI Agents requires a Bing connection for web search. " + "Provide bing_connection_id (or set BING_CONNECTION_ID env var) for Bing Grounding, " + "or provide both bing_custom_connection_id and bing_custom_instance_id " + "(or set BING_CUSTOM_CONNECTION_ID and BING_CUSTOM_INSTANCE_NAME env vars) for Bing Custom Search." + ) + + @staticmethod + def get_mcp_tool( + *, + name: str, + url: str | None = None, + description: str | None = None, + approval_mode: str | dict[str, list[str]] | None = None, + allowed_tools: list[str] | None = None, + headers: dict[str, str] | None = None, + ) -> McpTool: + """Create a hosted MCP tool configuration for Azure AI Agents. + + This configures an MCP (Model Context Protocol) server that will be called + by Azure AI's service. The tools from this MCP server are executed remotely + by Azure AI, not locally by your application. + + Note: + For local MCP execution where your application calls the MCP server + directly, use the MCP client tools instead of this method. + + Keyword Args: + name: A label/name for the MCP server. + url: The URL of the MCP server. + description: A description of what the MCP server provides. + approval_mode: Tool approval mode. Use "always_require" or "never_require" for all tools, + or provide a dict with "always_require_approval" and/or "never_require_approval" + keys mapping to lists of tool names. + allowed_tools: List of tool names that are allowed to be used from this MCP server. + headers: HTTP headers to include in requests to the MCP server. + + Returns: + An McpTool instance ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.azure import AzureAIAgentClient + + tool = AzureAIAgentClient.get_mcp_tool( + name="my_mcp", + url="https://mcp.example.com", + ) + agent = ChatAgent(client, tools=[tool]) + """ + mcp_tool = McpTool( + server_label=name.replace(" ", "_"), + server_url=url or "", + allowed_tools=list(allowed_tools) if allowed_tools else [], + ) + + # Set approval mode if provided + # The SDK's set_approval_mode() accepts dict at runtime even though type hints say str. + if approval_mode: + if isinstance(approval_mode, str): + if approval_mode == "never_require": + mcp_tool.set_approval_mode("never") + elif approval_mode == "always_require": + mcp_tool.set_approval_mode("always") + else: + mcp_tool.set_approval_mode(approval_mode) + elif isinstance(approval_mode, dict): + # Handle dict-based approval mode (per-tool approval settings) + if "never_require_approval" in approval_mode: + mcp_tool.set_approval_mode({"never": {"tool_names": approval_mode["never_require_approval"]}}) # type: ignore[arg-type] + elif "always_require_approval" in approval_mode: + mcp_tool.set_approval_mode({"always": {"tool_names": approval_mode["always_require_approval"]}}) # type: ignore[arg-type] + + # Set headers if provided + if headers: + for key, value in headers.items(): + mcp_tool.update_headers(key, value) + + return mcp_tool + + # endregion def __init__( self, @@ -293,26 +482,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." @@ -322,7 +511,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, ) @@ -341,12 +530,12 @@ 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 self._should_close_client = should_close_client # Track whether we should close client connection - self._agent_definition: Agent | None = None # Cached definition for existing agent + self._agent_definition: AzureAgent | None = None # Cached definition for existing agent async def __aenter__(self) -> Self: """Async context manager entry.""" @@ -365,7 +554,7 @@ class AzureAIAgentClient( def _inner_get_response( self, *, - messages: Sequence[ChatMessage], + messages: Sequence[Message], options: Mapping[str, Any], stream: bool = False, **kwargs: Any, @@ -898,7 +1087,7 @@ class AzureAIAgentClient( self.agent_id = None self._agent_created = False - async def _load_agent_definition_if_needed(self) -> Agent | None: + async def _load_agent_definition_if_needed(self) -> AzureAgent | None: """Load and cache agent details if not already loaded.""" if self._agent_definition is None and self.agent_id is not None: self._agent_definition = await self.agents_client.get_agent(self.agent_id) @@ -906,7 +1095,7 @@ class AzureAIAgentClient( async def _prepare_options( self, - messages: Sequence[ChatMessage], + messages: Sequence[Message], options: Mapping[str, Any], **kwargs: Any, ) -> tuple[dict[str, Any], list[Content] | None]: @@ -1020,7 +1209,7 @@ class AzureAIAgentClient( async def _prepare_tool_definitions_and_resources( self, options: Mapping[str, Any], - agent_definition: Agent | None, + agent_definition: AzureAgent | None, run_options: dict[str, Any], ) -> list[ToolDefinition | dict[str, Any]]: """Prepare tool definitions and resources for the run options.""" @@ -1049,42 +1238,29 @@ class AzureAIAgentClient( return tool_definitions - def _prepare_mcp_resources(self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]]) -> list[dict[str, Any]]: - """Prepare MCP tool resources for approval mode configuration.""" - mcp_tools = [tool for tool in tools if isinstance(tool, HostedMCPTool)] - if not mcp_tools: - return [] + def _prepare_mcp_resources(self, tools: Sequence[Any]) -> list[dict[str, Any]]: + """Prepare MCP tool resources for approval mode configuration. + Extracts MCP resources from McpTool instances including server_label, + require_approval, and headers. + """ mcp_resources: list[dict[str, Any]] = [] - for mcp_tool in mcp_tools: - server_label = mcp_tool.name.replace(" ", "_") - mcp_resource: dict[str, Any] = {"server_label": server_label} - - if mcp_tool.headers: - mcp_resource["headers"] = mcp_tool.headers - - if mcp_tool.approval_mode is not None: - match mcp_tool.approval_mode: - case str(): - # Map agent framework approval modes to Azure AI approval modes - approval_mode = "always" if mcp_tool.approval_mode == "always_require" else "never" - mcp_resource["require_approval"] = approval_mode - case _: - if "always_require_approval" in mcp_tool.approval_mode: - mcp_resource["require_approval"] = { - "always": mcp_tool.approval_mode["always_require_approval"] - } - elif "never_require_approval" in mcp_tool.approval_mode: - mcp_resource["require_approval"] = { - "never": mcp_tool.approval_mode["never_require_approval"] - } - - mcp_resources.append(mcp_resource) - + for tool in tools: + if isinstance(tool, McpTool): + # Use the resources property which includes all config (approval, headers) + tool_resources = tool.resources + if tool_resources and tool_resources.mcp: + for mcp_resource in tool_resources.mcp: + resource_dict: dict[str, Any] = {"server_label": mcp_resource.server_label} + if mcp_resource.require_approval: + resource_dict["require_approval"] = mcp_resource.require_approval + if mcp_resource.headers: + resource_dict["headers"] = mcp_resource.headers + mcp_resources.append(resource_dict) return mcp_resources def _prepare_messages( - self, messages: Sequence[ChatMessage] + self, messages: Sequence[Message] ) -> tuple[ list[ThreadMessageOptions] | None, list[str], @@ -1142,79 +1318,40 @@ class AzureAIAgentClient( return additional_messages, instructions, required_action_results async def _prepare_tools_for_azure_ai( - self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]], run_options: dict[str, Any] | None = None - ) -> list[ToolDefinition | dict[str, Any]]: - """Prepare tool definitions for the Azure AI Agents API.""" - tool_definitions: list[ToolDefinition | dict[str, Any]] = [] + self, tools: Sequence[Any], run_options: dict[str, Any] | None = None + ) -> list[Any]: + """Prepare tool definitions for the Azure AI Agents API. + + Converts FunctionTool to JSON schema format. SDK Tool wrappers with .definitions + are unpacked. All other tools (ToolDefinition, dict, etc.) pass through unchanged. + + Args: + tools: Sequence of tools to prepare. + run_options: Optional run options dict that may be updated with tool_resources. + + Returns: + List of tool definitions ready for the Azure AI API. + """ + tool_definitions: list[Any] = [] for tool in tools: - match tool: - case FunctionTool(): - tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType] - case HostedWebSearchTool(): - additional_props = tool.additional_properties or {} - config_args: dict[str, Any] = {} - if count := additional_props.get("count"): - config_args["count"] = count - if freshness := additional_props.get("freshness"): - config_args["freshness"] = freshness - if market := additional_props.get("market"): - config_args["market"] = market - if set_lang := additional_props.get("set_lang"): - config_args["set_lang"] = set_lang - # Bing Grounding - connection_id = additional_props.get("connection_id") or os.getenv("BING_CONNECTION_ID") - # Custom Bing Search - custom_connection_id = additional_props.get("custom_connection_id") or os.getenv( - "BING_CUSTOM_CONNECTION_ID" - ) - custom_instance_name = additional_props.get("custom_instance_name") or os.getenv( - "BING_CUSTOM_INSTANCE_NAME" - ) - bing_search: BingGroundingTool | BingCustomSearchTool | None = None - if (connection_id) and not custom_connection_id and not custom_instance_name: - if connection_id: - conn_id = connection_id - else: - raise ServiceInitializationError("Parameter connection_id is not provided.") - bing_search = BingGroundingTool(connection_id=conn_id, **config_args) - if custom_connection_id and custom_instance_name: - bing_search = BingCustomSearchTool( - connection_id=custom_connection_id, - instance_name=custom_instance_name, - **config_args, - ) - if not bing_search: - raise ServiceInitializationError( - "Bing search tool requires either 'connection_id' for Bing Grounding " - "or both 'custom_connection_id' and 'custom_instance_name' for Custom Bing Search. " - "These can be provided via additional_properties or environment variables: " - "'BING_CONNECTION_ID', 'BING_CUSTOM_CONNECTION_ID', " - "'BING_CUSTOM_INSTANCE_NAME'" - ) - tool_definitions.extend(bing_search.definitions) - case HostedCodeInterpreterTool(): - tool_definitions.append(CodeInterpreterToolDefinition()) - case HostedMCPTool(): - mcp_tool = McpTool( - server_label=tool.name.replace(" ", "_"), - server_url=str(tool.url), - allowed_tools=list(tool.allowed_tools) if tool.allowed_tools else [], - ) - tool_definitions.extend(mcp_tool.definitions) - case HostedFileSearchTool(): - vector_stores = [inp for inp in tool.inputs or [] if inp.type == "hosted_vector_store"] - if vector_stores: - file_search = FileSearchTool(vector_store_ids=[vs.vector_store_id for vs in vector_stores]) # type: ignore[misc] - tool_definitions.extend(file_search.definitions) - # Set tool_resources for file search to work properly with Azure AI - if run_options is not None and "tool_resources" not in run_options: - run_options["tool_resources"] = file_search.resources - case ToolDefinition(): - tool_definitions.append(tool) - case dict(): - tool_definitions.append(tool) - case _: - raise ServiceInitializationError(f"Unsupported tool type: {type(tool)}") + if isinstance(tool, FunctionTool): + tool_definitions.append(tool.to_json_schema_spec()) + elif hasattr(tool, "definitions") and not isinstance(tool, MutableMapping): + # SDK Tool wrappers (McpTool, FileSearchTool, BingGroundingTool, etc.) + tool_definitions.extend(tool.definitions) + # Handle tool resources (MCP resources handled separately by _prepare_mcp_resources) + if ( + run_options is not None + and hasattr(tool, "resources") + and tool.resources + and "mcp" not in tool.resources + ): + if "tool_resources" not in run_options: + run_options["tool_resources"] = {} + run_options["tool_resources"].update(tool.resources) + else: + # Pass through ToolDefinition, dict, and other types unchanged + tool_definitions.append(tool) return tool_definitions def _prepare_tool_outputs_for_azure_ai( @@ -1252,7 +1389,7 @@ class AzureAIAgentClient( if tool_outputs is None: tool_outputs = [] tool_outputs.append( - ToolOutput(tool_call_id=call_id, output=prepare_function_call_results(content.result)) + ToolOutput(tool_call_id=call_id, output=content.result if content.result is not None else "") ) elif content.type == "function_approval_response": if tool_approvals is None: @@ -1291,20 +1428,19 @@ class AzureAIAgentClient( name: str | None = None, description: str | None = None, instructions: str | None = None, - tools: ToolProtocol + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TAzureAIAgentOptions | Mapping[str, Any] | None = None, - chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None, - context_provider: ContextProvider | None = None, + default_options: AzureAIAgentOptionsT | Mapping[str, Any] | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, **kwargs: Any, - ) -> ChatAgent[TAzureAIAgentOptions]: - """Convert this chat client to a ChatAgent. + ) -> Agent[AzureAIAgentOptionsT]: + """Convert this chat client to a Agent. - This method creates a ChatAgent instance with this client pre-configured. + This method creates a Agent instance with this client pre-configured. It does NOT create an agent on the Azure AI service - the actual agent will be created on the server during the first invocation (run). @@ -1318,13 +1454,12 @@ class AzureAIAgentClient( instructions: Optional instructions for the agent. tools: The tools to use for the request. default_options: A TypedDict containing chat options. - chat_message_store_factory: Factory function to create an instance of ChatMessageStoreProtocol. - context_provider: Context providers to include during agent invocation. + context_providers: Context providers to include during agent invocation. middleware: List of middleware to intercept agent and function invocations. kwargs: Any additional keyword arguments. Returns: - A ChatAgent instance configured with this chat client. + A Agent instance configured with this chat client. """ return super().as_agent( id=id, @@ -1333,8 +1468,7 @@ class AzureAIAgentClient( instructions=instructions, tools=tools, default_options=default_options, - chat_message_store_factory=chat_message_store_factory, - context_provider=context_provider, + context_providers=context_providers, middleware=middleware, **kwargs, ) 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 2dd3e8cc8b..79a30b0d81 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -4,34 +4,44 @@ from __future__ import annotations import sys from collections.abc import Callable, Mapping, MutableMapping, Sequence -from typing import Any, ClassVar, Generic, TypedDict, TypeVar, cast +from typing import Any, ClassVar, Generic, Literal, TypedDict, TypeVar, cast from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, - ChatAgent, + Agent, + BaseContextProvider, ChatAndFunctionMiddlewareTypes, - ChatMessage, - ChatMessageStoreProtocol, ChatMiddlewareLayer, - ContextProvider, FunctionInvocationConfiguration, FunctionInvocationLayer, - HostedMCPTool, + FunctionTool, + Message, MiddlewareTypes, - ToolProtocol, 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 from agent_framework.openai._responses_client import RawOpenAIResponsesClient from azure.ai.projects.aio import AIProjectClient -from azure.ai.projects.models import MCPTool, PromptAgentDefinition, PromptAgentDefinitionText, RaiConfig, Reasoning +from azure.ai.projects.models import ( + ApproximateLocation, + CodeInterpreterTool, + CodeInterpreterToolAuto, + ImageGenTool, + MCPTool, + PromptAgentDefinition, + PromptAgentDefinitionText, + RaiConfig, + Reasoning, + WebSearchPreviewTool, +) +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, _extract_project_connection_id, create_text_format_config +from ._shared import AzureAISettings, create_text_format_config if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -60,15 +70,15 @@ class AzureAIProjectAgentOptions(OpenAIResponsesOptions, total=False): """Configuration for enabling reasoning capabilities (requires azure.ai.projects.models.Reasoning).""" -TAzureAIClientOptions = TypeVar( - "TAzureAIClientOptions", +AzureAIClientOptionsT = TypeVar( + "AzureAIClientOptionsT", bound=TypedDict, # type: ignore[valid-type] default="AzureAIProjectAgentOptions", covariant=True, ) -class RawAzureAIClient(RawOpenAIResponsesClient[TAzureAIClientOptions], Generic[TAzureAIClientOptions]): +class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[AzureAIClientOptionsT]): """Raw Azure AI client without middleware, telemetry, or function invocation layers. Warning: @@ -160,20 +170,20 @@ class RawAzureAIClient(RawOpenAIResponsesClient[TAzureAIClientOptions], 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." @@ -183,7 +193,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[TAzureAIClientOptions], 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, ) @@ -201,7 +211,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[TAzureAIClientOptions], 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 @@ -329,7 +339,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[TAzureAIClientOptions], Generic[ if self.agent_name is None: raise ServiceInitializationError( "Agent name is required. Provide 'agent_name' when initializing AzureAIClient " - "or 'name' when initializing ChatAgent." + "or 'name' when initializing Agent." ) # If no agent_version is provided, either use latest version or create a new agent: @@ -396,7 +406,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[TAzureAIClientOptions], Generic[ @override async def _prepare_options( self, - messages: Sequence[ChatMessage], + messages: Sequence[Message], options: Mapping[str, Any], **kwargs: Any, ) -> dict[str, Any]: @@ -489,9 +499,9 @@ class RawAzureAIClient(RawOpenAIResponsesClient[TAzureAIClientOptions], Generic[ """Get the current conversation ID from chat options or kwargs.""" return options.get("conversation_id") or kwargs.get("conversation_id") or self.conversation_id - def _prepare_messages_for_azure_ai(self, messages: Sequence[ChatMessage]) -> tuple[list[ChatMessage], str | None]: + def _prepare_messages_for_azure_ai(self, messages: Sequence[Message]) -> tuple[list[Message], str | None]: """Prepare input from messages and convert system/developer messages to instructions.""" - result: list[ChatMessage] = [] + result: list[Message] = [] instructions_list: list[str] = [] instructions: str | None = None @@ -526,37 +536,263 @@ class RawAzureAIClient(RawOpenAIResponsesClient[TAzureAIClientOptions], Generic[ if description and not self.agent_description: self.agent_description = description + # region Hosted Tool Factory Methods (Azure-specific overrides) + @staticmethod - def _prepare_mcp_tool(tool: HostedMCPTool) -> MCPTool: # type: ignore[override] - """Get MCP tool from HostedMCPTool.""" - mcp = MCPTool(server_label=tool.name.replace(" ", "_"), server_url=str(tool.url)) + def get_code_interpreter_tool( # type: ignore[override] + *, + file_ids: list[str] | None = None, + container: Literal["auto"] | dict[str, Any] = "auto", + **kwargs: Any, + ) -> CodeInterpreterTool: + """Create a code interpreter tool configuration for Azure AI Projects. - if tool.description: - mcp["server_description"] = tool.description + Keyword Args: + file_ids: Optional list of file IDs to make available to the code interpreter. + container: Container configuration. Use "auto" for automatic container management. + Note: Custom container settings from this parameter are not used by Azure AI Projects; + use file_ids instead. + **kwargs: Additional arguments passed to the SDK CodeInterpreterTool constructor. + + Returns: + A CodeInterpreterTool ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.azure import AzureAIClient + + tool = AzureAIClient.get_code_interpreter_tool() + agent = ChatAgent(client, tools=[tool]) + """ + # Extract file_ids from container if provided as dict and file_ids not explicitly set + if file_ids is None and isinstance(container, dict): + file_ids = container.get("file_ids") + tool_container = CodeInterpreterToolAuto(file_ids=file_ids if file_ids else None) + return CodeInterpreterTool(container=tool_container, **kwargs) + + @staticmethod + def get_file_search_tool( + *, + vector_store_ids: list[str], + max_num_results: int | None = None, + ranking_options: dict[str, Any] | None = None, + filters: dict[str, Any] | None = None, + **kwargs: Any, + ) -> ProjectsFileSearchTool: + """Create a file search tool configuration for Azure AI Projects. + + Keyword Args: + vector_store_ids: List of vector store IDs to search. + max_num_results: Maximum number of results to return (1-50). + ranking_options: Ranking options for search results. + filters: A filter to apply (ComparisonFilter or CompoundFilter). + **kwargs: Additional arguments passed to the SDK FileSearchTool constructor. + + Returns: + A FileSearchTool ready to pass to ChatAgent. + + Raises: + ValueError: If vector_store_ids is empty. + + Examples: + .. code-block:: python + + from agent_framework.azure import AzureAIClient + + tool = AzureAIClient.get_file_search_tool( + vector_store_ids=["vs_abc123"], + ) + agent = ChatAgent(client, tools=[tool]) + """ + if not vector_store_ids: + raise ValueError("File search tool requires 'vector_store_ids' to be specified.") + return ProjectsFileSearchTool( + vector_store_ids=vector_store_ids, + max_num_results=max_num_results, + ranking_options=ranking_options, # type: ignore[arg-type] + filters=filters, # type: ignore[arg-type] + **kwargs, + ) + + @staticmethod + def get_web_search_tool( # type: ignore[override] + *, + user_location: dict[str, str] | None = None, + search_context_size: Literal["low", "medium", "high"] | None = None, + **kwargs: Any, + ) -> WebSearchPreviewTool: + """Create a web search preview tool configuration for Azure AI Projects. + + Keyword Args: + user_location: Location context for search results. Dict with keys like + "city", "country", "region", "timezone". + search_context_size: Amount of context to include from search results. + One of "low", "medium", or "high". Defaults to "medium". + **kwargs: Additional arguments passed to the SDK WebSearchPreviewTool constructor. + + Returns: + A WebSearchPreviewTool ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.azure import AzureAIClient + + tool = AzureAIClient.get_web_search_tool() + agent = ChatAgent(client, tools=[tool]) + + # With location and context size + tool = AzureAIClient.get_web_search_tool( + user_location={"city": "Seattle", "country": "US"}, + search_context_size="high", + ) + """ + ws_tool = WebSearchPreviewTool(search_context_size=search_context_size, **kwargs) + + if user_location: + ws_tool.user_location = ApproximateLocation( + city=user_location.get("city"), + country=user_location.get("country"), + region=user_location.get("region"), + timezone=user_location.get("timezone"), + ) + + return ws_tool + + @staticmethod + def get_image_generation_tool( # type: ignore[override] + *, + model: Literal["gpt-image-1"] | str | None = None, + size: Literal["1024x1024", "1024x1536", "1536x1024", "auto"] | None = None, + output_format: Literal["png", "webp", "jpeg"] | None = None, + quality: Literal["low", "medium", "high", "auto"] | None = None, + background: Literal["transparent", "opaque", "auto"] | None = None, + partial_images: int | None = None, + moderation: Literal["auto", "low"] | None = None, + output_compression: int | None = None, + **kwargs: Any, + ) -> ImageGenTool: + """Create an image generation tool configuration for Azure AI Projects. + + Keyword Args: + model: The model to use for image generation. + size: Output image size. + output_format: Output image format. + quality: Output image quality. + background: Background transparency setting. + partial_images: Number of partial images to return during generation. + moderation: Moderation level. + output_compression: Compression level. + **kwargs: Additional arguments passed to the SDK ImageGenTool constructor. + + Returns: + An ImageGenTool ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.azure import AzureAIClient + + tool = AzureAIClient.get_image_generation_tool() + agent = ChatAgent(client, tools=[tool]) + """ + return ImageGenTool( # type: ignore[misc] + model=model, # type: ignore[arg-type] + size=size, + output_format=output_format, + quality=quality, + background=background, + partial_images=partial_images, + moderation=moderation, + output_compression=output_compression, + **kwargs, + ) + + @staticmethod + def get_mcp_tool( + *, + name: str, + url: str | None = None, + description: str | None = None, + approval_mode: Literal["always_require", "never_require"] | dict[str, list[str]] | None = None, + allowed_tools: list[str] | None = None, + headers: dict[str, str] | None = None, + project_connection_id: str | None = None, + **kwargs: Any, + ) -> MCPTool: + """Create a hosted MCP tool configuration for Azure AI. + + This configures an MCP (Model Context Protocol) server that will be called + by Azure AI's service. The tools from this MCP server are executed remotely + by Azure AI, not locally by your application. + + Note: + For local MCP execution where your application calls the MCP server + directly, use the MCP client tools instead of this method. + + Keyword Args: + name: A label/name for the MCP server. + url: The URL of the MCP server. Required if project_connection_id is not provided. + description: A description of what the MCP server provides. + approval_mode: Tool approval mode. Use "always_require" or "never_require" for all tools, + or provide a dict with "always_require_approval" and/or "never_require_approval" + keys mapping to lists of tool names. + allowed_tools: List of tool names that are allowed to be used from this MCP server. + headers: HTTP headers to include in requests to the MCP server. + project_connection_id: Azure AI Foundry connection ID for managed MCP connections. + If provided, url and headers are not required. + **kwargs: Additional arguments passed to the SDK MCPTool constructor. + + Returns: + An MCPTool configuration ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.azure import AzureAIClient + + # With URL + tool = AzureAIClient.get_mcp_tool( + name="my_mcp", + url="https://mcp.example.com", + ) + + # With Azure AI Foundry connection + tool = AzureAIClient.get_mcp_tool( + name="github_mcp", + project_connection_id="conn_abc123", + description="GitHub MCP via Azure AI Foundry", + ) + + agent = ChatAgent(client, tools=[tool]) + """ + mcp = MCPTool(server_label=name.replace(" ", "_"), server_url=url or "", **kwargs) + + if description: + mcp["server_description"] = description - # Check for project_connection_id in additional_properties (for Azure AI Foundry connections) - project_connection_id = _extract_project_connection_id(tool.additional_properties) if project_connection_id: mcp["project_connection_id"] = project_connection_id - elif tool.headers: - # Only use headers if no project_connection_id is available - mcp["headers"] = tool.headers + elif headers: + mcp["headers"] = headers - if tool.allowed_tools: - mcp["allowed_tools"] = list(tool.allowed_tools) + if allowed_tools: + mcp["allowed_tools"] = allowed_tools - if tool.approval_mode: - match tool.approval_mode: - case str(): - mcp["require_approval"] = "always" if tool.approval_mode == "always_require" else "never" - case _: - if always_require_approvals := tool.approval_mode.get("always_require_approval"): - mcp["require_approval"] = {"always": {"tool_names": list(always_require_approvals)}} - if never_require_approvals := tool.approval_mode.get("never_require_approval"): - mcp["require_approval"] = {"never": {"tool_names": list(never_require_approvals)}} + if approval_mode: + if isinstance(approval_mode, str): + mcp["require_approval"] = "always" if approval_mode == "always_require" else "never" + else: + if always_require := approval_mode.get("always_require_approval"): + mcp["require_approval"] = {"always": {"tool_names": always_require}} + if never_require := approval_mode.get("never_require_approval"): + mcp["require_approval"] = {"never": {"tool_names": never_require}} return mcp + # endregion + @override def as_agent( self, @@ -565,20 +801,19 @@ class RawAzureAIClient(RawOpenAIResponsesClient[TAzureAIClientOptions], Generic[ name: str | None = None, description: str | None = None, instructions: str | None = None, - tools: ToolProtocol + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TAzureAIClientOptions | Mapping[str, Any] | None = None, - chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None, - context_provider: ContextProvider | None = None, + default_options: AzureAIClientOptionsT | Mapping[str, Any] | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, **kwargs: Any, - ) -> ChatAgent[TAzureAIClientOptions]: - """Convert this chat client to a ChatAgent. + ) -> Agent[AzureAIClientOptionsT]: + """Convert this chat client to a Agent. - This method creates a ChatAgent instance with this client pre-configured. + This method creates a Agent instance with this client pre-configured. It does NOT create an agent on the Azure AI service - the actual agent will be created on the server during the first invocation (run). @@ -592,13 +827,12 @@ class RawAzureAIClient(RawOpenAIResponsesClient[TAzureAIClientOptions], Generic[ instructions: Optional instructions for the agent. tools: The tools to use for the request. default_options: A TypedDict containing chat options. - chat_message_store_factory: Factory function to create an instance of ChatMessageStoreProtocol. - context_provider: Context providers to include during agent invocation. + context_providers: Context providers to include during agent invocation. middleware: List of middleware to intercept agent and function invocations. kwargs: Any additional keyword arguments. Returns: - A ChatAgent instance configured with this chat client. + A Agent instance configured with this chat client. """ return super().as_agent( id=id, @@ -607,19 +841,18 @@ class RawAzureAIClient(RawOpenAIResponsesClient[TAzureAIClientOptions], Generic[ instructions=instructions, tools=tools, default_options=default_options, - chat_message_store_factory=chat_message_store_factory, - context_provider=context_provider, + context_providers=context_providers, middleware=middleware, **kwargs, ) class AzureAIClient( - ChatMiddlewareLayer[TAzureAIClientOptions], - FunctionInvocationLayer[TAzureAIClientOptions], - ChatTelemetryLayer[TAzureAIClientOptions], - RawAzureAIClient[TAzureAIClientOptions], - Generic[TAzureAIClientOptions], + ChatMiddlewareLayer[AzureAIClientOptionsT], + FunctionInvocationLayer[AzureAIClientOptionsT], + ChatTelemetryLayer[AzureAIClientOptionsT], + RawAzureAIClient[AzureAIClientOptionsT], + Generic[AzureAIClientOptionsT], ): """Azure AI client with middleware, telemetry, and function invocation support. 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 9c20e08b6c..f2faffdb99 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 @@ -8,15 +8,15 @@ from typing import Any, Generic from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, - ChatAgent, - ContextProvider, + Agent, + BaseContextProvider, FunctionTool, MiddlewareTypes, - ToolProtocol, get_logger, 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 ( @@ -29,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 @@ -47,17 +46,17 @@ else: logger = get_logger("agent_framework.azure") -# Type variable for options - allows typed ChatAgent[TOptions] returns +# Type variable for options - allows typed Agent[OptionsT] returns # Default matches AzureAIClient's default options type -TOptions_co = TypeVar( - "TOptions_co", +OptionsCoT = TypeVar( + "OptionsCoT", bound=TypedDict, # type: ignore[valid-type] default="AzureAIProjectAgentOptions", covariant=True, ) -class AzureAIProjectAgentProvider(Generic[TOptions_co]): +class AzureAIProjectAgentProvider(Generic[OptionsCoT]): """Provider for Azure AI Agent Service (Responses API). This provider allows you to create, retrieve, and manage Azure AI agents @@ -124,21 +123,21 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): 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." @@ -148,7 +147,7 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): 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, ) @@ -162,16 +161,16 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): model: str | None = None, instructions: str | None = None, description: str | None = None, - tools: ToolProtocol + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: - """Create a new agent on the Azure AI service and return a local ChatAgent wrapper. + context_providers: Sequence[BaseContextProvider] | None = None, + ) -> Agent[OptionsCoT]: + """Create a new agent on the Azure AI service and return a local Agent wrapper. Args: name: The name of the agent to create. @@ -183,16 +182,16 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): default_options: A TypedDict containing default chat options for the agent. These options are applied to every run unless overridden. middleware: List of middleware to intercept agent and function invocations. - context_provider: Context provider to include during agent invocation. + context_providers: Context providers to include during agent invocation. Returns: - ChatAgent: A ChatAgent instance configured with the created agent. + Agent: A Agent instance configured with the created agent. Raises: 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 " @@ -221,7 +220,7 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): # Normalize tools and separate MCP tools from other tools normalized_tools = normalize_tools(tools) mcp_tools: list[MCPTool] = [] - non_mcp_tools: list[ToolProtocol | MutableMapping[str, Any]] = [] + non_mcp_tools: list[FunctionTool | MutableMapping[str, Any]] = [] if normalized_tools: for tool in normalized_tools: @@ -239,7 +238,7 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): mcp_discovered_functions.extend(mcp_tool.functions) # Combine non-MCP tools with discovered MCP functions for Azure AI - all_tools_for_azure: list[ToolProtocol | MutableMapping[str, Any]] = list(non_mcp_tools) + all_tools_for_azure: list[FunctionTool | MutableMapping[str, Any]] = list(non_mcp_tools) all_tools_for_azure.extend(mcp_discovered_functions) if all_tools_for_azure: @@ -256,7 +255,7 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): normalized_tools, default_options=default_options, middleware=middleware, - context_provider=context_provider, + context_providers=context_providers, ) async def get_agent( @@ -264,16 +263,16 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): *, name: str | None = None, reference: AgentReference | None = None, - tools: ToolProtocol + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: - """Retrieve an existing agent from the Azure AI service and return a local ChatAgent wrapper. + context_providers: Sequence[BaseContextProvider] | None = None, + ) -> Agent[OptionsCoT]: + """Retrieve an existing agent from the Azure AI service and return a local Agent wrapper. You must provide either name or reference. Use `as_agent()` if you already have AgentVersionDetails and want to avoid an async call. @@ -285,10 +284,10 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): default_options: A TypedDict containing default chat options for the agent. These options are applied to every run unless overridden. middleware: List of middleware to intercept agent and function invocations. - context_provider: Context provider to include during agent invocation. + context_providers: Context providers to include during agent invocation. Returns: - ChatAgent: A ChatAgent instance configured with the retrieved agent. + Agent: A Agent instance configured with the retrieved agent. Raises: ValueError: If no identifier is provided or required tools are missing. @@ -308,7 +307,7 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): raise ValueError("Either name or reference must be provided to get an agent.") if not isinstance(existing_agent.definition, PromptAgentDefinition): - raise ValueError("Agent definition must be PromptAgentDefinition to get a ChatAgent.") + raise ValueError("Agent definition must be PromptAgentDefinition to get a Agent.") # Validate that required function tools are provided self._validate_function_tools(existing_agent.definition.tools, tools) @@ -318,22 +317,22 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): normalize_tools(tools), default_options=default_options, middleware=middleware, - context_provider=context_provider, + context_providers=context_providers, ) def as_agent( self, details: AgentVersionDetails, - tools: ToolProtocol + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: - """Wrap an SDK agent version object into a ChatAgent without making HTTP calls. + context_providers: Sequence[BaseContextProvider] | None = None, + ) -> Agent[OptionsCoT]: + """Wrap an SDK agent version object into a Agent without making HTTP calls. Use this when you already have an AgentVersionDetails from a previous API call. @@ -343,16 +342,16 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): default_options: A TypedDict containing default chat options for the agent. These options are applied to every run unless overridden. middleware: List of middleware to intercept agent and function invocations. - context_provider: Context provider to include during agent invocation. + context_providers: Context providers to include during agent invocation. Returns: - ChatAgent: A ChatAgent instance configured with the agent version. + Agent: A Agent instance configured with the agent version. Raises: ValueError: If the agent definition is not a PromptAgentDefinition or required tools are missing. """ if not isinstance(details.definition, PromptAgentDefinition): - raise ValueError("Agent definition must be PromptAgentDefinition to create a ChatAgent.") + raise ValueError("Agent definition must be PromptAgentDefinition to create a Agent.") # Validate that required function tools are provided self._validate_function_tools(details.definition.tools, tools) @@ -362,18 +361,18 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): normalize_tools(tools), default_options=default_options, middleware=middleware, - context_provider=context_provider, + context_providers=context_providers, ) def _to_chat_agent_from_details( self, details: AgentVersionDetails, - provided_tools: Sequence[ToolProtocol | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + provided_tools: Sequence[FunctionTool | MutableMapping[str, Any]] | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: - """Create a ChatAgent from an AgentVersionDetails. + context_providers: Sequence[BaseContextProvider] | None = None, + ) -> Agent[OptionsCoT]: + """Create a Agent from an AgentVersionDetails. Args: details: The AgentVersionDetails containing the agent definition. @@ -382,10 +381,10 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): default_options: A TypedDict containing default chat options for the agent. These options are applied to every run unless overridden. middleware: List of middleware to intercept agent and function invocations. - context_provider: Context provider to include during agent invocation. + context_providers: Context providers to include during agent invocation. """ if not isinstance(details.definition, PromptAgentDefinition): - raise ValueError("Agent definition must be PromptAgentDefinition to get a ChatAgent.") + raise ValueError("Agent definition must be PromptAgentDefinition to get a Agent.") client = AzureAIClient( project_client=self._project_client, @@ -400,8 +399,8 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): # but function tools need the actual implementations from provided_tools merged_tools = self._merge_tools(details.definition.tools, provided_tools) - return ChatAgent( # type: ignore[return-value] - chat_client=client, + return Agent( # type: ignore[return-value] + client=client, id=details.id, name=details.name, description=details.description, @@ -410,14 +409,14 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): tools=merged_tools, default_options=default_options, # type: ignore[arg-type] middleware=middleware, - context_provider=context_provider, + context_providers=context_providers, ) def _merge_tools( self, definition_tools: Sequence[Any] | None, - provided_tools: Sequence[ToolProtocol | MutableMapping[str, Any]] | None, - ) -> list[ToolProtocol | dict[str, Any]]: + provided_tools: Sequence[FunctionTool | MutableMapping[str, Any]] | None, + ) -> list[FunctionTool | dict[str, Any]]: """Merge hosted tools from definition with user-provided function tools. Args: @@ -425,9 +424,9 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): provided_tools: User-provided tools (Agent Framework format), including function implementations. Returns: - Combined list of tools for the ChatAgent. + Combined list of tools for the Agent. """ - merged: list[ToolProtocol | dict[str, Any]] = [] + merged: list[FunctionTool | dict[str, Any]] = [] # Convert hosted tools from definition (MCP, code interpreter, file search, web search) # Function tools from the definition are skipped - we use user-provided implementations instead @@ -442,7 +441,7 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): if provided_tools: for provided_tool in provided_tools: # FunctionTool - has implementation for function calling - # MCPTool - ChatAgent handles MCP connection and tool discovery at runtime + # MCPTool - Agent handles MCP connection and tool discovery at runtime if isinstance(provided_tool, (FunctionTool, MCPTool)): merged.append(provided_tool) # type: ignore[reportUnknownArgumentType] @@ -451,10 +450,10 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): def _validate_function_tools( self, agent_tools: Sequence[Any] | None, - provided_tools: ToolProtocol + provided_tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None, ) -> None: """Validate that required function tools are provided.""" 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 065a7d5af2..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,37 +2,21 @@ from __future__ import annotations -import os +import sys from collections.abc import Mapping, MutableMapping, Sequence -from typing import Any, ClassVar, Literal, cast +from typing import Any, cast from agent_framework import ( - Content, FunctionTool, - HostedCodeInterpreterTool, - HostedFileSearchTool, - HostedImageGenerationTool, - HostedMCPTool, - HostedWebSearchTool, - ToolProtocol, get_logger, ) -from agent_framework._pydantic import AFBaseSettings -from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidRequestError +from agent_framework.exceptions import ServiceInvalidRequestError from azure.ai.agents.models import ( - BingCustomSearchTool, - BingGroundingTool, CodeInterpreterToolDefinition, - McpTool, ToolDefinition, ) -from azure.ai.agents.models import FileSearchTool as AgentsFileSearchTool from azure.ai.projects.models import ( - ApproximateLocation, CodeInterpreterTool, - CodeInterpreterToolAuto, - ImageGenTool, - ImageGenToolInputImageMask, MCPTool, ResponseTextFormatConfigurationJsonObject, ResponseTextFormatConfigurationJsonSchema, @@ -48,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_'. @@ -86,20 +75,18 @@ 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: - """Extract project_connection_id from HostedMCPTool additional_properties. + """Extract project_connection_id from tool additional_properties. Checks for both direct 'project_connection_id' key (programmatic usage) and 'connection.name' structure (declarative/YAML usage). Args: - additional_properties: The additional_properties dict from a HostedMCPTool. + additional_properties: The additional_properties dict from a tool. Returns: The project_connection_id if found, None otherwise. @@ -124,11 +111,13 @@ def _extract_project_connection_id(additional_properties: dict[str, Any] | None) def to_azure_ai_agent_tools( - tools: Sequence[ToolProtocol | MutableMapping[str, Any]] | None, + tools: Sequence[FunctionTool | MutableMapping[str, Any]] | None, run_options: dict[str, Any] | None = None, ) -> list[ToolDefinition | dict[str, Any]]: """Convert Agent Framework tools to Azure AI V1 SDK tool definitions. + Handles FunctionTool instances and dict-based tools from static factory methods. + Args: tools: Sequence of Agent Framework tools to convert. run_options: Optional dict with run options. @@ -144,91 +133,53 @@ def to_azure_ai_agent_tools( tool_definitions: list[ToolDefinition | dict[str, Any]] = [] for tool in tools: - match tool: - case FunctionTool(): - tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType] - case HostedWebSearchTool(): - additional_props = tool.additional_properties or {} - config_args: dict[str, Any] = {} - if count := additional_props.get("count"): - config_args["count"] = count - if freshness := additional_props.get("freshness"): - config_args["freshness"] = freshness - if market := additional_props.get("market"): - config_args["market"] = market - if set_lang := additional_props.get("set_lang"): - config_args["set_lang"] = set_lang - # Bing Grounding - connection_id = additional_props.get("connection_id") or os.getenv("BING_CONNECTION_ID") - # Custom Bing Search - custom_connection_id = additional_props.get("custom_connection_id") or os.getenv( - "BING_CUSTOM_CONNECTION_ID" - ) - custom_instance_name = additional_props.get("custom_instance_name") or os.getenv( - "BING_CUSTOM_INSTANCE_NAME" - ) - bing_search: BingGroundingTool | BingCustomSearchTool | None = None - if connection_id and not custom_connection_id and not custom_instance_name: - bing_search = BingGroundingTool(connection_id=connection_id, **config_args) - if custom_connection_id and custom_instance_name: - bing_search = BingCustomSearchTool( - connection_id=custom_connection_id, - instance_name=custom_instance_name, - **config_args, - ) - if not bing_search: - raise ServiceInitializationError( - "Bing search tool requires either 'connection_id' for Bing Grounding " - "or both 'custom_connection_id' and 'custom_instance_name' for Custom Bing Search. " - "These can be provided via additional_properties or environment variables: " - "'BING_CONNECTION_ID', 'BING_CUSTOM_CONNECTION_ID', 'BING_CUSTOM_INSTANCE_NAME'" - ) - tool_definitions.extend(bing_search.definitions) - case HostedCodeInterpreterTool(): - tool_definitions.append(CodeInterpreterToolDefinition()) - case HostedMCPTool(): - mcp_tool = McpTool( - server_label=tool.name.replace(" ", "_"), - server_url=str(tool.url), - allowed_tools=list(tool.allowed_tools) if tool.allowed_tools else [], - ) - tool_definitions.extend(mcp_tool.definitions) - case HostedFileSearchTool(): - vector_stores = [inp for inp in tool.inputs or [] if inp.type == "hosted_vector_store"] - if vector_stores: - file_search = AgentsFileSearchTool(vector_store_ids=[vs.vector_store_id for vs in vector_stores]) # type: ignore[misc] - tool_definitions.extend(file_search.definitions) - # Set tool_resources for file search to work properly with Azure AI - if run_options is not None and "tool_resources" not in run_options: - run_options["tool_resources"] = file_search.resources - case ToolDefinition(): - tool_definitions.append(tool) - case dict(): - tool_definitions.append(tool) - case _: - raise ServiceInitializationError(f"Unsupported tool type: {type(tool)}") + if isinstance(tool, FunctionTool): + tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType] + elif isinstance(tool, ToolDefinition): + # Pass through ToolDefinition subclasses unchanged (includes CodeInterpreterToolDefinition, etc.) + tool_definitions.append(tool) + elif hasattr(tool, "definitions") and not isinstance(tool, (dict, MutableMapping)): + # SDK Tool wrappers (McpTool, FileSearchTool, BingGroundingTool, etc.) + tool_definitions.extend(tool.definitions) + # Handle tool resources (MCP resources handled separately) + if ( + run_options is not None + and hasattr(tool, "resources") + and tool.resources + and "mcp" not in tool.resources + ): + if "tool_resources" not in run_options: + run_options["tool_resources"] = {} + run_options["tool_resources"].update(tool.resources) + elif isinstance(tool, (dict, MutableMapping)): + # Handle dict-based tools - pass through directly + tool_dict = tool if isinstance(tool, dict) else dict(tool) + tool_definitions.append(tool_dict) + else: + # Pass through other types unchanged + tool_definitions.append(tool) return tool_definitions def from_azure_ai_agent_tools( tools: Sequence[ToolDefinition | dict[str, Any]] | None, -) -> list[ToolProtocol | dict[str, Any]]: - """Convert Azure AI V1 SDK tool definitions to Agent Framework tools. +) -> list[dict[str, Any]]: + """Convert Azure AI V1 SDK tool definitions to dict-based tools. Args: tools: Sequence of Azure AI V1 SDK tool definitions. Returns: - List of Agent Framework tools. + List of dict-based tool definitions. """ if not tools: return [] - result: list[ToolProtocol | dict[str, Any]] = [] + result: list[dict[str, Any]] = [] for tool in tools: # Handle SDK objects if isinstance(tool, CodeInterpreterToolDefinition): - result.append(HostedCodeInterpreterTool()) + result.append({"type": "code_interpreter"}) elif isinstance(tool, dict): # Handle dict format converted = _convert_dict_tool(tool) @@ -242,35 +193,38 @@ def from_azure_ai_agent_tools( return result -def _convert_dict_tool(tool: dict[str, Any]) -> ToolProtocol | dict[str, Any] | None: - """Convert a dict-format Azure AI tool to Agent Framework tool.""" +def _convert_dict_tool(tool: dict[str, Any]) -> dict[str, Any] | None: + """Convert a dict-format Azure AI tool to dict-based tool format.""" tool_type = tool.get("type") if tool_type == "code_interpreter": - return HostedCodeInterpreterTool() + return {"type": "code_interpreter"} if tool_type == "file_search": file_search_config = tool.get("file_search", {}) vector_store_ids = file_search_config.get("vector_store_ids", []) - inputs = [Content.from_hosted_vector_store(vector_store_id=vs_id) for vs_id in vector_store_ids] - return HostedFileSearchTool(inputs=inputs if inputs else None) # type: ignore + return {"type": "file_search", "vector_store_ids": vector_store_ids} if tool_type == "bing_grounding": bing_config = tool.get("bing_grounding", {}) connection_id = bing_config.get("connection_id") - return HostedWebSearchTool(additional_properties={"connection_id": connection_id} if connection_id else None) + return {"type": "bing_grounding", "connection_id": connection_id} if connection_id else None if tool_type == "bing_custom_search": bing_config = tool.get("bing_custom_search", {}) - return HostedWebSearchTool( - additional_properties={ - "custom_connection_id": bing_config.get("connection_id"), - "custom_instance_name": bing_config.get("instance_name"), + connection_id = bing_config.get("connection_id") + instance_name = bing_config.get("instance_name") + # Only return if both required fields are present + if connection_id and instance_name: + return { + "type": "bing_custom_search", + "connection_id": connection_id, + "instance_name": instance_name, } - ) + return None if tool_type == "mcp": - # Hosted MCP tools are defined on the Azure agent, no local handling needed + # MCP tools are defined on the Azure agent, no local handling needed # Azure may not return full server_url, so skip conversion return None @@ -282,35 +236,38 @@ def _convert_dict_tool(tool: dict[str, Any]) -> ToolProtocol | dict[str, Any] | return tool -def _convert_sdk_tool(tool: ToolDefinition) -> ToolProtocol | dict[str, Any] | None: - """Convert an SDK-object Azure AI tool to Agent Framework tool.""" +def _convert_sdk_tool(tool: ToolDefinition) -> dict[str, Any] | None: + """Convert an SDK-object Azure AI tool to dict-based tool format.""" tool_type = getattr(tool, "type", None) if tool_type == "code_interpreter": - return HostedCodeInterpreterTool() + return {"type": "code_interpreter"} if tool_type == "file_search": file_search_config = getattr(tool, "file_search", None) vector_store_ids = getattr(file_search_config, "vector_store_ids", []) if file_search_config else [] - inputs = [Content.from_hosted_vector_store(vector_store_id=vs_id) for vs_id in vector_store_ids] - return HostedFileSearchTool(inputs=inputs if inputs else None) # type: ignore + return {"type": "file_search", "vector_store_ids": vector_store_ids} if tool_type == "bing_grounding": bing_config = getattr(tool, "bing_grounding", None) connection_id = getattr(bing_config, "connection_id", None) if bing_config else None - return HostedWebSearchTool(additional_properties={"connection_id": connection_id} if connection_id else None) + return {"type": "bing_grounding", "connection_id": connection_id} if connection_id else None if tool_type == "bing_custom_search": bing_config = getattr(tool, "bing_custom_search", None) - return HostedWebSearchTool( - additional_properties={ - "custom_connection_id": getattr(bing_config, "connection_id", None) if bing_config else None, - "custom_instance_name": getattr(bing_config, "instance_name", None) if bing_config else None, + connection_id = getattr(bing_config, "connection_id", None) if bing_config else None + instance_name = getattr(bing_config, "instance_name", None) if bing_config else None + # Only return if both required fields are present + if connection_id and instance_name: + return { + "type": "bing_custom_search", + "connection_id": connection_id, + "instance_name": instance_name, } - ) + return None if tool_type == "mcp": - # Hosted MCP tools are defined on the Azure agent, no local handling needed + # MCP tools are defined on the Azure agent, no local handling needed # Azure may not return full server_url, so skip conversion return None @@ -324,18 +281,17 @@ def _convert_sdk_tool(tool: ToolDefinition) -> ToolProtocol | dict[str, Any] | N return {"type": tool_type} if tool_type else {} -def from_azure_ai_tools(tools: Sequence[Tool | dict[str, Any]] | None) -> list[ToolProtocol | dict[str, Any]]: - """Parses and converts a sequence of Azure AI tools into Agent Framework compatible tools. +def from_azure_ai_tools(tools: Sequence[Tool | dict[str, Any]] | None) -> list[dict[str, Any]]: + """Parses and converts a sequence of Azure AI tools into dict-based tools. Args: tools: A sequence of tool objects or dictionaries defining the tools to be parsed. Can be None. Returns: - list[ToolProtocol | dict[str, Any]]: A list of converted tools compatible with the - Agent Framework. + list[dict[str, Any]]: A list of dict-based tool definitions. """ - agent_tools: list[ToolProtocol | dict[str, Any]] = [] + agent_tools: list[dict[str, Any]] = [] if not tools: return agent_tools for tool in tools: @@ -345,81 +301,62 @@ def from_azure_ai_tools(tools: Sequence[Tool | dict[str, Any]] | None) -> list[T if tool_type == "mcp": mcp_tool = cast(MCPTool, tool_dict) - approval_mode: Literal["always_require", "never_require"] | dict[str, set[str]] | None = None + result: dict[str, Any] = { + "type": "mcp", + "server_label": mcp_tool.get("server_label", ""), + "server_url": mcp_tool.get("server_url", ""), + } + if description := mcp_tool.get("server_description"): + result["server_description"] = description + if headers := mcp_tool.get("headers"): + result["headers"] = headers + if allowed_tools := mcp_tool.get("allowed_tools"): + result["allowed_tools"] = allowed_tools if require_approval := mcp_tool.get("require_approval"): - if require_approval == "always": - approval_mode = "always_require" - elif require_approval == "never": - approval_mode = "never_require" - elif isinstance(require_approval, dict): - approval_mode = {} - if "always" in require_approval: - approval_mode["always_require_approval"] = set(require_approval["always"].get("tool_names", [])) # type: ignore - if "never" in require_approval: - approval_mode["never_require_approval"] = set(require_approval["never"].get("tool_names", [])) # type: ignore - - # Preserve project_connection_id in additional_properties - additional_props: dict[str, Any] | None = None + result["require_approval"] = require_approval if project_connection_id := mcp_tool.get("project_connection_id"): - additional_props = {"connection": {"name": project_connection_id}} - - agent_tools.append( - HostedMCPTool( - name=mcp_tool.get("server_label", "").replace("_", " "), - url=mcp_tool.get("server_url", ""), - description=mcp_tool.get("server_description"), - headers=mcp_tool.get("headers"), - allowed_tools=mcp_tool.get("allowed_tools"), - approval_mode=approval_mode, # type: ignore - additional_properties=additional_props, - ) - ) + result["project_connection_id"] = project_connection_id + agent_tools.append(result) elif tool_type == "code_interpreter": ci_tool = cast(CodeInterpreterTool, tool_dict) container = ci_tool.get("container", {}) - ci_inputs: list[Content] = [] + result = {"type": "code_interpreter"} if "file_ids" in container: - for file_id in container["file_ids"]: - ci_inputs.append(Content.from_hosted_file(file_id=file_id)) - - agent_tools.append(HostedCodeInterpreterTool(inputs=ci_inputs if ci_inputs else None)) # type: ignore + result["file_ids"] = container["file_ids"] + agent_tools.append(result) elif tool_type == "file_search": fs_tool = cast(ProjectsFileSearchTool, tool_dict) - fs_inputs: list[Content] = [] + result = {"type": "file_search"} if "vector_store_ids" in fs_tool: - for vs_id in fs_tool["vector_store_ids"]: - fs_inputs.append(Content.from_hosted_vector_store(vector_store_id=vs_id)) - - agent_tools.append( - HostedFileSearchTool( - inputs=fs_inputs if fs_inputs else None, # type: ignore - max_results=fs_tool.get("max_num_results"), - ) - ) + result["vector_store_ids"] = fs_tool["vector_store_ids"] + if max_results := fs_tool.get("max_num_results"): + result["max_num_results"] = max_results + agent_tools.append(result) elif tool_type == "web_search_preview": ws_tool = cast(WebSearchPreviewTool, tool_dict) - additional_properties: dict[str, Any] = {} + result = {"type": "web_search_preview"} if user_location := ws_tool.get("user_location"): - additional_properties["user_location"] = { + result["user_location"] = { "city": user_location.get("city"), "country": user_location.get("country"), "region": user_location.get("region"), "timezone": user_location.get("timezone"), } - - agent_tools.append(HostedWebSearchTool(additional_properties=additional_properties)) + agent_tools.append(result) else: agent_tools.append(tool_dict) return agent_tools def to_azure_ai_tools( - tools: Sequence[ToolProtocol | MutableMapping[str, Any]] | None, + tools: Sequence[FunctionTool | MutableMapping[str, Any] | Tool] | None, ) -> list[Tool | dict[str, Any]]: """Converts Agent Framework tools into Azure AI compatible tools. + Handles FunctionTool instances and passes through SDK Tool types directly. + Args: - tools: A sequence of Agent Framework tool objects or dictionaries + tools: A sequence of Agent Framework tool objects, SDK Tool types, or dictionaries defining the tools to be converted. Can be None. Returns: @@ -430,133 +367,54 @@ def to_azure_ai_tools( return azure_tools for tool in tools: - if isinstance(tool, ToolProtocol): - match tool: - case HostedMCPTool(): - azure_tools.append(_prepare_mcp_tool_for_azure_ai(tool)) - case HostedCodeInterpreterTool(): - file_ids: list[str] = [] - if tool.inputs: - for tool_input in tool.inputs: - if tool_input.type == "hosted_file": - file_ids.append(tool_input.file_id) # type: ignore[misc, arg-type] - container = CodeInterpreterToolAuto(file_ids=file_ids if file_ids else None) - ci_tool: CodeInterpreterTool = CodeInterpreterTool(container=container) - azure_tools.append(ci_tool) - case FunctionTool(): - params = tool.parameters() - params["additionalProperties"] = False - azure_tools.append( - AzureFunctionTool( - name=tool.name, - parameters=params, - strict=False, - description=tool.description, - ) - ) - case HostedFileSearchTool(): - if not tool.inputs: - raise ValueError("HostedFileSearchTool requires inputs to be specified.") - vector_store_ids: list[str] = [ - inp.vector_store_id # type: ignore[misc] - for inp in tool.inputs - if inp.type == "hosted_vector_store" - ] - if not vector_store_ids: - raise ValueError( - "HostedFileSearchTool requires inputs to be of type `Content` with " - "type 'hosted_vector_store'." - ) - fs_tool: ProjectsFileSearchTool = ProjectsFileSearchTool(vector_store_ids=vector_store_ids) - if tool.max_results: - fs_tool["max_num_results"] = tool.max_results - azure_tools.append(fs_tool) - case HostedWebSearchTool(): - ws_tool: WebSearchPreviewTool = WebSearchPreviewTool() - if tool.additional_properties: - location: dict[str, str] | None = ( - tool.additional_properties.get("user_location", None) - if tool.additional_properties - else None - ) - if location: - ws_tool.user_location = ApproximateLocation( - city=location.get("city"), - country=location.get("country"), - region=location.get("region"), - timezone=location.get("timezone"), - ) - azure_tools.append(ws_tool) - case HostedImageGenerationTool(): - opts = tool.options or {} - addl = tool.additional_properties or {} - # Azure ImageGenTool requires the constant model "gpt-image-1" - ig_tool: ImageGenTool = ImageGenTool( - model=opts.get("model_id", "gpt-image-1"), # type: ignore - size=cast( - Literal["1024x1024", "1024x1536", "1536x1024", "auto"] | None, opts.get("image_size") - ), - output_format=cast(Literal["png", "webp", "jpeg"] | None, opts.get("media_type")), - input_image_mask=( - ImageGenToolInputImageMask( - image_url=addl.get("input_image_mask", {}).get("image_url"), - file_id=addl.get("input_image_mask", {}).get("file_id"), - ) - if isinstance(addl.get("input_image_mask"), dict) - else None - ), - quality=cast(Literal["low", "medium", "high", "auto"] | None, addl.get("quality")), - background=cast(Literal["transparent", "opaque", "auto"] | None, addl.get("background")), - output_compression=cast(int | None, addl.get("output_compression")), - moderation=cast(Literal["auto", "low"] | None, addl.get("moderation")), - partial_images=opts.get("streaming_count"), - ) - azure_tools.append(ig_tool) - case _: - logger.debug("Unsupported tool passed (type: %s)", type(tool)) + if isinstance(tool, FunctionTool): + params = tool.parameters() + params["additionalProperties"] = False + azure_tools.append( + AzureFunctionTool( + name=tool.name, + parameters=params, + strict=False, + description=tool.description, + ) + ) + elif isinstance(tool, Tool): + # Pass through SDK Tool types directly (CodeInterpreterTool, FileSearchTool, etc.) + azure_tools.append(tool) else: - # Handle raw dictionary tools - tool_dict = tool if isinstance(tool, dict) else dict(tool) - azure_tools.append(tool_dict) + # Pass through dict-based tools directly + azure_tools.append(dict(tool) if isinstance(tool, MutableMapping) else tool) # type: ignore[arg-type] return azure_tools -def _prepare_mcp_tool_for_azure_ai(tool: HostedMCPTool) -> MCPTool: - """Convert HostedMCPTool to Azure AI MCPTool format. +def _prepare_mcp_tool_dict_for_azure_ai(tool_dict: dict[str, Any]) -> MCPTool: + """Convert dict-based MCP tool to Azure AI MCPTool format. Args: - tool: The HostedMCPTool to convert. + tool_dict: The dict-based MCP tool configuration. Returns: MCPTool: The converted Azure AI MCPTool. """ - mcp: MCPTool = MCPTool(server_label=tool.name.replace(" ", "_"), server_url=str(tool.url)) + server_label = tool_dict.get("server_label", "") + server_url = tool_dict.get("server_url", "") + mcp: MCPTool = MCPTool(server_label=server_label, server_url=server_url) - if tool.description: - mcp["server_description"] = tool.description + if description := tool_dict.get("server_description"): + mcp["server_description"] = description - # Check for project_connection_id in additional_properties (for Azure AI Foundry connections) - project_connection_id = _extract_project_connection_id(tool.additional_properties) - if project_connection_id: + # Check for project_connection_id + if project_connection_id := tool_dict.get("project_connection_id"): mcp["project_connection_id"] = project_connection_id - elif tool.headers: - # Only use headers if no project_connection_id is available - # Note: Azure AI Agent Service may reject headers with sensitive info - mcp["headers"] = tool.headers + elif headers := tool_dict.get("headers"): + mcp["headers"] = headers - if tool.allowed_tools: - mcp["allowed_tools"] = list(tool.allowed_tools) + if allowed_tools := tool_dict.get("allowed_tools"): + mcp["allowed_tools"] = list(allowed_tools) - if tool.approval_mode: - match tool.approval_mode: - case str(): - mcp["require_approval"] = "always" if tool.approval_mode == "always_require" else "never" - case _: - if always_require_approvals := tool.approval_mode.get("always_require_approval"): - mcp["require_approval"] = {"always": {"tool_names": list(always_require_approvals)}} - if never_require_approvals := tool.approval_mode.get("never_require_approval"): - mcp["require_approval"] = {"never": {"tool_names": list(never_require_approvals)}} + if require_approval := tool_dict.get("require_approval"): + mcp["require_approval"] = require_approval return mcp diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml index 4efe8ed0b7..33fcbfcaf2 100644 --- a/python/packages/azure-ai/pyproject.toml +++ b/python/packages/azure-ai/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,8 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", - "azure-ai-projects >= 2.0.0b3", + "agent-framework-core>=1.0.0b260212", "azure-ai-agents == 1.2.0b5", "aiohttp", ] diff --git a/python/packages/azure-ai/tests/test_agent_provider.py b/python/packages/azure-ai/tests/test_agent_provider.py index c4bcf0e953..b8755ed7d7 100644 --- a/python/packages/azure-ai/tests/test_agent_provider.py +++ b/python/packages/azure-ai/tests/test_agent_provider.py @@ -6,23 +6,21 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from agent_framework import ( - ChatAgent, - Content, - HostedCodeInterpreterTool, - HostedFileSearchTool, - HostedMCPTool, - HostedWebSearchTool, + Agent, tool, ) from agent_framework.exceptions import ServiceInitializationError from azure.ai.agents.models import ( - Agent, + Agent as AzureAgent, +) +from azure.ai.agents.models import ( CodeInterpreterToolDefinition, ) from azure.identity.aio import AzureCliCredential from pydantic import BaseModel from agent_framework_azure_ai import ( + AzureAIAgentClient, AzureAIAgentsProvider, AzureAISettings, ) @@ -88,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) @@ -156,7 +151,7 @@ async def test_create_agent_basic( mock_agents_client: MagicMock, ) -> None: """Test creating a basic agent.""" - mock_agent = MagicMock(spec=Agent) + mock_agent = MagicMock(spec=AzureAgent) mock_agent.id = "test-agent-id" mock_agent.name = "TestAgent" mock_agent.description = "A test agent" @@ -175,7 +170,7 @@ async def test_create_agent_basic( description="A test agent", ) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) assert agent.name == "TestAgent" assert agent.id == "test-agent-id" mock_agents_client.create_agent.assert_called_once() @@ -186,7 +181,7 @@ async def test_create_agent_with_model( mock_agents_client: MagicMock, ) -> None: """Test creating an agent with explicit model.""" - mock_agent = MagicMock(spec=Agent) + mock_agent = MagicMock(spec=AzureAgent) mock_agent.id = "test-agent-id" mock_agent.name = "TestAgent" mock_agent.description = None @@ -210,7 +205,7 @@ async def test_create_agent_with_tools( mock_agents_client: MagicMock, ) -> None: """Test creating an agent with tools.""" - mock_agent = MagicMock(spec=Agent) + mock_agent = MagicMock(spec=AzureAgent) mock_agent.id = "test-agent-id" mock_agent.name = "TestAgent" mock_agent.description = None @@ -245,7 +240,7 @@ async def test_create_agent_with_response_format( temperature: float description: str - mock_agent = MagicMock(spec=Agent) + mock_agent = MagicMock(spec=AzureAgent) mock_agent.id = "test-agent-id" mock_agent.name = "TestAgent" mock_agent.description = None @@ -272,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) @@ -297,7 +289,7 @@ async def test_get_agent_by_id( mock_agents_client: MagicMock, ) -> None: """Test getting an agent by ID.""" - mock_agent = MagicMock(spec=Agent) + mock_agent = MagicMock(spec=AzureAgent) mock_agent.id = "existing-agent-id" mock_agent.name = "ExistingAgent" mock_agent.description = "An existing agent" @@ -312,7 +304,7 @@ async def test_get_agent_by_id( agent = await provider.get_agent("existing-agent-id") - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) assert agent.id == "existing-agent-id" mock_agents_client.get_agent.assert_called_once_with("existing-agent-id") @@ -327,7 +319,7 @@ async def test_get_agent_with_function_tools( mock_function_tool.function = MagicMock() mock_function_tool.function.name = "get_weather" - mock_agent = MagicMock(spec=Agent) + mock_agent = MagicMock(spec=AzureAgent) mock_agent.id = "agent-with-tools" mock_agent.name = "AgentWithTools" mock_agent.description = None @@ -356,7 +348,7 @@ async def test_get_agent_with_provided_function_tools( mock_function_tool.function = MagicMock() mock_function_tool.function.name = "get_weather" - mock_agent = MagicMock(spec=Agent) + mock_agent = MagicMock(spec=AzureAgent) mock_agent.id = "agent-with-tools" mock_agent.name = "AgentWithTools" mock_agent.description = None @@ -376,7 +368,7 @@ async def test_get_agent_with_provided_function_tools( agent = await provider.get_agent("agent-with-tools", tools=get_weather) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) assert agent.id == "agent-with-tools" @@ -391,7 +383,7 @@ def test_as_agent_wraps_without_http( mock_agents_client: MagicMock, ) -> None: """Test as_agent wraps Agent object without making HTTP calls.""" - mock_agent = MagicMock(spec=Agent) + mock_agent = MagicMock(spec=AzureAgent) mock_agent.id = "wrap-agent-id" mock_agent.name = "WrapAgent" mock_agent.description = "Wrapped agent" @@ -405,7 +397,7 @@ def test_as_agent_wraps_without_http( agent = provider.as_agent(mock_agent) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) assert agent.id == "wrap-agent-id" assert agent.name == "WrapAgent" # Ensure no HTTP calls were made @@ -423,7 +415,7 @@ def test_as_agent_with_function_tools_validates( mock_function_tool.function = MagicMock() mock_function_tool.function.name = "my_function" - mock_agent = MagicMock(spec=Agent) + mock_agent = MagicMock(spec=AzureAgent) mock_agent.id = "agent-id" mock_agent.name = "Agent" mock_agent.description = None @@ -449,7 +441,7 @@ def test_as_agent_with_hosted_tools( mock_code_interpreter = MagicMock() mock_code_interpreter.type = "code_interpreter" - mock_agent = MagicMock(spec=Agent) + mock_agent = MagicMock(spec=AzureAgent) mock_agent.id = "agent-id" mock_agent.name = "Agent" mock_agent.description = None @@ -463,9 +455,10 @@ def test_as_agent_with_hosted_tools( agent = provider.as_agent(mock_agent) - assert isinstance(agent, ChatAgent) - # Should have HostedCodeInterpreterTool in the default_options tools - assert any(isinstance(t, HostedCodeInterpreterTool) for t in (agent.default_options.get("tools") or [])) # type: ignore + assert isinstance(agent, Agent) + # Should have code_interpreter dict tool in the default_options tools + tools = agent.default_options.get("tools") or [] + assert any(isinstance(t, dict) and t.get("type") == "code_interpreter" for t in tools) def test_as_agent_with_dict_function_tools_validates( @@ -483,7 +476,7 @@ def test_as_agent_with_dict_function_tools_validates( }, } - mock_agent = MagicMock(spec=Agent) + mock_agent = MagicMock(spec=AzureAgent) mock_agent.id = "agent-id" mock_agent.name = "Agent" mock_agent.description = None @@ -515,7 +508,7 @@ def test_as_agent_with_dict_function_tools_provided( }, } - mock_agent = MagicMock(spec=Agent) + mock_agent = MagicMock(spec=AzureAgent) mock_agent.id = "agent-id" mock_agent.name = "Agent" mock_agent.description = None @@ -534,7 +527,7 @@ def test_as_agent_with_dict_function_tools_provided( agent = provider.as_agent(mock_agent, tools=dict_based_function) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) assert agent.id == "agent-id" @@ -569,8 +562,8 @@ def test_to_azure_ai_agent_tools_function() -> None: def test_to_azure_ai_agent_tools_code_interpreter() -> None: - """Test converting HostedCodeInterpreterTool.""" - tool = HostedCodeInterpreterTool() + """Test converting code_interpreter dict tool.""" + tool = AzureAIAgentClient.get_code_interpreter_tool() result = to_azure_ai_agent_tools([tool]) @@ -579,8 +572,8 @@ def test_to_azure_ai_agent_tools_code_interpreter() -> None: def test_to_azure_ai_agent_tools_file_search() -> None: - """Test converting HostedFileSearchTool with vector stores.""" - tool = HostedFileSearchTool(inputs=[Content.from_hosted_vector_store(vector_store_id="vs-123")]) + """Test converting file_search dict tool with vector stores.""" + tool = AzureAIAgentClient.get_file_search_tool(vector_store_ids=["vs-123"]) run_options: dict[str, Any] = {} result = to_azure_ai_agent_tools([tool], run_options) @@ -590,15 +583,14 @@ def test_to_azure_ai_agent_tools_file_search() -> None: def test_to_azure_ai_agent_tools_web_search_bing_grounding(monkeypatch: Any) -> None: - """Test converting HostedWebSearchTool for Bing Grounding.""" + """Test converting web_search dict tool for Bing Grounding.""" # Use a properly formatted connection ID as required by Azure SDK valid_conn_id = ( "/subscriptions/test-sub/resourceGroups/test-rg/" "providers/Microsoft.CognitiveServices/accounts/test-account/" "projects/test-project/connections/test-connection" ) - monkeypatch.setenv("BING_CONNECTION_ID", valid_conn_id) - tool = HostedWebSearchTool() + tool = AzureAIAgentClient.get_web_search_tool(bing_connection_id=valid_conn_id) result = to_azure_ai_agent_tools([tool]) @@ -606,10 +598,11 @@ def test_to_azure_ai_agent_tools_web_search_bing_grounding(monkeypatch: Any) -> def test_to_azure_ai_agent_tools_web_search_custom(monkeypatch: Any) -> None: - """Test converting HostedWebSearchTool for Custom Bing Search.""" - monkeypatch.setenv("BING_CUSTOM_CONNECTION_ID", "custom-conn-id") - monkeypatch.setenv("BING_CUSTOM_INSTANCE_NAME", "my-instance") - tool = HostedWebSearchTool() + """Test converting web_search dict tool for Custom Bing Search.""" + tool = AzureAIAgentClient.get_web_search_tool( + bing_custom_connection_id="custom-conn-id", + bing_custom_instance_id="my-instance", + ) result = to_azure_ai_agent_tools([tool]) @@ -617,22 +610,23 @@ def test_to_azure_ai_agent_tools_web_search_custom(monkeypatch: Any) -> None: def test_to_azure_ai_agent_tools_web_search_missing_config(monkeypatch: Any) -> None: - """Test converting HostedWebSearchTool raises error when config is missing.""" + """Test converting web_search dict tool without bing config returns empty.""" monkeypatch.delenv("BING_CONNECTION_ID", raising=False) monkeypatch.delenv("BING_CUSTOM_CONNECTION_ID", raising=False) monkeypatch.delenv("BING_CUSTOM_INSTANCE_NAME", raising=False) - tool = HostedWebSearchTool() + tool = {"type": "web_search"} - with pytest.raises(ServiceInitializationError): - to_azure_ai_agent_tools([tool]) + result = to_azure_ai_agent_tools([tool]) + + # web_search without bing connection is passed through as dict + assert len(result) == 1 def test_to_azure_ai_agent_tools_mcp() -> None: - """Test converting HostedMCPTool.""" - tool = HostedMCPTool( + """Test converting MCP dict tool.""" + tool = AzureAIAgentClient.get_mcp_tool( name="my mcp server", url="https://mcp.example.com", - allowed_tools=["tool1", "tool2"], ) result = to_azure_ai_agent_tools([tool]) @@ -651,13 +645,15 @@ def test_to_azure_ai_agent_tools_dict_passthrough() -> None: def test_to_azure_ai_agent_tools_unsupported_type() -> None: - """Test that unsupported tool types raise error.""" + """Test that unsupported tool types pass through unchanged.""" class UnsupportedTool: pass - with pytest.raises(ServiceInitializationError): - to_azure_ai_agent_tools([UnsupportedTool()]) # type: ignore + unsupported = UnsupportedTool() + result = to_azure_ai_agent_tools([unsupported]) # type: ignore + assert len(result) == 1 + assert result[0] is unsupported # Passed through unchanged # endregion @@ -682,7 +678,7 @@ def test_from_azure_ai_agent_tools_code_interpreter() -> None: result = from_azure_ai_agent_tools([tool]) assert len(result) == 1 - assert isinstance(result[0], HostedCodeInterpreterTool) + assert result[0] == {"type": "code_interpreter"} def test_from_azure_ai_agent_tools_code_interpreter_dict() -> None: @@ -692,7 +688,7 @@ def test_from_azure_ai_agent_tools_code_interpreter_dict() -> None: result = from_azure_ai_agent_tools([tool]) assert len(result) == 1 - assert isinstance(result[0], HostedCodeInterpreterTool) + assert result[0] == {"type": "code_interpreter"} def test_from_azure_ai_agent_tools_file_search_dict() -> None: @@ -705,8 +701,8 @@ def test_from_azure_ai_agent_tools_file_search_dict() -> None: result = from_azure_ai_agent_tools([tool]) assert len(result) == 1 - assert isinstance(result[0], HostedFileSearchTool) - assert len(result[0].inputs or []) == 2 + assert result[0]["type"] == "file_search" + assert result[0]["vector_store_ids"] == ["vs-123", "vs-456"] def test_from_azure_ai_agent_tools_bing_grounding_dict() -> None: @@ -719,12 +715,8 @@ def test_from_azure_ai_agent_tools_bing_grounding_dict() -> None: result = from_azure_ai_agent_tools([tool]) assert len(result) == 1 - assert isinstance(result[0], HostedWebSearchTool) - - additional_properties = result[0].additional_properties - - assert additional_properties - assert additional_properties.get("connection_id") == "conn-123" + assert result[0]["type"] == "bing_grounding" + assert result[0]["connection_id"] == "conn-123" def test_from_azure_ai_agent_tools_bing_custom_search_dict() -> None: @@ -740,11 +732,9 @@ def test_from_azure_ai_agent_tools_bing_custom_search_dict() -> None: result = from_azure_ai_agent_tools([tool]) assert len(result) == 1 - assert isinstance(result[0], HostedWebSearchTool) - additional_properties = result[0].additional_properties - - assert additional_properties - assert additional_properties.get("custom_connection_id") == "custom-conn" + assert result[0]["type"] == "bing_custom_search" + assert result[0]["connection_id"] == "custom-conn" + assert result[0]["instance_name"] == "my-instance" def test_from_azure_ai_agent_tools_mcp_dict() -> None: @@ -810,7 +800,7 @@ async def test_integration_create_agent() -> None: ) try: - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) assert agent.name == "IntegrationTestAgent" assert agent.id is not None finally: @@ -837,7 +827,7 @@ async def test_integration_get_agent() -> None: # Then get it using the provider agent = await provider.get_agent(created.id) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) assert agent.id == created.id finally: await provider._agents_client.delete_agent(created.id) # type: ignore 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 ef1000b12d..8ca3f0dc56 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 @@ -8,23 +8,20 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from agent_framework import ( + Agent, AgentResponse, AgentResponseUpdate, - AgentThread, - ChatAgent, - ChatClientProtocol, - ChatMessage, + AgentSession, ChatOptions, ChatResponse, ChatResponseUpdate, Content, - HostedCodeInterpreterTool, - HostedFileSearchTool, - HostedMCPTool, - HostedWebSearchTool, + Message, + SupportsChatGetResponse, 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, @@ -48,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 @@ -71,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) @@ -82,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 @@ -108,33 +105,35 @@ 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: """Test AzureAIAgentClient initialization with existing agents_client.""" - chat_client = create_test_azure_ai_chat_client( + client = create_test_azure_ai_chat_client( mock_agents_client, agent_id="existing-agent-id", thread_id="test-thread-id" ) - assert chat_client.agents_client is mock_agents_client - assert chat_client.agent_id == "existing-agent-id" - assert chat_client.thread_id == "test-thread-id" - assert isinstance(chat_client, ChatClientProtocol) + assert client.agents_client is mock_agents_client + assert client.agent_id == "existing-agent-id" + assert client.thread_id == "test-thread-id" + assert isinstance(client, SupportsChatGetResponse) def test_azure_ai_chat_client_init_auto_create_client( @@ -142,7 +141,7 @@ 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 chat_client = object.__new__(AzureAIAgentClient) @@ -151,7 +150,7 @@ def test_azure_ai_chat_client_init_auto_create_client( chat_client.thread_id = None chat_client._should_close_client = False # type: ignore chat_client.credential = None - chat_client.model_id = azure_ai_settings.model_deployment_name + chat_client.model_id = azure_ai_settings.get("model_deployment_name") chat_client.agent_name = None chat_client.additional_properties = {} chat_client.middleware = None @@ -163,12 +162,8 @@ def test_azure_ai_chat_client_init_auto_create_client( 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( @@ -183,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( @@ -214,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() @@ -252,16 +229,20 @@ 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"]) - chat_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"], + ) + 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, } - agent_id = await chat_client._get_agent_id_or_create(run_options) # type: ignore + agent_id = await client._get_agent_id_or_create(run_options) # type: ignore assert agent_id == "test-agent-id" # Verify create_agent was called with temperature and top_p parameters @@ -275,12 +256,12 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_existing_agent( mock_agents_client: MagicMock, ) -> None: """Test _get_agent_id_or_create when agent_id is already provided.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="existing-agent-id") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="existing-agent-id") - agent_id = await chat_client._get_agent_id_or_create() # type: ignore + agent_id = await client._get_agent_id_or_create() # type: ignore assert agent_id == "existing-agent-id" - assert not chat_client._agent_created + assert not client._agent_created async def test_azure_ai_chat_client_get_agent_id_or_create_create_new( @@ -288,10 +269,16 @@ 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"]) + 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 chat_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 chat_client._agent_created @@ -299,7 +286,7 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_create_new( async def test_azure_ai_chat_client_thread_management_through_public_api(mock_agents_client: MagicMock) -> None: """Test thread creation and management through public API.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Mock get_agent to avoid the async error mock_agents_client.get_agent = AsyncMock(return_value=None) @@ -319,10 +306,10 @@ async def test_azure_ai_chat_client_thread_management_through_public_api(mock_ag mock_stream.__aenter__ = AsyncMock(return_value=empty_async_iter()) mock_stream.__aexit__ = AsyncMock(return_value=None) - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] # Call without existing thread - should create new one - response = chat_client.get_response(messages, stream=True) + response = client.get_response(messages, stream=True) # Consume the generator to trigger the method execution async for _ in response: pass @@ -336,20 +323,20 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_missing_model( mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] ) -> None: """Test _get_agent_id_or_create when model_deployment_name is missing.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) with pytest.raises(ServiceInitializationError, match="Model deployment name is required"): - await chat_client._get_agent_id_or_create() # type: ignore + await client._get_agent_id_or_create() # type: ignore async def test_azure_ai_chat_client_prepare_options_basic(mock_agents_client: MagicMock) -> None: """Test _prepare_options with basic ChatOptions.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] chat_options: ChatOptions = {"max_tokens": 100, "temperature": 0.7} - run_options, tool_results = await chat_client._prepare_options(messages, chat_options) # type: ignore + run_options, tool_results = await client._prepare_options(messages, chat_options) # type: ignore assert run_options is not None assert tool_results is None @@ -357,11 +344,11 @@ async def test_azure_ai_chat_client_prepare_options_basic(mock_agents_client: Ma async def test_azure_ai_chat_client_prepare_options_no_chat_options(mock_agents_client: MagicMock) -> None: """Test _prepare_options with default ChatOptions.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] - run_options, tool_results = await chat_client._prepare_options(messages, {}) # type: ignore + run_options, tool_results = await client._prepare_options(messages, {}) # type: ignore assert run_options is not None assert tool_results is None @@ -370,15 +357,15 @@ async def test_azure_ai_chat_client_prepare_options_no_chat_options(mock_agents_ async def test_azure_ai_chat_client_prepare_options_with_image_content(mock_agents_client: MagicMock) -> None: """Test _prepare_options with image content.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Mock get_agent mock_agents_client.get_agent = AsyncMock(return_value=None) image_content = Content.from_uri(uri="https://example.com/image.jpg", media_type="image/jpeg") - messages = [ChatMessage(role="user", contents=[image_content])] + messages = [Message(role="user", contents=[image_content])] - run_options, _ = await chat_client._prepare_options(messages, {}) # type: ignore + run_options, _ = await client._prepare_options(messages, {}) # type: ignore assert "additional_messages" in run_options assert len(run_options["additional_messages"]) == 1 @@ -389,9 +376,9 @@ async def test_azure_ai_chat_client_prepare_options_with_image_content(mock_agen def test_azure_ai_chat_client_prepare_tool_outputs_for_azure_ai_none(mock_agents_client: MagicMock) -> None: """Test _prepare_tool_outputs_for_azure_ai with None input.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) - run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai(None) # type: ignore + run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai(None) # type: ignore assert run_id is None assert tool_outputs is None @@ -400,22 +387,22 @@ def test_azure_ai_chat_client_prepare_tool_outputs_for_azure_ai_none(mock_agents async def test_azure_ai_chat_client_close_client_when_should_close_true(mock_agents_client: MagicMock) -> None: """Test _close_client_if_needed closes agents_client when should_close_client is True.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) - chat_client._should_close_client = True # type: ignore + client = create_test_azure_ai_chat_client(mock_agents_client) + client._should_close_client = True # type: ignore mock_agents_client.close = AsyncMock() - await chat_client._close_client_if_needed() # type: ignore + await client._close_client_if_needed() # type: ignore mock_agents_client.close.assert_called_once() async def test_azure_ai_chat_client_close_client_when_should_close_false(mock_agents_client: MagicMock) -> None: """Test _close_client_if_needed does not close agents_client when should_close_client is False.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) - chat_client._should_close_client = False # type: ignore + client = create_test_azure_ai_chat_client(mock_agents_client) + client._should_close_client = False # type: ignore - await chat_client._close_client_if_needed() # type: ignore + await client._close_client_if_needed() # type: ignore mock_agents_client.close.assert_not_called() @@ -424,52 +411,52 @@ def test_azure_ai_chat_client_update_agent_name_and_description_when_current_is_ mock_agents_client: MagicMock, ) -> None: """Test _update_agent_name_and_description updates name when current agent_name is None.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) - chat_client.agent_name = None # type: ignore + client = create_test_azure_ai_chat_client(mock_agents_client) + client.agent_name = None # type: ignore - chat_client._update_agent_name_and_description("NewAgentName", "description") # type: ignore + client._update_agent_name_and_description("NewAgentName", "description") # type: ignore - assert chat_client.agent_name == "NewAgentName" - assert chat_client.agent_description == "description" + assert client.agent_name == "NewAgentName" + assert client.agent_description == "description" def test_azure_ai_chat_client_update_agent_name_and_description_when_current_exists( mock_agents_client: MagicMock, ) -> None: """Test _update_agent_name_and_description does not update when current agent_name exists.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) - chat_client.agent_name = "ExistingName" # type: ignore - chat_client.agent_description = "ExistingDescription" # type: ignore + client = create_test_azure_ai_chat_client(mock_agents_client) + client.agent_name = "ExistingName" # type: ignore + client.agent_description = "ExistingDescription" # type: ignore - chat_client._update_agent_name_and_description("NewAgentName", "description") # type: ignore + client._update_agent_name_and_description("NewAgentName", "description") # type: ignore - assert chat_client.agent_name == "ExistingName" - assert chat_client.agent_description == "ExistingDescription" + assert client.agent_name == "ExistingName" + assert client.agent_description == "ExistingDescription" def test_azure_ai_chat_client_update_agent_name_and_description_with_none_input(mock_agents_client: MagicMock) -> None: """Test _update_agent_name_and_description with None input.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) - chat_client.agent_name = None # type: ignore - chat_client.agent_description = None # type: ignore + client = create_test_azure_ai_chat_client(mock_agents_client) + client.agent_name = None # type: ignore + client.agent_description = None # type: ignore - chat_client._update_agent_name_and_description(None, None) # type: ignore + client._update_agent_name_and_description(None, None) # type: ignore - assert chat_client.agent_name is None - assert chat_client.agent_description is None + assert client.agent_name is None + assert client.agent_description is None async def test_azure_ai_chat_client_prepare_options_with_messages(mock_agents_client: MagicMock) -> None: """Test _prepare_options with different message types.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) # Test with system message (becomes instruction) messages = [ - ChatMessage(role="system", text="You are a helpful assistant"), - ChatMessage(role="user", text="Hello"), + Message(role="system", text="You are a helpful assistant"), + Message(role="user", text="Hello"), ] - run_options, _ = await chat_client._prepare_options(messages, {}) # type: ignore + run_options, _ = await client._prepare_options(messages, {}) # type: ignore assert "instructions" in run_options assert "You are a helpful assistant" in run_options["instructions"] @@ -485,15 +472,15 @@ async def test_azure_ai_chat_client_prepare_options_with_instructions_from_optio This verifies that agent instructions set via as_agent(instructions=...) are properly included in the API call. """ - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") mock_agents_client.get_agent = AsyncMock(return_value=None) - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] chat_options: ChatOptions = { "instructions": "You are a thoughtful reviewer. Give brief feedback.", } - run_options, _ = await chat_client._prepare_options(messages, chat_options) # type: ignore + run_options, _ = await client._prepare_options(messages, chat_options) # type: ignore assert "instructions" in run_options assert "reviewer" in run_options["instructions"].lower() @@ -507,18 +494,18 @@ async def test_azure_ai_chat_client_prepare_options_merges_instructions_from_mes When instructions come from both system/developer messages AND from options, both should be included in the final instructions. """ - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") mock_agents_client.get_agent = AsyncMock(return_value=None) messages = [ - ChatMessage(role="system", text="Context: You are reviewing marketing copy."), - ChatMessage(role="user", text="Review this tagline"), + Message(role="system", text="Context: You are reviewing marketing copy."), + Message(role="user", text="Review this tagline"), ] chat_options: ChatOptions = { "instructions": "Be concise and constructive in your feedback.", } - run_options, _ = await chat_client._prepare_options(messages, chat_options) # type: ignore + run_options, _ = await client._prepare_options(messages, chat_options) # type: ignore assert "instructions" in run_options instructions_text = run_options["instructions"] @@ -529,16 +516,16 @@ async def test_azure_ai_chat_client_prepare_options_merges_instructions_from_mes async def test_azure_ai_chat_client_inner_get_response(mock_agents_client: MagicMock) -> None: """Test _inner_get_response method.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") async def mock_streaming_response(): yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("Hello back")]) with ( - patch.object(chat_client, "_inner_get_response", return_value=mock_streaming_response()), + patch.object(client, "_inner_get_response", return_value=mock_streaming_response()), patch("agent_framework.ChatResponse.from_update_generator") as mock_from_generator, ): - mock_response = ChatResponse(messages=[ChatMessage(role="assistant", text="Hello back")]) + mock_response = ChatResponse(messages=[Message(role="assistant", text="Hello back")]) mock_from_generator.return_value = mock_response result = await ChatResponse.from_update_generator(mock_streaming_response()) @@ -551,17 +538,21 @@ 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"]) - chat_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"], + ) + 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 chat_client._get_agent_id_or_create(run_options) # type: ignore + agent_id = await client._get_agent_id_or_create(run_options) # type: ignore assert agent_id == "test-agent-id" # Verify create_agent was called with run_options parameters @@ -574,7 +565,7 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_with_run_options( async def test_azure_ai_chat_client_prepare_thread_cancels_active_run(mock_agents_client: MagicMock) -> None: """Test _prepare_thread cancels active thread run when provided.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") mock_thread_run = MagicMock() mock_thread_run.id = "run_123" @@ -582,7 +573,7 @@ async def test_azure_ai_chat_client_prepare_thread_cancels_active_run(mock_agent run_options = {"additional_messages": []} # type: ignore - result = await chat_client._prepare_thread("test-thread", mock_thread_run, run_options) # type: ignore + result = await client._prepare_thread("test-thread", mock_thread_run, run_options) # type: ignore assert result == "test-thread" mock_agents_client.runs.cancel.assert_called_once_with("test-thread", "run_123") @@ -590,7 +581,7 @@ async def test_azure_ai_chat_client_prepare_thread_cancels_active_run(mock_agent def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_basic(mock_agents_client: MagicMock) -> None: """Test _parse_function_calls_from_azure_ai with basic function call.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) mock_tool_call = MagicMock(spec=RequiredFunctionToolCall) mock_tool_call.id = "call_123" @@ -603,7 +594,7 @@ def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_basic(mock_agen mock_event_data = MagicMock(spec=ThreadRun) mock_event_data.required_action = mock_submit_action - result = chat_client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore + result = client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore assert len(result) == 1 assert result[0].type == "function_call" @@ -615,12 +606,12 @@ def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_no_submit_actio mock_agents_client: MagicMock, ) -> None: """Test _parse_function_calls_from_azure_ai when required_action is not SubmitToolOutputsAction.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) mock_event_data = MagicMock(spec=ThreadRun) mock_event_data.required_action = MagicMock() - result = chat_client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore + result = client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore assert result == [] @@ -629,7 +620,7 @@ def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_non_function_to mock_agents_client: MagicMock, ) -> None: """Test _parse_function_calls_from_azure_ai with non-function tool call.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) mock_tool_call = MagicMock() @@ -639,7 +630,7 @@ def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_non_function_to mock_event_data = MagicMock(spec=ThreadRun) mock_event_data.required_action = mock_submit_action - result = chat_client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore + result = client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore assert result == [] @@ -648,11 +639,11 @@ async def test_azure_ai_chat_client_prepare_options_with_none_tool_choice( mock_agents_client: MagicMock, ) -> None: """Test _prepare_options with tool_choice set to 'none'.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) chat_options: ChatOptions = {"tool_choice": "none"} - run_options, _ = await chat_client._prepare_options([], chat_options) # type: ignore + run_options, _ = await client._prepare_options([], chat_options) # type: ignore assert run_options["tool_choice"] == AgentsToolChoiceOptionMode.NONE @@ -661,11 +652,11 @@ async def test_azure_ai_chat_client_prepare_options_with_auto_tool_choice( mock_agents_client: MagicMock, ) -> None: """Test _prepare_options with tool_choice set to 'auto'.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) chat_options = {"tool_choice": "auto"} - run_options, _ = await chat_client._prepare_options([], chat_options) # type: ignore + run_options, _ = await client._prepare_options([], chat_options) # type: ignore assert run_options["tool_choice"] == AgentsToolChoiceOptionMode.AUTO @@ -674,16 +665,16 @@ async def test_azure_ai_chat_client_prepare_options_tool_choice_required_specifi mock_agents_client: MagicMock, ) -> None: """Test _prepare_options with required tool_choice specifying a specific function name.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) required_tool_mode = {"mode": "required", "required_function_name": "specific_function_name"} dict_tool = {"type": "function", "function": {"name": "test_function"}} chat_options = {"tools": [dict_tool], "tool_choice": required_tool_mode} - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] - run_options, _ = await chat_client._prepare_options(messages, chat_options) # type: ignore + run_options, _ = await client._prepare_options(messages, chat_options) # type: ignore # Verify tool_choice is set to the specific named function assert "tool_choice" in run_options @@ -697,14 +688,14 @@ async def test_azure_ai_chat_client_prepare_options_with_response_format( mock_agents_client: MagicMock, ) -> None: """Test _prepare_options with response_format configured.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) class TestResponseModel(BaseModel): name: str = Field(description="Test name") chat_options: ChatOptions = {"response_format": TestResponseModel} - run_options, _ = await chat_client._prepare_options([], chat_options) # type: ignore + run_options, _ = await client._prepare_options([], chat_options) # type: ignore assert "response_format" in run_options response_format = run_options["response_format"] @@ -714,155 +705,138 @@ async def test_azure_ai_chat_client_prepare_options_with_response_format( def test_azure_ai_chat_client_service_url_method(mock_agents_client: MagicMock) -> None: """Test service_url method returns endpoint.""" mock_agents_client._config.endpoint = "https://test-endpoint.com/" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) - url = chat_client.service_url() + url = client.service_url() assert url == "https://test-endpoint.com/" async def test_azure_ai_chat_client_prepare_options_mcp_never_require(mock_agents_client: MagicMock) -> None: - """Test _prepare_options with HostedMCPTool having never_require approval mode.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + """Test _prepare_options with MCP dict tool having never_require approval mode.""" + client = create_test_azure_ai_chat_client(mock_agents_client) - mcp_tool = HostedMCPTool(name="Test MCP Tool", url="https://example.com/mcp", approval_mode="never_require") + # Create MCP tool with approval_mode parameter + mcp_tool = AzureAIAgentClient.get_mcp_tool( + name="Test MCP Tool", url="https://example.com/mcp", approval_mode="never_require" + ) - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] chat_options: ChatOptions = {"tools": [mcp_tool], "tool_choice": "auto"} - with patch("agent_framework_azure_ai._shared.McpTool") as mock_mcp_tool_class: - mock_mcp_tool_instance = MagicMock() - mock_mcp_tool_instance.definitions = [{"type": "mcp", "name": "test_mcp"}] - mock_mcp_tool_class.return_value = mock_mcp_tool_instance + run_options, _ = await client._prepare_options(messages, chat_options) # type: ignore - run_options, _ = await chat_client._prepare_options(messages, chat_options) # type: ignore + # Verify tool_resources is created with correct MCP approval structure + assert "tool_resources" in run_options, f"Expected 'tool_resources' in run_options keys: {list(run_options.keys())}" + assert "mcp" in run_options["tool_resources"] + assert len(run_options["tool_resources"]["mcp"]) == 1 - # Verify tool_resources is created with correct MCP approval structure - assert "tool_resources" in run_options, ( - f"Expected 'tool_resources' in run_options keys: {list(run_options.keys())}" - ) - assert "mcp" in run_options["tool_resources"] - assert len(run_options["tool_resources"]["mcp"]) == 1 - - mcp_resource = run_options["tool_resources"]["mcp"][0] - assert mcp_resource["server_label"] == "Test_MCP_Tool" - assert mcp_resource["require_approval"] == "never" + mcp_resource = run_options["tool_resources"]["mcp"][0] + assert mcp_resource["server_label"] == "Test_MCP_Tool" + assert mcp_resource["require_approval"] == "never" async def test_azure_ai_chat_client_prepare_options_mcp_with_headers(mock_agents_client: MagicMock) -> None: - """Test _prepare_options with HostedMCPTool having headers.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + """Test _prepare_options with MCP dict tool having headers.""" + client = create_test_azure_ai_chat_client(mock_agents_client) - # Test with headers + # Test with headers - create MCP tool with all options headers = {"Authorization": "Bearer DUMMY_TOKEN", "X-API-Key": "DUMMY_KEY"} - mcp_tool = HostedMCPTool( - name="Test MCP Tool", url="https://example.com/mcp", headers=headers, approval_mode="never_require" + mcp_tool = AzureAIAgentClient.get_mcp_tool( + name="Test MCP Tool", + url="https://example.com/mcp", + headers=headers, + approval_mode="never_require", ) - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] chat_options: ChatOptions = {"tools": [mcp_tool], "tool_choice": "auto"} - with patch("agent_framework_azure_ai._shared.McpTool") as mock_mcp_tool_class: - mock_mcp_tool_instance = MagicMock() - mock_mcp_tool_instance.definitions = [{"type": "mcp", "name": "test_mcp"}] - mock_mcp_tool_class.return_value = mock_mcp_tool_instance + run_options, _ = await client._prepare_options(messages, chat_options) # type: ignore - run_options, _ = await chat_client._prepare_options(messages, chat_options) # type: ignore + # Verify tool_resources is created with headers + assert "tool_resources" in run_options + assert "mcp" in run_options["tool_resources"] + assert len(run_options["tool_resources"]["mcp"]) == 1 - # Verify tool_resources is created with headers - assert "tool_resources" in run_options - assert "mcp" in run_options["tool_resources"] - assert len(run_options["tool_resources"]["mcp"]) == 1 - - mcp_resource = run_options["tool_resources"]["mcp"][0] - assert mcp_resource["server_label"] == "Test_MCP_Tool" - assert mcp_resource["require_approval"] == "never" - assert mcp_resource["headers"] == headers + mcp_resource = run_options["tool_resources"]["mcp"][0] + assert mcp_resource["server_label"] == "Test_MCP_Tool" + assert mcp_resource["require_approval"] == "never" + assert mcp_resource["headers"] == headers async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_bing_grounding( mock_agents_client: MagicMock, ) -> None: - """Test _prepare_tools_for_azure_ai with HostedWebSearchTool using Bing Grounding.""" + """Test _prepare_tools_for_azure_ai with BingGroundingTool from get_web_search_tool().""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - web_search_tool = HostedWebSearchTool( - additional_properties={ - "connection_id": "test-connection-id", - "count": 5, - "freshness": "Day", - "market": "en-US", - "set_lang": "en", - } - ) - - # Mock BingGroundingTool + # Mock BingGroundingTool to avoid SDK validation of connection ID with patch("agent_framework_azure_ai._chat_client.BingGroundingTool") as mock_bing_grounding: mock_bing_tool = MagicMock() mock_bing_tool.definitions = [{"type": "bing_grounding"}] mock_bing_grounding.return_value = mock_bing_tool - result = await chat_client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore + # get_web_search_tool now returns a BingGroundingTool directly + web_search_tool = client.get_web_search_tool(bing_connection_id="test-connection-id") + # Verify the factory method created the tool with correct args + mock_bing_grounding.assert_called_once_with(connection_id="test-connection-id") + + result = await client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore + + # BingGroundingTool.definitions should be extended into result assert len(result) == 1 assert result[0] == {"type": "bing_grounding"} - call_args = mock_bing_grounding.call_args[1] - assert call_args["count"] == 5 - assert call_args["freshness"] == "Day" - assert call_args["market"] == "en-US" - assert call_args["set_lang"] == "en" - assert "connection_id" in call_args async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_bing_grounding_with_connection_id( mock_agents_client: MagicMock, ) -> None: - """Test _prepare_tools_... with HostedWebSearchTool using Bing Grounding with connection_id (no HTTP call).""" + """Test _prepare_tools_for_azure_ai with BingGroundingTool using explicit connection_id.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - web_search_tool = HostedWebSearchTool( - additional_properties={ - "connection_id": "direct-connection-id", - "count": 3, - } - ) - - # Mock BingGroundingTool + # Mock BingGroundingTool to avoid SDK validation of connection ID with patch("agent_framework_azure_ai._chat_client.BingGroundingTool") as mock_bing_grounding: mock_bing_tool = MagicMock() mock_bing_tool.definitions = [{"type": "bing_grounding"}] mock_bing_grounding.return_value = mock_bing_tool - result = await chat_client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore + web_search_tool = client.get_web_search_tool(bing_connection_id="direct-connection-id") + + mock_bing_grounding.assert_called_once_with(connection_id="direct-connection-id") + + result = await client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore assert len(result) == 1 assert result[0] == {"type": "bing_grounding"} - mock_bing_grounding.assert_called_once_with(connection_id="direct-connection-id", count=3) async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_custom_bing( mock_agents_client: MagicMock, ) -> None: - """Test _prepare_tools_for_azure_ai with HostedWebSearchTool using Custom Bing Search.""" + """Test _prepare_tools_for_azure_ai with BingCustomSearchTool from get_web_search_tool().""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - web_search_tool = HostedWebSearchTool( - additional_properties={ - "custom_connection_id": "custom-connection-id", - "custom_instance_name": "custom-instance", - "count": 10, - } - ) - - # Mock BingCustomSearchTool + # Mock BingCustomSearchTool to avoid SDK validation with patch("agent_framework_azure_ai._chat_client.BingCustomSearchTool") as mock_custom_bing: mock_custom_tool = MagicMock() mock_custom_tool.definitions = [{"type": "bing_custom_search"}] mock_custom_bing.return_value = mock_custom_tool - result = await chat_client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore + web_search_tool = client.get_web_search_tool( + bing_custom_connection_id="custom-connection-id", + bing_custom_instance_id="custom-instance", + ) + + mock_custom_bing.assert_called_once_with( + connection_id="custom-connection-id", + instance_name="custom-instance", + ) + + result = await client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore assert len(result) == 1 assert result[0] == {"type": "bing_custom_search"} @@ -871,40 +845,32 @@ async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_custom async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_file_search_with_vector_stores( mock_agents_client: MagicMock, ) -> None: - """Test _prepare_tools_for_azure_ai with HostedFileSearchTool using vector stores.""" + """Test _prepare_tools_for_azure_ai with FileSearchTool from get_file_search_tool().""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - vector_store_input = Content.from_hosted_vector_store(vector_store_id="vs-123") - file_search_tool = HostedFileSearchTool(inputs=[vector_store_input]) + # get_file_search_tool() now returns a FileSearchTool instance directly + file_search_tool = client.get_file_search_tool(vector_store_ids=["vs-123"]) - # Mock FileSearchTool - with patch("agent_framework_azure_ai._chat_client.FileSearchTool") as mock_file_search: - mock_file_tool = MagicMock() - mock_file_tool.definitions = [{"type": "file_search"}] - mock_file_tool.resources = {"vector_store_ids": ["vs-123"]} - mock_file_search.return_value = mock_file_tool + run_options: dict[str, Any] = {} + result = await client._prepare_tools_for_azure_ai([file_search_tool], run_options) # type: ignore - run_options = {} - result = await chat_client._prepare_tools_for_azure_ai([file_search_tool], run_options) # type: ignore - - assert len(result) == 1 - assert result[0] == {"type": "file_search"} - assert run_options["tool_resources"] == {"vector_store_ids": ["vs-123"]} - mock_file_search.assert_called_once_with(vector_store_ids=["vs-123"]) + assert len(result) == 1 + assert result[0] == {"type": "file_search"} + assert run_options["tool_resources"] == {"file_search": {"vector_store_ids": ["vs-123"]}} async def test_azure_ai_chat_client_create_agent_stream_submit_tool_approvals( mock_agents_client: MagicMock, ) -> None: """Test _create_agent_stream with tool approvals submission path.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Mock active thread run that matches the tool run ID mock_thread_run = MagicMock() mock_thread_run.thread_id = "test-thread" mock_thread_run.id = "test-run-id" - chat_client._get_active_thread_run = AsyncMock(return_value=mock_thread_run) # type: ignore + client._get_active_thread_run = AsyncMock(return_value=mock_thread_run) # type: ignore # Mock required action results with approval response that matches run ID approval_response = Content.from_function_approval_response( @@ -920,7 +886,7 @@ async def test_azure_ai_chat_client_create_agent_stream_submit_tool_approvals( mock_agents_client.runs.submit_tool_outputs_stream = AsyncMock() with patch("azure.ai.agents.models.AsyncAgentEventHandler", return_value=mock_handler): - stream, final_thread_id = await chat_client._create_agent_stream( # type: ignore + stream, final_thread_id = await client._create_agent_stream( # type: ignore "test-agent", {"thread_id": "test-thread"}, [approval_response] ) @@ -938,7 +904,7 @@ async def test_azure_ai_chat_client_create_agent_stream_submit_tool_approvals( async def test_azure_ai_chat_client_get_active_thread_run_with_active_run(mock_agents_client: MagicMock) -> None: """Test _get_active_thread_run when there's an active run.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Mock an active run mock_run = MagicMock() @@ -949,7 +915,7 @@ async def test_azure_ai_chat_client_get_active_thread_run_with_active_run(mock_a mock_agents_client.runs.list = mock_list_runs - result = await chat_client._get_active_thread_run("thread-123") # type: ignore + result = await client._get_active_thread_run("thread-123") # type: ignore assert result == mock_run @@ -957,7 +923,7 @@ async def test_azure_ai_chat_client_get_active_thread_run_with_active_run(mock_a async def test_azure_ai_chat_client_get_active_thread_run_no_active_run(mock_agents_client: MagicMock) -> None: """Test _get_active_thread_run when there's no active run.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Mock a completed run (not active) mock_run = MagicMock() @@ -968,16 +934,16 @@ async def test_azure_ai_chat_client_get_active_thread_run_no_active_run(mock_age mock_agents_client.runs.list = mock_list_runs - result = await chat_client._get_active_thread_run("thread-123") # type: ignore + result = await client._get_active_thread_run("thread-123") # type: ignore assert result is None async def test_azure_ai_chat_client_get_active_thread_run_no_thread(mock_agents_client: MagicMock) -> None: """Test _get_active_thread_run with None thread_id.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - result = await chat_client._get_active_thread_run(None) # type: ignore + result = await client._get_active_thread_run(None) # type: ignore assert result is None # Should not call list since thread_id is None @@ -986,14 +952,14 @@ async def test_azure_ai_chat_client_get_active_thread_run_no_thread(mock_agents_ async def test_azure_ai_chat_client_service_url(mock_agents_client: MagicMock) -> None: """Test service_url method.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Mock the config endpoint mock_config = MagicMock() mock_config.endpoint = "https://test-endpoint.com/" mock_agents_client._config = mock_config - result = chat_client.service_url() + result = client.service_url() assert result == "https://test-endpoint.com/" @@ -1002,12 +968,12 @@ async def test_azure_ai_chat_client_prepare_tool_outputs_for_azure_tool_result( mock_agents_client: MagicMock, ) -> None: """Test _prepare_tool_outputs_for_azure_ai with FunctionResultContent.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Test with simple result function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result="Simple result") - run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore + run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore assert run_id == "run_123" assert tool_approvals is None @@ -1020,25 +986,25 @@ async def test_azure_ai_chat_client_prepare_tool_outputs_for_azure_tool_result( async def test_azure_ai_chat_client_convert_required_action_invalid_call_id(mock_agents_client: MagicMock) -> None: """Test _prepare_tool_outputs_for_azure_ai with invalid call_id format.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Invalid call_id format - should raise JSONDecodeError function_result = Content.from_function_result(call_id="invalid_json", result="result") with pytest.raises(json.JSONDecodeError): - chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore + client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore async def test_azure_ai_chat_client_convert_required_action_invalid_structure( mock_agents_client: MagicMock, ) -> None: """Test _prepare_tool_outputs_for_azure_ai with invalid call_id structure.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Valid JSON but invalid structure (missing second element) function_result = Content.from_function_result(call_id='["run_123"]', result="result") - run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore + run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore # Should return None values when structure is invalid assert run_id is None @@ -1056,21 +1022,21 @@ async def test_azure_ai_chat_client_convert_required_action_serde_model_results( self.name = name self.value = value - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - # Test with BaseModel result + # Test with BaseModel result (pre-parsed as it would be from FunctionTool.invoke) mock_result = MockResult(name="test", value=42) - function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=mock_result) + expected_json = mock_result.to_json() + function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=expected_json) - run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore + run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore assert run_id == "run_123" assert tool_approvals is None assert tool_outputs is not None assert len(tool_outputs) == 1 assert tool_outputs[0].tool_call_id == "call_456" - # Should use model_dump_json for BaseModel - expected_json = mock_result.to_json() + # Should use pre-parsed result string directly assert tool_outputs[0].output == expected_json @@ -1083,35 +1049,33 @@ async def test_azure_ai_chat_client_convert_required_action_multiple_results( def __init__(self, data: str): self.data = data - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - # Test with multiple results - mix of BaseModel and regular objects + # Test with multiple results - pre-parsed as FunctionTool.invoke would produce mock_basemodel = MockResult(data="model_data") results_list = [mock_basemodel, {"key": "value"}, "string_result"] - function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=results_list) + # FunctionTool.parse_result would serialize this to a JSON string + from agent_framework import FunctionTool - run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore + pre_parsed = FunctionTool.parse_result(results_list) + function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=pre_parsed) + + run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore assert run_id == "run_123" assert tool_outputs is not None assert len(tool_outputs) == 1 assert tool_outputs[0].tool_call_id == "call_456" - # Should JSON dump the entire results array since len > 1 - expected_results = [ - mock_basemodel.to_dict(), - {"key": "value"}, - "string_result", - ] - expected_output = json.dumps(expected_results) - assert tool_outputs[0].output == expected_output + # Result is pre-parsed string (already JSON) + assert tool_outputs[0].output == pre_parsed async def test_azure_ai_chat_client_convert_required_action_approval_response( mock_agents_client: MagicMock, ) -> None: """Test _prepare_tool_outputs_for_azure_ai with FunctionApprovalResponseContent.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Test with approval response - need to provide required fields approval_response = Content.from_function_approval_response( @@ -1122,7 +1086,7 @@ async def test_azure_ai_chat_client_convert_required_action_approval_response( approved=True, ) - run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([approval_response]) # type: ignore + run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([approval_response]) # type: ignore assert run_id == "run_123" assert tool_outputs is None @@ -1136,7 +1100,7 @@ async def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_approval_ mock_agents_client: MagicMock, ) -> None: """Test _parse_function_calls_from_azure_ai with approval action.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Mock SubmitToolApprovalAction with RequiredMcpToolCall mock_tool_call = MagicMock(spec=RequiredMcpToolCall) @@ -1150,7 +1114,7 @@ async def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_approval_ mock_event_data = MagicMock(spec=ThreadRun) mock_event_data.required_action = mock_approval_action - result = chat_client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore + result = client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore assert len(result) == 1 assert result[0].type == "function_approval_request" @@ -1163,13 +1127,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"]) - chat_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"], + ) + 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 - chat_client.agent_name = None # type: ignore + client.agent_name = None # type: ignore - agent_id = await chat_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" @@ -1182,13 +1152,17 @@ 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"]) - chat_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"], + ) + 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 chat_client._get_agent_id_or_create(run_options) # type: ignore + agent_id = await client._get_agent_id_or_create(run_options) # type: ignore assert agent_id == "test-agent-id" # Verify create_agent was called with response_format @@ -1201,16 +1175,20 @@ 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"]) - chat_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"], + ) + 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 chat_client._get_agent_id_or_create(run_options) # type: ignore + agent_id = await client._get_agent_id_or_create(run_options) # type: ignore assert agent_id == "test-agent-id" # Verify create_agent was called with tool_resources @@ -1223,13 +1201,13 @@ async def test_azure_ai_chat_client_create_agent_stream_submit_tool_outputs( mock_agents_client: MagicMock, ) -> None: """Test _create_agent_stream with tool outputs submission path.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Mock active thread run that matches the tool run ID mock_thread_run = MagicMock() mock_thread_run.thread_id = "test-thread" mock_thread_run.id = "test-run-id" - chat_client._get_active_thread_run = AsyncMock(return_value=mock_thread_run) # type: ignore + client._get_active_thread_run = AsyncMock(return_value=mock_thread_run) # type: ignore # Mock required action results with matching run ID function_result = Content.from_function_result(call_id='["test-run-id", "test-call-id"]', result="test result") @@ -1239,7 +1217,7 @@ async def test_azure_ai_chat_client_create_agent_stream_submit_tool_outputs( mock_agents_client.runs.submit_tool_outputs_stream = AsyncMock() with patch("azure.ai.agents.models.AsyncAgentEventHandler", return_value=mock_handler): - stream, final_thread_id = await chat_client._create_agent_stream( # type: ignore + stream, final_thread_id = await client._create_agent_stream( # type: ignore agent_id="test-agent", run_options={"thread_id": "test-thread"}, required_action_results=[function_result] ) @@ -1250,7 +1228,7 @@ async def test_azure_ai_chat_client_create_agent_stream_submit_tool_outputs( def test_azure_ai_chat_client_extract_url_citations_with_citations(mock_agents_client: MagicMock) -> None: """Test _extract_url_citations with MessageDeltaChunk containing URL citations.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Create mock URL citation annotation mock_url_citation = MagicMock() @@ -1278,7 +1256,7 @@ def test_azure_ai_chat_client_extract_url_citations_with_citations(mock_agents_c mock_chunk.delta = mock_delta # Call the method with empty azure_search_tool_calls - citations = chat_client._extract_url_citations(mock_chunk, []) # type: ignore + citations = client._extract_url_citations(mock_chunk, []) # type: ignore # Verify results assert len(citations) == 1 @@ -1296,7 +1274,7 @@ def test_azure_ai_chat_client_extract_file_path_contents_with_file_path_annotati mock_agents_client: MagicMock, ) -> None: """Test _extract_file_path_contents with MessageDeltaChunk containing file path annotation.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Create mock file_path annotation mock_file_path = MagicMock() @@ -1321,7 +1299,7 @@ def test_azure_ai_chat_client_extract_file_path_contents_with_file_path_annotati mock_chunk.delta = mock_delta # Call the method - file_contents = chat_client._extract_file_path_contents(mock_chunk) + file_contents = client._extract_file_path_contents(mock_chunk) # Verify results assert len(file_contents) == 1 @@ -1333,7 +1311,7 @@ def test_azure_ai_chat_client_extract_file_path_contents_with_file_citation_anno mock_agents_client: MagicMock, ) -> None: """Test _extract_file_path_contents with MessageDeltaChunk containing file citation annotation.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Create mock file_citation annotation mock_file_citation = MagicMock() @@ -1358,7 +1336,7 @@ def test_azure_ai_chat_client_extract_file_path_contents_with_file_citation_anno mock_chunk.delta = mock_delta # Call the method - file_contents = chat_client._extract_file_path_contents(mock_chunk) + file_contents = client._extract_file_path_contents(mock_chunk) # Verify results assert len(file_contents) == 1 @@ -1370,7 +1348,7 @@ def test_azure_ai_chat_client_extract_file_path_contents_empty_annotations( mock_agents_client: MagicMock, ) -> None: """Test _extract_file_path_contents with no annotations returns empty list.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Create mock text content with no annotations mock_text = MagicMock() @@ -1388,7 +1366,7 @@ def test_azure_ai_chat_client_extract_file_path_contents_empty_annotations( mock_chunk.delta = mock_delta # Call the method - file_contents = chat_client._extract_file_path_contents(mock_chunk) + file_contents = client._extract_file_path_contents(mock_chunk) # Verify results assert len(file_contents) == 0 @@ -1407,17 +1385,17 @@ def get_weather( async def test_azure_ai_chat_client_get_response() -> None: """Test Azure AI Chat Client response.""" async with AzureAIAgentClient(credential=AzureCliCredential()) as azure_ai_chat_client: - assert isinstance(azure_ai_chat_client, ChatClientProtocol) + assert isinstance(azure_ai_chat_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] + messages: list[Message] = [] messages.append( - ChatMessage( + Message( role="user", text="The weather in Seattle is currently sunny with a high of 25°C. " "It's a beautiful day for outdoor activities.", ) ) - messages.append(ChatMessage(role="user", text="What's the weather like today?")) + messages.append(Message(role="user", text="What's the weather like today?")) # Test that the agents_client can be used to get a response response = await azure_ai_chat_client.get_response(messages=messages) @@ -1432,10 +1410,10 @@ async def test_azure_ai_chat_client_get_response() -> None: async def test_azure_ai_chat_client_get_response_tools() -> None: """Test Azure AI Chat Client response with tools.""" async with AzureAIAgentClient(credential=AzureCliCredential()) as azure_ai_chat_client: - assert isinstance(azure_ai_chat_client, ChatClientProtocol) + assert isinstance(azure_ai_chat_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] - messages.append(ChatMessage(role="user", text="What's the weather like in Seattle?")) + messages: list[Message] = [] + messages.append(Message(role="user", text="What's the weather like in Seattle?")) # Test that the agents_client can be used to get a response response = await azure_ai_chat_client.get_response( @@ -1453,17 +1431,17 @@ async def test_azure_ai_chat_client_get_response_tools() -> None: async def test_azure_ai_chat_client_streaming() -> None: """Test Azure AI Chat Client streaming response.""" async with AzureAIAgentClient(credential=AzureCliCredential()) as azure_ai_chat_client: - assert isinstance(azure_ai_chat_client, ChatClientProtocol) + assert isinstance(azure_ai_chat_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] + messages: list[Message] = [] messages.append( - ChatMessage( + Message( role="user", text="The weather in Seattle is currently sunny with a high of 25°C. " "It's a beautiful day for outdoor activities.", ) ) - messages.append(ChatMessage(role="user", text="What's the weather like today?")) + messages.append(Message(role="user", text="What's the weather like today?")) # Test that the agents_client can be used to get a response response = azure_ai_chat_client.get_response(messages=messages, stream=True) @@ -1484,10 +1462,10 @@ async def test_azure_ai_chat_client_streaming() -> None: async def test_azure_ai_chat_client_streaming_tools() -> None: """Test Azure AI Chat Client streaming response with tools.""" async with AzureAIAgentClient(credential=AzureCliCredential()) as azure_ai_chat_client: - assert isinstance(azure_ai_chat_client, ChatClientProtocol) + assert isinstance(azure_ai_chat_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] - messages.append(ChatMessage(role="user", text="What's the weather like in Seattle?")) + messages: list[Message] = [] + messages.append(Message(role="user", text="What's the weather like in Seattle?")) # Test that the agents_client can be used to get a response response = azure_ai_chat_client.get_response( @@ -1509,9 +1487,9 @@ async def test_azure_ai_chat_client_streaming_tools() -> None: @pytest.mark.flaky @skip_if_azure_ai_integration_tests_disabled async def test_azure_ai_chat_client_agent_basic_run() -> None: - """Test ChatAgent basic run functionality with AzureAIAgentClient.""" - async with ChatAgent( - chat_client=AzureAIAgentClient(credential=AzureCliCredential()), + """Test Agent basic run functionality with AzureAIAgentClient.""" + async with Agent( + client=AzureAIAgentClient(credential=AzureCliCredential()), ) as agent: # Run a simple query response = await agent.run("Hello! Please respond with 'Hello World' exactly.") @@ -1526,9 +1504,9 @@ async def test_azure_ai_chat_client_agent_basic_run() -> None: @pytest.mark.flaky @skip_if_azure_ai_integration_tests_disabled async def test_azure_ai_chat_client_agent_basic_run_streaming() -> None: - """Test ChatAgent basic streaming functionality with AzureAIAgentClient.""" - async with ChatAgent( - chat_client=AzureAIAgentClient(credential=AzureCliCredential()), + """Test Agent basic streaming functionality with AzureAIAgentClient.""" + async with Agent( + client=AzureAIAgentClient(credential=AzureCliCredential()), ) as agent: # Run streaming query full_message: str = "" @@ -1546,24 +1524,24 @@ async def test_azure_ai_chat_client_agent_basic_run_streaming() -> None: @pytest.mark.flaky @skip_if_azure_ai_integration_tests_disabled async def test_azure_ai_chat_client_agent_thread_persistence() -> None: - """Test ChatAgent thread persistence across runs with AzureAIAgentClient.""" - async with ChatAgent( - chat_client=AzureAIAgentClient(credential=AzureCliCredential()), + """Test Agent session persistence across runs with AzureAIAgentClient.""" + async with Agent( + client=AzureAIAgentClient(credential=AzureCliCredential()), instructions="You are a helpful assistant with good memory.", ) as agent: - # Create a new thread that will be reused - thread = agent.get_new_thread() + # Create a new session that will be reused + session = agent.create_session() # First message - establish context first_response = await agent.run( - "Remember this number: 42. What number did I just tell you to remember?", thread=thread + "Remember this number: 42. What number did I just tell you to remember?", session=session ) assert isinstance(first_response, AgentResponse) assert "42" in first_response.text # Second message - test conversation memory second_response = await agent.run( - "What number did I tell you to remember in my previous message?", thread=thread + "What number did I tell you to remember in my previous message?", session=session ) assert isinstance(second_response, AgentResponse) assert "42" in second_response.text @@ -1572,33 +1550,33 @@ async def test_azure_ai_chat_client_agent_thread_persistence() -> None: @pytest.mark.flaky @skip_if_azure_ai_integration_tests_disabled async def test_azure_ai_chat_client_agent_existing_thread_id() -> None: - """Test ChatAgent existing thread ID functionality with AzureAIAgentClient.""" - async with ChatAgent( - chat_client=AzureAIAgentClient(credential=AzureCliCredential()), + """Test Agent existing thread ID functionality with AzureAIAgentClient.""" + async with Agent( + client=AzureAIAgentClient(credential=AzureCliCredential()), instructions="You are a helpful assistant with good memory.", ) as first_agent: - # Start a conversation and get the thread ID - thread = first_agent.get_new_thread() - first_response = await first_agent.run("My name is Alice. Remember this.", thread=thread) + # Start a conversation and get the session ID + session = first_agent.create_session() + first_response = await first_agent.run("My name is Alice. Remember this.", session=session) # Validate first response assert isinstance(first_response, AgentResponse) assert first_response.text is not None # The thread ID is set after the first response - existing_thread_id = thread.service_thread_id + existing_thread_id = session.service_session_id assert existing_thread_id is not None # Now continue with the same thread ID in a new agent instance - async with ChatAgent( - chat_client=AzureAIAgentClient(thread_id=existing_thread_id, credential=AzureCliCredential()), + async with Agent( + client=AzureAIAgentClient(thread_id=existing_thread_id, credential=AzureCliCredential()), instructions="You are a helpful assistant with good memory.", ) as second_agent: - # Create a thread with the existing ID - thread = AgentThread(service_thread_id=existing_thread_id) + # Create a session with the existing ID + session = AgentSession(service_session_id=existing_thread_id) # Ask about the previous conversation - response2 = await second_agent.run("What is my name?", thread=thread) + response2 = await second_agent.run("What is my name?", session=session) # Validate that the agent remembers the previous conversation assert isinstance(response2, AgentResponse) @@ -1610,12 +1588,12 @@ async def test_azure_ai_chat_client_agent_existing_thread_id() -> None: @pytest.mark.flaky @skip_if_azure_ai_integration_tests_disabled async def test_azure_ai_chat_client_agent_code_interpreter(): - """Test ChatAgent with code interpreter through AzureAIAgentClient.""" + """Test Agent with code interpreter through AzureAIAgentClient.""" - async with ChatAgent( - chat_client=AzureAIAgentClient(credential=AzureCliCredential()), + async with Agent( + client=AzureAIAgentClient(credential=AzureCliCredential()), instructions="You are a helpful assistant that can write and execute Python code.", - tools=[HostedCodeInterpreterTool()], + tools=[AzureAIAgentClient.get_code_interpreter_tool()], ) as agent: # Request code execution response = await agent.run("Write Python code to calculate the factorial of 5 and show the result.") @@ -1630,7 +1608,7 @@ async def test_azure_ai_chat_client_agent_code_interpreter(): @pytest.mark.flaky @skip_if_azure_ai_integration_tests_disabled async def test_azure_ai_chat_client_agent_file_search(): - """Test ChatAgent with file search through AzureAIAgentClient.""" + """Test Agent with file search through AzureAIAgentClient.""" client = AzureAIAgentClient(credential=AzureCliCredential()) file: FileInfo | None = None @@ -1645,12 +1623,10 @@ async def test_azure_ai_chat_client_agent_file_search(): ) # 2. Create file search tool with uploaded resources - file_search_tool = HostedFileSearchTool( - inputs=[Content.from_hosted_vector_store(vector_store_id=vector_store.id)] - ) + file_search_tool = AzureAIAgentClient.get_file_search_tool(vector_store_ids=[vector_store.id]) - async with ChatAgent( - chat_client=client, + async with Agent( + client=client, instructions="You are a helpful assistant that can search through uploaded employee files.", tools=[file_search_tool], ) as agent: @@ -1679,17 +1655,17 @@ async def test_azure_ai_chat_client_agent_file_search(): @skip_if_azure_ai_integration_tests_disabled async def test_azure_ai_chat_client_agent_hosted_mcp_tool() -> None: - """Integration test for HostedMCPTool with Azure AI Agent using Microsoft Learn MCP.""" + """Integration test for MCP tool with Azure AI Agent using Microsoft Learn MCP.""" - mcp_tool = HostedMCPTool( + mcp_tool = AzureAIAgentClient.get_mcp_tool( name="Microsoft Learn MCP", url="https://learn.microsoft.com/api/mcp", description="A Microsoft Learn MCP server for documentation questions", approval_mode="never_require", ) - async with ChatAgent( - chat_client=AzureAIAgentClient(credential=AzureCliCredential()), + async with Agent( + client=AzureAIAgentClient(credential=AzureCliCredential()), instructions="You are a helpful assistant that can help with microsoft documentation questions.", tools=[mcp_tool], ) as agent: @@ -1715,8 +1691,8 @@ async def test_azure_ai_chat_client_agent_hosted_mcp_tool() -> None: @skip_if_azure_ai_integration_tests_disabled async def test_azure_ai_chat_client_agent_level_tool_persistence(): """Test that agent-level tools persist across multiple runs with AzureAIAgentClient.""" - async with ChatAgent( - chat_client=AzureAIAgentClient(credential=AzureCliCredential()), + async with Agent( + client=AzureAIAgentClient(credential=AzureCliCredential()), instructions="You are a helpful assistant that uses available tools.", tools=[get_weather], ) as agent: @@ -1740,8 +1716,8 @@ async def test_azure_ai_chat_client_agent_level_tool_persistence(): @skip_if_azure_ai_integration_tests_disabled async def test_azure_ai_chat_client_agent_chat_options_run_level() -> None: """Test ChatOptions parameter coverage at run level.""" - async with ChatAgent( - chat_client=AzureAIAgentClient(credential=AzureCliCredential()), + async with Agent( + client=AzureAIAgentClient(credential=AzureCliCredential()), instructions="You are a helpful assistant.", ) as agent: response = await agent.run( @@ -1764,8 +1740,8 @@ async def test_azure_ai_chat_client_agent_chat_options_run_level() -> None: @skip_if_azure_ai_integration_tests_disabled async def test_azure_ai_chat_client_agent_chat_options_agent_level() -> None: """Test ChatOptions parameter coverage agent level.""" - async with ChatAgent( - chat_client=AzureAIAgentClient(credential=AzureCliCredential()), + async with Agent( + client=AzureAIAgentClient(credential=AzureCliCredential()), instructions="You are a helpful assistant.", tools=[get_weather], default_options={ @@ -1789,59 +1765,59 @@ async def test_azure_ai_chat_client_cleanup_agent_when_enabled_and_created( mock_agents_client: MagicMock, ) -> None: """Test that agent is cleaned up when should_cleanup_agent=True and agent was created by client.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id=None, should_cleanup_agent=True) + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id=None, should_cleanup_agent=True) # Simulate agent creation - chat_client.agent_id = "created-agent-id" - chat_client._agent_created = True # type: ignore + client.agent_id = "created-agent-id" + client._agent_created = True # type: ignore - await chat_client._cleanup_agent_if_needed() # type: ignore + await client._cleanup_agent_if_needed() # type: ignore # Verify agent was deleted mock_agents_client.delete_agent.assert_called_once_with("created-agent-id") - assert chat_client.agent_id is None - assert chat_client._agent_created is False # type: ignore + assert client.agent_id is None + assert client._agent_created is False # type: ignore async def test_azure_ai_chat_client_no_cleanup_when_disabled( mock_agents_client: MagicMock, ) -> None: """Test that agent is not cleaned up when should_cleanup_agent=False.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id=None, should_cleanup_agent=False) + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id=None, should_cleanup_agent=False) # Simulate agent creation - chat_client.agent_id = "created-agent-id" - chat_client._agent_created = True + client.agent_id = "created-agent-id" + client._agent_created = True - await chat_client._cleanup_agent_if_needed() # type: ignore + await client._cleanup_agent_if_needed() # type: ignore # Verify agent was NOT deleted mock_agents_client.delete_agent.assert_not_called() - assert chat_client.agent_id == "created-agent-id" - assert chat_client._agent_created is True + assert client.agent_id == "created-agent-id" + assert client._agent_created is True async def test_azure_ai_chat_client_no_cleanup_when_agent_not_created_by_client( mock_agents_client: MagicMock, ) -> None: """Test that agent is not cleaned up when it was not created by this client instance.""" - chat_client = create_test_azure_ai_chat_client( + client = create_test_azure_ai_chat_client( mock_agents_client, agent_id="existing-agent-id", should_cleanup_agent=True ) # Agent exists but was not created by this client (_agent_created = False) - assert chat_client._agent_created is False # type: ignore + assert client._agent_created is False # type: ignore - await chat_client._cleanup_agent_if_needed() # type: ignore + await client._cleanup_agent_if_needed() # type: ignore # Verify agent was NOT deleted mock_agents_client.delete_agent.assert_not_called() - assert chat_client.agent_id == "existing-agent-id" + assert client.agent_id == "existing-agent-id" def test_azure_ai_chat_client_capture_azure_search_tool_calls(mock_agents_client: MagicMock) -> None: """Test _capture_azure_search_tool_calls method.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) # Mock Azure AI Search tool call mock_tool_call = MagicMock() @@ -1855,7 +1831,7 @@ def test_azure_ai_chat_client_capture_azure_search_tool_calls(mock_agents_client # Call the method with a list to capture tool calls azure_search_tool_calls: list[dict[str, Any]] = [] - chat_client._capture_azure_search_tool_calls(mock_step_data, azure_search_tool_calls) # type: ignore + client._capture_azure_search_tool_calls(mock_step_data, azure_search_tool_calls) # type: ignore # Verify tool call was captured assert len(azure_search_tool_calls) == 1 @@ -1869,10 +1845,10 @@ def test_azure_ai_chat_client_get_real_url_from_citation_reference_no_tool_calls mock_agents_client: MagicMock, ) -> None: """Test _get_real_url_from_citation_reference with no tool calls.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) # No tool calls - pass empty list - result = chat_client._get_real_url_from_citation_reference("doc_1", []) # type: ignore + result = client._get_real_url_from_citation_reference("doc_1", []) # type: ignore assert result == "doc_1" @@ -1880,51 +1856,51 @@ def test_azure_ai_chat_client_get_real_url_from_citation_reference_invalid_outpu mock_agents_client: MagicMock, ) -> None: """Test _get_real_url_from_citation_reference with invalid output format.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) # Tool call with invalid output format azure_search_tool_calls = [ {"id": "call_123", "type": "azure_ai_search", "azure_ai_search": {"output": "invalid_json_format"}} ] - result = chat_client._get_real_url_from_citation_reference("doc_1", azure_search_tool_calls) # type: ignore + result = client._get_real_url_from_citation_reference("doc_1", azure_search_tool_calls) # type: ignore assert result == "doc_1" async def test_azure_ai_chat_client_context_manager(mock_agents_client: MagicMock) -> None: """Test AzureAIAgentClient as async context manager.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) # Mock close method to avoid actual cleanup - chat_client.close = AsyncMock() + client.close = AsyncMock() - async with chat_client as client: - assert client is chat_client + async with client as client: + assert client is client # Verify close was called on exit - chat_client.close.assert_called_once() + client.close.assert_called_once() async def test_azure_ai_chat_client_close_method(mock_agents_client: MagicMock) -> None: """Test AzureAIAgentClient close method.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) # Mock cleanup methods - chat_client._cleanup_agent_if_needed = AsyncMock() - chat_client._close_client_if_needed = AsyncMock() + client._cleanup_agent_if_needed = AsyncMock() + client._close_client_if_needed = AsyncMock() - await chat_client.close() + await client.close() # Verify cleanup methods were called - chat_client._cleanup_agent_if_needed.assert_called_once() - chat_client._close_client_if_needed.assert_called_once() + client._cleanup_agent_if_needed.assert_called_once() + client._close_client_if_needed.assert_called_once() def test_azure_ai_chat_client_extract_url_citations_with_azure_search_enhanced_url( mock_agents_client: MagicMock, ) -> None: """Test _extract_url_citations with Azure AI Search URL enhancement.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) # Add Azure Search tool calls for URL enhancement azure_search_tool_calls = [ @@ -1961,7 +1937,7 @@ def test_azure_ai_chat_client_extract_url_citations_with_azure_search_enhanced_u mock_chunk = MagicMock(spec=MessageDeltaChunk) mock_chunk.delta = mock_delta - citations = chat_client._extract_url_citations(mock_chunk, azure_search_tool_calls) # type: ignore + citations = client._extract_url_citations(mock_chunk, azure_search_tool_calls) # type: ignore # Verify real URL was used assert len(citations) == 1 @@ -2006,7 +1982,7 @@ async def test_azure_ai_chat_client_prepare_options_with_mapping_response_format mock_agents_client: MagicMock, ) -> None: """Test _prepare_options with Mapping-based response_format (runtime JSON schema).""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) # Runtime JSON schema dict response_format_dict = { @@ -2019,7 +1995,7 @@ async def test_azure_ai_chat_client_prepare_options_with_mapping_response_format chat_options: ChatOptions = {"response_format": response_format_dict} # type: ignore[typeddict-item] - run_options, _ = await chat_client._prepare_options([], chat_options) # type: ignore + run_options, _ = await client._prepare_options([], chat_options) # type: ignore assert "response_format" in run_options # Should pass through as-is for Mapping types @@ -2030,20 +2006,20 @@ async def test_azure_ai_chat_client_prepare_options_with_invalid_response_format mock_agents_client: MagicMock, ) -> None: """Test _prepare_options with invalid response_format raises error.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) # Invalid response_format (not BaseModel or Mapping) chat_options: ChatOptions = {"response_format": "invalid_format"} # type: ignore[typeddict-item] with pytest.raises(ServiceInvalidRequestError, match="response_format must be a Pydantic BaseModel"): - await chat_client._prepare_options([], chat_options) # type: ignore + await client._prepare_options([], chat_options) # type: ignore async def test_azure_ai_chat_client_prepare_tool_definitions_with_agent_tool_resources( mock_agents_client: MagicMock, ) -> None: """Test _prepare_tool_definitions_and_resources copies tool_resources from agent definition.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Create mock agent definition with tool_resources mock_agent_definition = MagicMock() @@ -2053,7 +2029,7 @@ async def test_azure_ai_chat_client_prepare_tool_definitions_with_agent_tool_res run_options: dict[str, Any] = {} options: dict[str, Any] = {} - await chat_client._prepare_tool_definitions_and_resources(options, mock_agent_definition, run_options) # type: ignore + await client._prepare_tool_definitions_and_resources(options, mock_agent_definition, run_options) # type: ignore # Verify tool_resources was copied to run_options assert "tool_resources" in run_options @@ -2064,52 +2040,51 @@ def test_azure_ai_chat_client_prepare_mcp_resources_with_dict_approval_mode( mock_agents_client: MagicMock, ) -> None: """Test _prepare_mcp_resources with dict-based approval mode (always_require_approval).""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) - # MCP tool with dict-based approval mode - mcp_tool = HostedMCPTool( + # MCP tool with dict-based approval mode - use approval_mode parameter + mcp_tool = AzureAIAgentClient.get_mcp_tool( name="Test MCP", url="https://example.com/mcp", - approval_mode={"always_require_approval": {"tool1", "tool2"}}, + approval_mode={"always_require_approval": ["tool1", "tool2"]}, ) - result = chat_client._prepare_mcp_resources([mcp_tool]) # type: ignore + result = client._prepare_mcp_resources([mcp_tool]) # type: ignore assert len(result) == 1 assert result[0]["server_label"] == "Test_MCP" assert "require_approval" in result[0] - assert result[0]["require_approval"] == {"always": {"tool1", "tool2"}} def test_azure_ai_chat_client_prepare_mcp_resources_with_never_require_dict( mock_agents_client: MagicMock, ) -> None: """Test _prepare_mcp_resources with dict-based approval mode (never_require_approval).""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) - # MCP tool with never_require_approval dict - mcp_tool = HostedMCPTool( + # MCP tool with never require approval - use approval_mode parameter + mcp_tool = AzureAIAgentClient.get_mcp_tool( name="Test MCP", url="https://example.com/mcp", - approval_mode={"never_require_approval": {"safe_tool"}}, + approval_mode={"never_require_approval": ["safe_tool"]}, ) - result = chat_client._prepare_mcp_resources([mcp_tool]) # type: ignore + result = client._prepare_mcp_resources([mcp_tool]) # type: ignore assert len(result) == 1 - assert result[0]["require_approval"] == {"never": {"safe_tool"}} + assert "require_approval" in result[0] def test_azure_ai_chat_client_prepare_messages_with_function_result( mock_agents_client: MagicMock, ) -> None: """Test _prepare_messages extracts function_result content.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result="test result") - messages = [ChatMessage(role="user", contents=[function_result])] + messages = [Message(role="user", contents=[function_result])] - additional_messages, instructions, required_action_results = chat_client._prepare_messages(messages) # type: ignore + additional_messages, instructions, required_action_results = client._prepare_messages(messages) # type: ignore # function_result should be extracted, not added to additional_messages assert additional_messages is None @@ -2122,14 +2097,14 @@ def test_azure_ai_chat_client_prepare_messages_with_raw_content_block( mock_agents_client: MagicMock, ) -> None: """Test _prepare_messages handles raw MessageInputContentBlock in content.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client) + client = create_test_azure_ai_chat_client(mock_agents_client) # Create content with raw_representation that is a MessageInputContentBlock raw_block = MessageInputTextBlock(text="Raw block text") custom_content = Content(type="custom", raw_representation=raw_block) - messages = [ChatMessage(role="user", contents=[custom_content])] + messages = [Message(role="user", contents=[custom_content])] - additional_messages, instructions, required_action_results = chat_client._prepare_messages(messages) # type: ignore + additional_messages, instructions, required_action_results = client._prepare_messages(messages) # type: ignore assert additional_messages is not None assert len(additional_messages) == 1 @@ -2140,16 +2115,15 @@ def test_azure_ai_chat_client_prepare_messages_with_raw_content_block( async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_mcp_tool( mock_agents_client: MagicMock, ) -> None: - """Test _prepare_tools_for_azure_ai with HostedMCPTool.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + """Test _prepare_tools_for_azure_ai with MCP dict tool.""" + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - mcp_tool = HostedMCPTool( + mcp_tool = AzureAIAgentClient.get_mcp_tool( name="Test MCP Server", url="https://example.com/mcp", - allowed_tools=["tool1", "tool2"], ) - tool_definitions = await chat_client._prepare_tools_for_azure_ai([mcp_tool]) # type: ignore + tool_definitions = await client._prepare_tools_for_azure_ai([mcp_tool]) # type: ignore assert len(tool_definitions) >= 1 # The McpTool.definitions property returns the tool definitions @@ -2162,12 +2136,12 @@ async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_tool_definition( mock_agents_client: MagicMock, ) -> None: """Test _prepare_tools_for_azure_ai with ToolDefinition passthrough.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Pass a ToolDefinition directly - should be passed through as-is tool_def = CodeInterpreterToolDefinition() - tool_definitions = await chat_client._prepare_tools_for_azure_ai([tool_def]) # type: ignore + tool_definitions = await client._prepare_tools_for_azure_ai([tool_def]) # type: ignore assert len(tool_definitions) == 1 assert tool_definitions[0] is tool_def @@ -2177,12 +2151,12 @@ async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_dict_passthrough( mock_agents_client: MagicMock, ) -> None: """Test _prepare_tools_for_azure_ai with dict passthrough.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") # Pass a dict tool definition - should be passed through as-is dict_tool = {"type": "function", "function": {"name": "test_func", "parameters": {}}} - tool_definitions = await chat_client._prepare_tools_for_azure_ai([dict_tool]) # type: ignore + tool_definitions = await client._prepare_tools_for_azure_ai([dict_tool]) # type: ignore assert len(tool_definitions) == 1 assert tool_definitions[0] is dict_tool @@ -2191,14 +2165,16 @@ async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_dict_passthrough( async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_unsupported_type( mock_agents_client: MagicMock, ) -> None: - """Test _prepare_tools_for_azure_ai raises error for unsupported tool type.""" - chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + """Test _prepare_tools_for_azure_ai passes through unsupported tool types.""" + client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - # Pass an unsupported tool type + # Pass an unsupported tool type - it should be passed through unchanged class UnsupportedTool: pass unsupported_tool = UnsupportedTool() - with pytest.raises(ServiceInitializationError, match="Unsupported tool type"): - await chat_client._prepare_tools_for_azure_ai([unsupported_tool]) # type: ignore + # Unsupported tools are now passed through unchanged (server will reject if invalid) + tool_definitions = await client._prepare_tools_for_azure_ai([unsupported_tool]) # type: ignore + assert len(tool_definitions) == 1 + assert tool_definitions[0] is unsupported_tool 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 38ccfb5ad3..1114747d1b 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -11,19 +11,16 @@ from uuid import uuid4 import pytest from agent_framework import ( + Agent, AgentResponse, - ChatAgent, - ChatClientProtocol, - ChatMessage, ChatOptions, ChatResponse, Content, - HostedCodeInterpreterTool, - HostedFileSearchTool, - HostedMCPTool, - HostedWebSearchTool, + Message, + 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 ( @@ -31,6 +28,7 @@ from azure.ai.projects.models import ( CodeInterpreterTool, CodeInterpreterToolAuto, FileSearchTool, + ImageGenTool, MCPTool, ResponseTextFormatConfigurationJsonSchema, WebSearchPreviewTool, @@ -39,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 @@ -88,19 +86,19 @@ async def temporary_chat_client(agent_name: str) -> AsyncIterator[AzureAIClient] """Async context manager that creates an Azure AI agent and yields an `AzureAIClient`. The underlying agent version is cleaned up automatically after use. - Tests can construct their own `ChatAgent` instances from the yielded client. + Tests can construct their own `Agent` instances from the yielded client. """ endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] async with ( AzureCliCredential() as credential, AIProjectClient(endpoint=endpoint, credential=credential) as project_client, ): - chat_client = AzureAIClient( + client = AzureAIClient( project_client=project_client, agent_name=agent_name, ) try: - yield chat_client + yield client finally: await project_client.agents.delete(agent_name=agent_name) @@ -116,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) @@ -128,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 @@ -146,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, @@ -179,7 +178,7 @@ def test_init_with_project_client(mock_project_client: MagicMock) -> None: assert client.agent_name == "test-agent" assert client.agent_version == "1.0" assert not client._should_close_client # type: ignore - assert isinstance(client, ChatClientProtocol) + assert isinstance(client, SupportsChatGetResponse) def test_init_auto_create_client( @@ -208,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()) @@ -227,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: @@ -262,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 ) @@ -273,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"} @@ -298,9 +291,9 @@ async def test_prepare_messages_for_azure_ai_with_system_messages( client = create_test_azure_ai_client(mock_project_client) messages = [ - ChatMessage(role="system", contents=[Content.from_text(text="You are a helpful assistant.")]), - ChatMessage(role="user", contents=[Content.from_text(text="Hello")]), - ChatMessage(role="assistant", contents=[Content.from_text(text="System response")]), + Message(role="system", contents=[Content.from_text(text="You are a helpful assistant.")]), + Message(role="user", contents=[Content.from_text(text="Hello")]), + Message(role="assistant", contents=[Content.from_text(text="System response")]), ] result_messages, instructions = client._prepare_messages_for_azure_ai(messages) # type: ignore @@ -318,8 +311,8 @@ async def test_prepare_messages_for_azure_ai_no_system_messages( client = create_test_azure_ai_client(mock_project_client) messages = [ - ChatMessage(role="user", contents=[Content.from_text(text="Hello")]), - ChatMessage(role="assistant", contents=[Content.from_text(text="Hi there!")]), + Message(role="user", contents=[Content.from_text(text="Hello")]), + Message(role="assistant", contents=[Content.from_text(text="Hi there!")]), ] result_messages, instructions = client._prepare_messages_for_azure_ai(messages) # type: ignore @@ -419,7 +412,7 @@ async def test_prepare_options_basic(mock_project_client: MagicMock) -> None: """Test prepare_options basic functionality.""" client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") - messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])] + messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] with ( patch( @@ -456,7 +449,7 @@ async def test_prepare_options_with_application_endpoint( agent_version="1", ) - messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])] + messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] with ( patch( @@ -498,7 +491,7 @@ async def test_prepare_options_with_application_project_client( agent_version="1", ) - messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])] + messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] with ( patch( @@ -977,7 +970,7 @@ async def test_prepare_options_excludes_response_format( """Test that prepare_options excludes response_format, text, and text_format from final run options.""" client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") - messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])] + messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] chat_options: ChatOptions = {} with ( @@ -1100,178 +1093,50 @@ def test_get_conversation_id_with_parsed_response_no_conversation() -> None: assert result == "resp_parsed_12345" -def test_prepare_mcp_tool_basic() -> None: - """Test _prepare_mcp_tool with basic HostedMCPTool.""" - mcp_tool = HostedMCPTool( - name="Test MCP Server", - url="https://example.com/mcp", - ) - - result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore - - assert result["server_label"] == "Test_MCP_Server" - assert result["server_url"] == "https://example.com/mcp" +# region MCP Tool Dict Tests +# These tests verify that dict-based MCP tools are processed correctly by from_azure_ai_tools -def test_prepare_mcp_tool_with_description() -> None: - """Test _prepare_mcp_tool with description.""" - mcp_tool = HostedMCPTool( - name="Test MCP", - url="https://example.com/mcp", - description="A test MCP server", - ) - - result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore - - assert result["server_description"] == "A test MCP server" - - -def test_prepare_mcp_tool_with_project_connection_id() -> None: - """Test _prepare_mcp_tool with project_connection_id in additional_properties.""" - mcp_tool = HostedMCPTool( - name="Test MCP", - url="https://example.com/mcp", - additional_properties={"project_connection_id": "conn-123"}, - ) - - result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore - - assert result["project_connection_id"] == "conn-123" - assert "headers" not in result # headers should not be set when project_connection_id is present - - -def test_prepare_mcp_tool_with_headers() -> None: - """Test _prepare_mcp_tool with headers (no project_connection_id).""" - mcp_tool = HostedMCPTool( - name="Test MCP", - url="https://example.com/mcp", - headers={"Authorization": "Bearer token123"}, - ) - - result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore - - assert result["headers"] == {"Authorization": "Bearer token123"} - - -def test_prepare_mcp_tool_with_allowed_tools() -> None: - """Test _prepare_mcp_tool with allowed_tools.""" - mcp_tool = HostedMCPTool( - name="Test MCP", - url="https://example.com/mcp", - allowed_tools=["tool1", "tool2"], - ) - - result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore - - assert set(result["allowed_tools"]) == {"tool1", "tool2"} - - -def test_prepare_mcp_tool_with_approval_mode_always_require() -> None: - """Test _prepare_mcp_tool with string approval_mode 'always_require'.""" - mcp_tool = HostedMCPTool( - name="Test MCP", - url="https://example.com/mcp", - approval_mode="always_require", - ) - - result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore - - assert result["require_approval"] == "always" - - -def test_prepare_mcp_tool_with_approval_mode_never_require() -> None: - """Test _prepare_mcp_tool with string approval_mode 'never_require'.""" - mcp_tool = HostedMCPTool( - name="Test MCP", - url="https://example.com/mcp", - approval_mode="never_require", - ) - - result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore - - assert result["require_approval"] == "never" - - -def test_prepare_mcp_tool_with_dict_approval_mode_always() -> None: - """Test _prepare_mcp_tool with dict approval_mode containing always_require_approval.""" - mcp_tool = HostedMCPTool( - name="Test MCP", - url="https://example.com/mcp", - approval_mode={"always_require_approval": {"dangerous_tool", "risky_tool"}}, - ) - - result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore - - assert "require_approval" in result - assert "always" in result["require_approval"] - assert set(result["require_approval"]["always"]["tool_names"]) == {"dangerous_tool", "risky_tool"} - - -def test_prepare_mcp_tool_with_dict_approval_mode_never() -> None: - """Test _prepare_mcp_tool with dict approval_mode containing never_require_approval.""" - mcp_tool = HostedMCPTool( - name="Test MCP", - url="https://example.com/mcp", - approval_mode={"never_require_approval": {"safe_tool"}}, - ) - - result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore - - assert "require_approval" in result - assert "never" in result["require_approval"] - assert set(result["require_approval"]["never"]["tool_names"]) == {"safe_tool"} - - -def test_from_azure_ai_tools() -> None: - """Test from_azure_ai_tools.""" - # Test MCP tool +def test_from_azure_ai_tools_mcp() -> None: + """Test from_azure_ai_tools with MCP tool.""" mcp_tool = MCPTool(server_label="test_server", server_url="http://localhost:8080") parsed_tools = from_azure_ai_tools([mcp_tool]) assert len(parsed_tools) == 1 - assert isinstance(parsed_tools[0], HostedMCPTool) - assert parsed_tools[0].name == "test server" - assert str(parsed_tools[0].url).rstrip("/") == "http://localhost:8080" + assert parsed_tools[0]["type"] == "mcp" + assert parsed_tools[0]["server_label"] == "test_server" + assert parsed_tools[0]["server_url"] == "http://localhost:8080" - # Test Code Interpreter tool + +def test_from_azure_ai_tools_code_interpreter() -> None: + """Test from_azure_ai_tools with Code Interpreter tool.""" ci_tool = CodeInterpreterTool(container=CodeInterpreterToolAuto(file_ids=["file-1"])) parsed_tools = from_azure_ai_tools([ci_tool]) assert len(parsed_tools) == 1 - assert isinstance(parsed_tools[0], HostedCodeInterpreterTool) - assert parsed_tools[0].inputs is not None - assert len(parsed_tools[0].inputs) == 1 + assert parsed_tools[0]["type"] == "code_interpreter" - tool_input = parsed_tools[0].inputs[0] - assert tool_input and tool_input.type == "hosted_file" and tool_input.file_id == "file-1" - - # Test File Search tool +def test_from_azure_ai_tools_file_search() -> None: + """Test from_azure_ai_tools with File Search tool.""" fs_tool = FileSearchTool(vector_store_ids=["vs-1"], max_num_results=5) parsed_tools = from_azure_ai_tools([fs_tool]) assert len(parsed_tools) == 1 - assert isinstance(parsed_tools[0], HostedFileSearchTool) - assert parsed_tools[0].inputs is not None - assert len(parsed_tools[0].inputs) == 1 + assert parsed_tools[0]["type"] == "file_search" + assert parsed_tools[0]["vector_store_ids"] == ["vs-1"] + assert parsed_tools[0]["max_num_results"] == 5 - tool_input = parsed_tools[0].inputs[0] - assert tool_input and tool_input.type == "hosted_vector_store" and tool_input.vector_store_id == "vs-1" - assert parsed_tools[0].max_results == 5 - - # Test Web Search tool +def test_from_azure_ai_tools_web_search() -> None: + """Test from_azure_ai_tools with Web Search tool.""" ws_tool = WebSearchPreviewTool( user_location=ApproximateLocation(city="Seattle", country="US", region="WA", timezone="PST") ) parsed_tools = from_azure_ai_tools([ws_tool]) assert len(parsed_tools) == 1 - assert isinstance(parsed_tools[0], HostedWebSearchTool) - assert parsed_tools[0].additional_properties + assert parsed_tools[0]["type"] == "web_search_preview" + assert parsed_tools[0]["user_location"]["city"] == "Seattle" - user_location = parsed_tools[0].additional_properties["user_location"] - assert user_location["city"] == "Seattle" - assert user_location["country"] == "US" - assert user_location["region"] == "WA" - assert user_location["timezone"] == "PST" +# endregion # region Integration Tests @@ -1363,10 +1228,10 @@ async def test_integration_options( # Prepare test message if option_name.startswith("tool_choice"): # Use weather-related prompt for tool tests - messages = [ChatMessage(role="user", text="What is the weather in Seattle?")] + messages = [Message(role="user", text="What is the weather in Seattle?")] else: # Generic prompt for simple options - messages = [ChatMessage(role="user", text="Say 'Hello World' briefly.")] + messages = [Message(role="user", text="Say 'Hello World' briefly.")] # Build options dict options: dict[str, Any] = {option_name: option_value, "tools": [get_weather]} @@ -1480,11 +1345,11 @@ async def test_integration_agent_options( # Prepare test message if option_name.startswith("response_format"): # Use prompt that works well with structured output - messages = [ChatMessage(role="user", text="The weather in Seattle is sunny")] - messages.append(ChatMessage(role="user", text="What is the weather in Seattle?")) + messages = [Message(role="user", text="The weather in Seattle is sunny")] + messages.append(Message(role="user", text="What is the weather in Seattle?")) else: # Generic prompt for simple options - messages = [ChatMessage(role="user", text="Say 'Hello World' briefly.")] + messages = [Message(role="user", text="Say 'Hello World' briefly.")] # Build options dict options = {option_name: option_value} @@ -1535,7 +1400,7 @@ async def test_integration_web_search() -> None: "messages": "Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", "options": { "tool_choice": "auto", - "tools": [HostedWebSearchTool()], + "tools": [client.get_web_search_tool()], }, } if streaming: @@ -1550,17 +1415,11 @@ async def test_integration_web_search() -> None: assert "Zoey" in response.text # Test that the client will use the web search tool with location - additional_properties = { - "user_location": { - "country": "US", - "city": "Seattle", - } - } content = { "messages": "What is the current weather? Do not ask for my current location.", "options": { "tool_choice": "auto", - "tools": [HostedWebSearchTool(additional_properties=additional_properties)], + "tools": [client.get_web_search_tool(user_location={"country": "US", "city": "Seattle"})], }, } if streaming: @@ -1573,14 +1432,14 @@ async def test_integration_web_search() -> None: @pytest.mark.flaky @skip_if_azure_ai_integration_tests_disabled async def test_integration_agent_hosted_mcp_tool() -> None: - """Integration test for HostedMCPTool with Azure Response Agent using Microsoft Learn MCP.""" + """Integration test for MCP tool with Azure Response Agent using Microsoft Learn MCP.""" async with temporary_chat_client(agent_name="af-int-test-mcp") as client: response = await client.get_response( "How to create an Azure storage account using az cli?", options={ # this needs to be high enough to handle the full MCP tool response. "max_tokens": 5000, - "tools": HostedMCPTool( + "tools": client.get_mcp_tool( name="Microsoft Learn MCP", url="https://learn.microsoft.com/api/mcp", description="A Microsoft Learn MCP server for documentation questions", @@ -1597,12 +1456,12 @@ async def test_integration_agent_hosted_mcp_tool() -> None: @pytest.mark.flaky @skip_if_azure_ai_integration_tests_disabled async def test_integration_agent_hosted_code_interpreter_tool(): - """Test Azure Responses Client agent with HostedCodeInterpreterTool through AzureAIClient.""" + """Test Azure Responses Client agent with code interpreter tool through AzureAIClient.""" async with temporary_chat_client(agent_name="af-int-test-code-interpreter") as client: response = await client.get_response( "Calculate the sum of numbers from 1 to 10 using Python code.", options={ - "tools": [HostedCodeInterpreterTool()], + "tools": [client.get_code_interpreter_tool()], }, ) # Should contain calculation result (sum of 1-10 = 55) or code execution content @@ -1614,40 +1473,152 @@ async def test_integration_agent_hosted_code_interpreter_tool(): @pytest.mark.flaky @skip_if_azure_ai_integration_tests_disabled -async def test_integration_agent_existing_thread(): - """Test Azure Responses Client agent with existing thread to continue conversations across agent instances.""" - # First conversation - capture the thread - preserved_thread = None +async def test_integration_agent_existing_session(): + """Test Azure Responses Client agent with existing session to continue conversations across agent instances.""" + # First conversation - capture the session + preserved_session = None async with ( - temporary_chat_client(agent_name="af-int-test-existing-thread") as client, - ChatAgent( - chat_client=client, + temporary_chat_client(agent_name="af-int-test-existing-session") as client, + Agent( + client=client, instructions="You are a helpful assistant with good memory.", ) as first_agent, ): - # Start a conversation and capture the thread - thread = first_agent.get_new_thread() - first_response = await first_agent.run("My hobby is photography. Remember this.", thread=thread, store=True) + # Start a conversation and capture the session + session = first_agent.create_session() + first_response = await first_agent.run("My hobby is photography. Remember this.", session=session, store=True) assert isinstance(first_response, AgentResponse) assert first_response.text is not None - # Preserve the thread for reuse - preserved_thread = thread + # Preserve the session for reuse + preserved_session = session - # Second conversation - reuse the thread in a new agent instance - if preserved_thread: + # Second conversation - reuse the session in a new agent instance + if preserved_session: async with ( - temporary_chat_client(agent_name="af-int-test-existing-thread-2") as client, - ChatAgent( - chat_client=client, + temporary_chat_client(agent_name="af-int-test-existing-session-2") as client, + Agent( + client=client, instructions="You are a helpful assistant with good memory.", ) as second_agent, ): - # Reuse the preserved thread - second_response = await second_agent.run("What is my hobby?", thread=preserved_thread) + # Reuse the preserved session + second_response = await second_agent.run("What is my hobby?", session=preserved_session) assert isinstance(second_response, AgentResponse) assert second_response.text is not None assert "photography" in second_response.text.lower() + + +# region Factory Method Tests + + +def test_get_code_interpreter_tool_basic() -> None: + """Test get_code_interpreter_tool returns CodeInterpreterTool.""" + tool = AzureAIClient.get_code_interpreter_tool() + assert isinstance(tool, CodeInterpreterTool) + + +def test_get_code_interpreter_tool_with_file_ids() -> None: + """Test get_code_interpreter_tool with file_ids.""" + tool = AzureAIClient.get_code_interpreter_tool(file_ids=["file-123", "file-456"]) + assert isinstance(tool, CodeInterpreterTool) + assert tool["container"]["file_ids"] == ["file-123", "file-456"] + + +def test_get_file_search_tool_basic() -> None: + """Test get_file_search_tool returns FileSearchTool.""" + tool = AzureAIClient.get_file_search_tool(vector_store_ids=["vs-123"]) + assert isinstance(tool, FileSearchTool) + assert tool["vector_store_ids"] == ["vs-123"] + + +def test_get_file_search_tool_with_options() -> None: + """Test get_file_search_tool with max_num_results.""" + tool = AzureAIClient.get_file_search_tool( + vector_store_ids=["vs-123"], + max_num_results=10, + ) + assert isinstance(tool, FileSearchTool) + assert tool["max_num_results"] == 10 + + +def test_get_file_search_tool_requires_vector_store_ids() -> None: + """Test get_file_search_tool raises ValueError when vector_store_ids is empty.""" + with pytest.raises(ValueError, match="vector_store_ids"): + AzureAIClient.get_file_search_tool(vector_store_ids=[]) + + +def test_get_web_search_tool_basic() -> None: + """Test get_web_search_tool returns WebSearchPreviewTool.""" + tool = AzureAIClient.get_web_search_tool() + assert isinstance(tool, WebSearchPreviewTool) + + +def test_get_web_search_tool_with_location() -> None: + """Test get_web_search_tool with user_location.""" + tool = AzureAIClient.get_web_search_tool( + user_location={"city": "Seattle", "country": "US"}, + ) + assert isinstance(tool, WebSearchPreviewTool) + assert tool.user_location is not None + assert tool.user_location.city == "Seattle" + assert tool.user_location.country == "US" + + +def test_get_web_search_tool_with_search_context_size() -> None: + """Test get_web_search_tool with search_context_size.""" + tool = AzureAIClient.get_web_search_tool(search_context_size="high") + assert isinstance(tool, WebSearchPreviewTool) + assert tool.search_context_size == "high" + + +def test_get_mcp_tool_basic() -> None: + """Test get_mcp_tool returns MCPTool.""" + tool = AzureAIClient.get_mcp_tool(name="test_mcp", url="https://example.com") + assert isinstance(tool, MCPTool) + assert tool["server_label"] == "test_mcp" + assert tool["server_url"] == "https://example.com" + + +def test_get_mcp_tool_with_description() -> None: + """Test get_mcp_tool with description.""" + tool = AzureAIClient.get_mcp_tool( + name="test_mcp", + url="https://example.com", + description="Test MCP server", + ) + assert tool["server_description"] == "Test MCP server" + + +def test_get_mcp_tool_with_project_connection_id() -> None: + """Test get_mcp_tool with project_connection_id.""" + tool = AzureAIClient.get_mcp_tool( + name="test_mcp", + project_connection_id="conn-123", + ) + assert tool["project_connection_id"] == "conn-123" + + +def test_get_image_generation_tool_basic() -> None: + """Test get_image_generation_tool returns ImageGenTool.""" + tool = AzureAIClient.get_image_generation_tool() + assert isinstance(tool, ImageGenTool) + + +def test_get_image_generation_tool_with_options() -> None: + """Test get_image_generation_tool with various options.""" + tool = AzureAIClient.get_image_generation_tool( + size="1024x1024", + quality="high", + output_format="png", + ) + assert isinstance(tool, ImageGenTool) + assert tool["size"] == "1024x1024" + assert tool["quality"] == "high" + assert tool["output_format"] == "png" + + +# endregion diff --git a/python/packages/azure-ai/tests/test_provider.py b/python/packages/azure-ai/tests/test_provider.py index c209d14fd6..1b673d2ded 100644 --- a/python/packages/azure-ai/tests/test_provider.py +++ b/python/packages/azure-ai/tests/test_provider.py @@ -4,7 +4,7 @@ import os from unittest.mock import AsyncMock, MagicMock, patch import pytest -from agent_framework import ChatAgent, FunctionTool +from agent_framework import Agent, FunctionTool from agent_framework._mcp import MCPTool from agent_framework.exceptions import ServiceInitializationError from azure.ai.projects.aio import AIProjectClient @@ -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) @@ -158,7 +159,7 @@ async def test_provider_create_agent( description="Test Agent", ) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) assert agent.name == "test-agent" mock_project_client.agents.create_version.assert_called_once() @@ -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) @@ -192,7 +195,7 @@ async def test_provider_create_agent_with_env_model( # Call without model parameter - should use env var agent = await provider.create_agent(name="test-agent") - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) # Verify the model from env var was used call_args = mock_project_client.agents.create_version.call_args assert call_args[1]["definition"].model == azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] @@ -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) @@ -322,7 +328,7 @@ async def test_provider_get_agent_with_name(mock_project_client: MagicMock) -> N agent = await provider.get_agent(name="test-agent") - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) assert agent.name == "test-agent" mock_project_client.agents.get.assert_called_with(agent_name="test-agent") @@ -350,7 +356,7 @@ async def test_provider_get_agent_with_reference(mock_project_client: MagicMock) agent_reference = AgentReference(name="test-agent", version="1.0") agent = await provider.get_agent(reference=agent_reference) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) assert agent.name == "test-agent" mock_project_client.agents.get_version.assert_called_with(agent_name="test-agent", agent_version="1.0") @@ -410,7 +416,7 @@ def test_provider_as_agent(mock_project_client: MagicMock) -> None: with patch("agent_framework_azure_ai._project_provider.AzureAIClient") as mock_azure_ai_client: agent = provider.as_agent(mock_agent_version) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) assert agent.name == "test-agent" assert agent.description == "Test Agent" @@ -440,19 +446,17 @@ def test_provider_merge_tools_skips_function_tool_dicts(mock_project_client: Mag # Call _merge_tools with user-provided function implementation merged = provider._merge_tools(definition_tools, [mock_ai_function]) # type: ignore - # Should have 2 items: the converted HostedMCPTool and the user-provided FunctionTool + # Should have 2 items: the converted MCP dict and the user-provided FunctionTool assert len(merged) == 2 # Check that the function tool dict was NOT included (it was skipped) function_dicts = [t for t in merged if isinstance(t, dict) and t.get("type") == "function"] assert len(function_dicts) == 0 - # Check that the MCP tool was converted to HostedMCPTool - from agent_framework import HostedMCPTool - - mcp_tools = [t for t in merged if isinstance(t, HostedMCPTool)] + # Check that the MCP tool was converted to dict + mcp_tools = [t for t in merged if isinstance(t, dict) and t.get("type") == "mcp"] assert len(mcp_tools) == 1 - assert mcp_tools[0].name == "my mcp" # server_label with _ replaced by space + assert mcp_tools[0]["server_label"] == "my_mcp" # Check that the user-provided FunctionTool was included ai_functions = [t for t in merged if isinstance(t, FunctionTool)] @@ -467,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 @@ -496,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() @@ -583,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) @@ -644,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) @@ -709,7 +721,7 @@ async def test_provider_create_and_get_agent_integration() -> None: instructions="You are a helpful assistant. Always respond with 'Hello from provider!'", ) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) assert agent.name == "ProviderTestAgent" # Run the agent diff --git a/python/packages/azure-ai/tests/test_shared.py b/python/packages/azure-ai/tests/test_shared.py index 1a0292287d..b6f097bf85 100644 --- a/python/packages/azure-ai/tests/test_shared.py +++ b/python/packages/azure-ai/tests/test_shared.py @@ -5,29 +5,26 @@ from unittest.mock import MagicMock, patch import pytest from agent_framework import ( - Content, FunctionTool, - HostedCodeInterpreterTool, - HostedFileSearchTool, - HostedImageGenerationTool, - HostedMCPTool, - HostedWebSearchTool, ) -from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidRequestError +from agent_framework.exceptions import ServiceInvalidRequestError from azure.ai.agents.models import CodeInterpreterToolDefinition from pydantic import BaseModel +from agent_framework_azure_ai import AzureAIAgentClient from agent_framework_azure_ai._shared import ( _convert_response_format, # type: ignore _convert_sdk_tool, # type: ignore _extract_project_connection_id, # type: ignore - _prepare_mcp_tool_for_azure_ai, # type: ignore create_text_format_config, from_azure_ai_agent_tools, from_azure_ai_tools, to_azure_ai_agent_tools, to_azure_ai_tools, ) +from agent_framework_azure_ai._shared import ( + _prepare_mcp_tool_dict_for_azure_ai as _prepare_mcp_tool_for_azure_ai, # type: ignore +) def test_extract_project_connection_id_direct() -> None: @@ -69,16 +66,15 @@ def test_to_azure_ai_agent_tools_function_tool() -> None: def test_to_azure_ai_agent_tools_code_interpreter() -> None: - """Test converting HostedCodeInterpreterTool.""" - tool = HostedCodeInterpreterTool() + """Test converting code_interpreter dict tool.""" + tool = AzureAIAgentClient.get_code_interpreter_tool() result = to_azure_ai_agent_tools([tool]) assert len(result) == 1 assert isinstance(result[0], CodeInterpreterToolDefinition) def test_to_azure_ai_agent_tools_web_search_missing_connection() -> None: - """Test HostedWebSearchTool raises without connection info.""" - tool = HostedWebSearchTool() + """Test web search tool raises without connection info.""" # Clear any environment variables that could provide connection info with patch.dict( os.environ, @@ -90,8 +86,9 @@ def test_to_azure_ai_agent_tools_web_search_missing_connection() -> None: for key in ["BING_CONNECTION_ID", "BING_CUSTOM_CONNECTION_ID", "BING_CUSTOM_INSTANCE_NAME"]: env_backup[key] = os.environ.pop(key, None) try: - with pytest.raises(ServiceInitializationError, match="Bing search tool requires"): - to_azure_ai_agent_tools([tool]) + # get_web_search_tool now raises ValueError when no connection info is available + with pytest.raises(ValueError, match="Azure AI Agents requires a Bing connection"): + AzureAIAgentClient.get_web_search_tool() finally: # Restore environment for key, value in env_backup.items(): @@ -107,13 +104,15 @@ def test_to_azure_ai_agent_tools_dict_passthrough() -> None: def test_to_azure_ai_agent_tools_unsupported_type() -> None: - """Test unsupported tool type raises error.""" + """Test unsupported tool type passes through unchanged.""" class UnsupportedTool: pass - with pytest.raises(ServiceInitializationError, match="Unsupported tool type"): - to_azure_ai_agent_tools([UnsupportedTool()]) # type: ignore + unsupported = UnsupportedTool() + result = to_azure_ai_agent_tools([unsupported]) # type: ignore + assert len(result) == 1 + assert result[0] is unsupported # Passed through unchanged def test_from_azure_ai_agent_tools_empty() -> None: @@ -127,7 +126,7 @@ def test_from_azure_ai_agent_tools_code_interpreter() -> None: tool = CodeInterpreterToolDefinition() result = from_azure_ai_agent_tools([tool]) assert len(result) == 1 - assert isinstance(result[0], HostedCodeInterpreterTool) + assert result[0] == {"type": "code_interpreter"} def test_convert_sdk_tool_code_interpreter() -> None: @@ -135,7 +134,7 @@ def test_convert_sdk_tool_code_interpreter() -> None: tool = MagicMock() tool.type = "code_interpreter" result = _convert_sdk_tool(tool) - assert isinstance(result, HostedCodeInterpreterTool) + assert result == {"type": "code_interpreter"} def test_convert_sdk_tool_function_returns_none() -> None: @@ -161,8 +160,8 @@ def test_convert_sdk_tool_file_search() -> None: tool.file_search = MagicMock() tool.file_search.vector_store_ids = ["vs-1", "vs-2"] result = _convert_sdk_tool(tool) - assert isinstance(result, HostedFileSearchTool) - assert len(result.inputs) == 2 # type: ignore + assert result["type"] == "file_search" + assert result["vector_store_ids"] == ["vs-1", "vs-2"] def test_convert_sdk_tool_bing_grounding() -> None: @@ -172,8 +171,8 @@ def test_convert_sdk_tool_bing_grounding() -> None: tool.bing_grounding = MagicMock() tool.bing_grounding.connection_id = "conn-123" result = _convert_sdk_tool(tool) - assert isinstance(result, HostedWebSearchTool) - assert result.additional_properties["connection_id"] == "conn-123" # type: ignore + assert result["type"] == "bing_grounding" + assert result["connection_id"] == "conn-123" def test_convert_sdk_tool_bing_custom_search() -> None: @@ -184,9 +183,9 @@ def test_convert_sdk_tool_bing_custom_search() -> None: tool.bing_custom_search.connection_id = "conn-123" tool.bing_custom_search.instance_name = "my-instance" result = _convert_sdk_tool(tool) - assert isinstance(result, HostedWebSearchTool) - assert result.additional_properties["custom_connection_id"] == "conn-123" # type: ignore - assert result.additional_properties["custom_instance_name"] == "my-instance" # type: ignore + assert result["type"] == "bing_custom_search" + assert result["connection_id"] == "conn-123" + assert result["instance_name"] == "my-instance" def test_to_azure_ai_tools_empty() -> None: @@ -196,14 +195,14 @@ def test_to_azure_ai_tools_empty() -> None: def test_to_azure_ai_tools_code_interpreter_with_file_ids() -> None: - """Test converting HostedCodeInterpreterTool with file inputs.""" - tool = HostedCodeInterpreterTool( - inputs=[Content.from_hosted_file(file_id="file-123")] # type: ignore - ) + """Test converting code_interpreter dict tool with file inputs.""" + tool = { + "type": "code_interpreter", + "file_ids": ["file-123"], + } result = to_azure_ai_tools([tool]) assert len(result) == 1 assert result[0]["type"] == "code_interpreter" - assert result[0]["container"]["file_ids"] == ["file-123"] def test_to_azure_ai_tools_function_tool() -> None: @@ -221,11 +220,12 @@ def test_to_azure_ai_tools_function_tool() -> None: def test_to_azure_ai_tools_file_search() -> None: - """Test converting HostedFileSearchTool.""" - tool = HostedFileSearchTool( - inputs=[Content.from_hosted_vector_store(vector_store_id="vs-123")], # type: ignore - max_results=10, - ) + """Test converting file_search dict tool.""" + tool = { + "type": "file_search", + "vector_store_ids": ["vs-123"], + "max_num_results": 10, + } result = to_azure_ai_tools([tool]) assert len(result) == 1 assert result[0]["type"] == "file_search" @@ -234,28 +234,29 @@ def test_to_azure_ai_tools_file_search() -> None: def test_to_azure_ai_tools_web_search_with_location() -> None: - """Test converting HostedWebSearchTool with user location.""" - tool = HostedWebSearchTool( - additional_properties={ - "user_location": { - "city": "Seattle", - "country": "US", - "region": "WA", - "timezone": "PST", - } - } - ) + """Test converting web_search dict tool with user location.""" + tool = { + "type": "web_search_preview", + "user_location": { + "city": "Seattle", + "country": "US", + "region": "WA", + "timezone": "PST", + }, + } result = to_azure_ai_tools([tool]) assert len(result) == 1 assert result[0]["type"] == "web_search_preview" def test_to_azure_ai_tools_image_generation() -> None: - """Test converting HostedImageGenerationTool.""" - tool = HostedImageGenerationTool( - options={"model_id": "gpt-image-1", "image_size": "1024x1024"}, - additional_properties={"quality": "high"}, - ) + """Test converting image_generation dict tool.""" + tool = { + "type": "image_generation", + "model": "gpt-image-1", + "size": "1024x1024", + "quality": "high", + } result = to_azure_ai_tools([tool]) assert len(result) == 1 assert result[0]["type"] == "image_generation" @@ -264,7 +265,7 @@ def test_to_azure_ai_tools_image_generation() -> None: def test_prepare_mcp_tool_basic() -> None: """Test basic MCP tool conversion.""" - tool = HostedMCPTool(name="my tool", url="http://localhost:8080") + tool = {"type": "mcp", "server_label": "my_tool", "server_url": "http://localhost:8080"} result = _prepare_mcp_tool_for_azure_ai(tool) assert result["server_label"] == "my_tool" assert "http://localhost:8080" in result["server_url"] @@ -272,26 +273,37 @@ def test_prepare_mcp_tool_basic() -> None: def test_prepare_mcp_tool_with_description() -> None: """Test MCP tool with description.""" - tool = HostedMCPTool(name="my tool", url="http://localhost:8080", description="My MCP server") + tool = { + "type": "mcp", + "server_label": "my_tool", + "server_url": "http://localhost:8080", + "server_description": "My MCP server", + } result = _prepare_mcp_tool_for_azure_ai(tool) assert result["server_description"] == "My MCP server" def test_prepare_mcp_tool_with_headers() -> None: """Test MCP tool with headers (no project_connection_id).""" - tool = HostedMCPTool(name="my tool", url="http://localhost:8080", headers={"X-Api-Key": "secret"}) + tool = { + "type": "mcp", + "server_label": "my_tool", + "server_url": "http://localhost:8080", + "headers": {"X-Api-Key": "secret"}, + } result = _prepare_mcp_tool_for_azure_ai(tool) assert result["headers"] == {"X-Api-Key": "secret"} def test_prepare_mcp_tool_project_connection_takes_precedence() -> None: """Test project_connection_id takes precedence over headers.""" - tool = HostedMCPTool( - name="my tool", - url="http://localhost:8080", - headers={"X-Api-Key": "secret"}, - additional_properties={"project_connection_id": "my-conn"}, - ) + tool = { + "type": "mcp", + "server_label": "my_tool", + "server_url": "http://localhost:8080", + "headers": {"X-Api-Key": "secret"}, + "project_connection_id": "my-conn", + } result = _prepare_mcp_tool_for_azure_ai(tool) assert result["project_connection_id"] == "my-conn" assert "headers" not in result @@ -299,30 +311,38 @@ def test_prepare_mcp_tool_project_connection_takes_precedence() -> None: def test_prepare_mcp_tool_approval_mode_always() -> None: """Test MCP tool with always_require approval mode.""" - tool = HostedMCPTool(name="my tool", url="http://localhost:8080", approval_mode="always_require") + tool = { + "type": "mcp", + "server_label": "my_tool", + "server_url": "http://localhost:8080", + "require_approval": "always", + } result = _prepare_mcp_tool_for_azure_ai(tool) assert result["require_approval"] == "always" def test_prepare_mcp_tool_approval_mode_never() -> None: """Test MCP tool with never_require approval mode.""" - tool = HostedMCPTool(name="my tool", url="http://localhost:8080", approval_mode="never_require") + tool = { + "type": "mcp", + "server_label": "my_tool", + "server_url": "http://localhost:8080", + "require_approval": "never", + } result = _prepare_mcp_tool_for_azure_ai(tool) assert result["require_approval"] == "never" def test_prepare_mcp_tool_approval_mode_dict() -> None: """Test MCP tool with dict approval mode.""" - tool = HostedMCPTool( - name="my tool", - url="http://localhost:8080", - approval_mode={ - "always_require_approval": {"sensitive_tool"}, - "never_require_approval": {"safe_tool"}, - }, - ) + tool = { + "type": "mcp", + "server_label": "my_tool", + "server_url": "http://localhost:8080", + "require_approval": {"always": {"tool_names": ["sensitive_tool", "dangerous_tool"]}}, + } result = _prepare_mcp_tool_for_azure_ai(tool) - # The last assignment wins in the current implementation + # The approval mode is passed through assert "require_approval" in result @@ -385,7 +405,7 @@ def test_convert_response_format_json_schema_missing_schema_raises() -> None: def test_from_azure_ai_tools_mcp_approval_mode_always() -> None: - """Test from_azure_ai_tools converts MCP require_approval='always' to approval_mode.""" + """Test from_azure_ai_tools converts MCP require_approval='always' to dict.""" tools = [ { "type": "mcp", @@ -396,12 +416,12 @@ def test_from_azure_ai_tools_mcp_approval_mode_always() -> None: ] result = from_azure_ai_tools(tools) assert len(result) == 1 - assert isinstance(result[0], HostedMCPTool) - assert result[0].approval_mode == "always_require" + assert result[0]["type"] == "mcp" + assert result[0]["require_approval"] == "always" def test_from_azure_ai_tools_mcp_approval_mode_never() -> None: - """Test from_azure_ai_tools converts MCP require_approval='never' to approval_mode.""" + """Test from_azure_ai_tools converts MCP require_approval='never' to dict.""" tools = [ { "type": "mcp", @@ -412,8 +432,8 @@ def test_from_azure_ai_tools_mcp_approval_mode_never() -> None: ] result = from_azure_ai_tools(tools) assert len(result) == 1 - assert isinstance(result[0], HostedMCPTool) - assert result[0].approval_mode == "never_require" + assert result[0]["type"] == "mcp" + assert result[0]["require_approval"] == "never" def test_from_azure_ai_tools_mcp_approval_mode_dict_always() -> None: @@ -428,8 +448,8 @@ def test_from_azure_ai_tools_mcp_approval_mode_dict_always() -> None: ] result = from_azure_ai_tools(tools) assert len(result) == 1 - assert isinstance(result[0], HostedMCPTool) - assert result[0].approval_mode == {"always_require_approval": {"sensitive_tool", "dangerous_tool"}} + assert result[0]["type"] == "mcp" + assert result[0]["require_approval"] == {"always": {"tool_names": ["sensitive_tool", "dangerous_tool"]}} def test_from_azure_ai_tools_mcp_approval_mode_dict_never() -> None: @@ -444,5 +464,5 @@ def test_from_azure_ai_tools_mcp_approval_mode_dict_never() -> None: ] result = from_azure_ai_tools(tools) assert len(result) == 1 - assert isinstance(result[0], HostedMCPTool) - assert result[0].approval_mode == {"never_require_approval": {"safe_tool"}} + assert result[0]["type"] == "mcp" + assert result[0]["require_approval"] == {"never": {"tool_names": ["safe_tool"]}} diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py index 724b95015b..886e0d8588 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py @@ -135,8 +135,8 @@ class AgentFunctionApp(DFAppBase): @app.orchestration_trigger(context_name="context") def my_orchestration(context): writer = app.get_agent(context, "WeatherAgent") - thread = writer.get_new_thread() - forecast_task = writer.run("What's the forecast?", thread=thread) + session = writer.create_session() + forecast_task = writer.run("What's the forecast?", session=session) forecast = yield forecast_task return forecast diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py index 4e55fe1819..5875f9119b 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py @@ -9,7 +9,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any, TypeAlias import azure.durable_functions as df -from agent_framework import AgentThread, get_logger +from agent_framework import AgentSession, get_logger from agent_framework_durabletask import ( DurableAgentExecutor, RunRequest, @@ -178,11 +178,11 @@ class AzureFunctionsAgentExecutor(DurableAgentExecutor[AgentTask]): self, agent_name: str, run_request: RunRequest, - thread: AgentThread | None = None, + session: AgentSession | None = None, ) -> AgentTask: # Resolve session - session_id = self._create_session_id(agent_name, thread) + session_id = self._create_session_id(agent_name, session) entity_id = df.EntityId( name=session_id.entity_name, diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml index 45b8bbdce9..6246b686a6 100644 --- a/python/packages/azurefunctions/pyproject.toml +++ b/python/packages/azurefunctions/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "agent-framework-durabletask", "azure-functions", "azure-functions-durable", diff --git a/python/packages/azurefunctions/tests/integration_tests/conftest.py b/python/packages/azurefunctions/tests/integration_tests/conftest.py index 53a6de926d..cec3758aff 100644 --- a/python/packages/azurefunctions/tests/integration_tests/conftest.py +++ b/python/packages/azurefunctions/tests/integration_tests/conftest.py @@ -300,7 +300,7 @@ def _get_sample_path_from_marker(request: pytest.FixtureRequest) -> tuple[Path | sample_name = marker.args[0] repo_root = _resolve_repo_root() - sample_path = repo_root / "samples" / "getting_started" / "azure_functions" / sample_name + sample_path = repo_root / "samples" / "04-hosting" / "azure_functions" / sample_name if not sample_path.exists(): return None, f"Sample directory does not exist: {sample_path}" diff --git a/python/packages/azurefunctions/tests/test_app.py b/python/packages/azurefunctions/tests/test_app.py index f8b414fc34..cc68176c93 100644 --- a/python/packages/azurefunctions/tests/test_app.py +++ b/python/packages/azurefunctions/tests/test_app.py @@ -12,7 +12,7 @@ from unittest.mock import ANY, AsyncMock, Mock, patch import azure.durable_functions as df import azure.functions as func import pytest -from agent_framework import AgentResponse, ChatMessage +from agent_framework import AgentResponse, Message from agent_framework_durabletask import ( MIMETYPE_APPLICATION_JSON, MIMETYPE_TEXT_PLAIN, @@ -27,10 +27,10 @@ from agent_framework_durabletask import ( from agent_framework_azurefunctions import AgentFunctionApp from agent_framework_azurefunctions._entities import create_agent_entity -TFunc = TypeVar("TFunc", bound=Callable[..., Any]) +FuncT = TypeVar("FuncT", bound=Callable[..., Any]) -def _identity_decorator(func: TFunc) -> TFunc: +def _identity_decorator(func: FuncT) -> FuncT: return func @@ -165,8 +165,8 @@ class TestAgentFunctionAppSetup: mock_agent = Mock() mock_agent.name = "TestAgent" - def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: - def decorator(func: TFunc) -> TFunc: + def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: + def decorator(func: FuncT) -> FuncT: return func return decorator @@ -190,15 +190,15 @@ class TestAgentFunctionAppSetup: def capture_function_name( self: AgentFunctionApp, name: str, *args: Any, **kwargs: Any - ) -> Callable[[TFunc], TFunc]: - def decorator(func: TFunc) -> TFunc: + ) -> Callable[[FuncT], FuncT]: + def decorator(func: FuncT) -> FuncT: captured_names.append(name) return func return decorator - def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: - def decorator(func: TFunc) -> TFunc: + def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: + def decorator(func: FuncT) -> FuncT: return func return decorator @@ -220,16 +220,16 @@ class TestAgentFunctionAppSetup: captured_routes: list[str | None] = [] - def capture_route(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: - def decorator(func: TFunc) -> TFunc: + def capture_route(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: + def decorator(func: FuncT) -> FuncT: route_key = kwargs.get("route") if kwargs else None captured_routes.append(route_key) return func return decorator - def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: - def decorator(func: TFunc) -> TFunc: + def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: + def decorator(func: FuncT) -> FuncT: return func return decorator @@ -356,7 +356,7 @@ class TestAgentEntityOperations: """Test that entity can run agent operation.""" mock_agent = Mock() mock_agent.run = AsyncMock( - return_value=AgentResponse(messages=[ChatMessage(role="assistant", text="Test response")]) + return_value=AgentResponse(messages=[Message(role="assistant", text="Test response")]) ) entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="test-conv-123")) @@ -373,9 +373,7 @@ class TestAgentEntityOperations: async def test_entity_stores_conversation_history(self) -> None: """Test that the entity stores conversation history.""" mock_agent = Mock() - mock_agent.run = AsyncMock( - return_value=AgentResponse(messages=[ChatMessage(role="assistant", text="Response 1")]) - ) + mock_agent.run = AsyncMock(return_value=AgentResponse(messages=[Message(role="assistant", text="Response 1")])) entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1")) @@ -407,9 +405,7 @@ class TestAgentEntityOperations: async def test_entity_increments_message_count(self) -> None: """Test that the entity increments the message count.""" mock_agent = Mock() - mock_agent.run = AsyncMock( - return_value=AgentResponse(messages=[ChatMessage(role="assistant", text="Response")]) - ) + mock_agent.run = AsyncMock(return_value=AgentResponse(messages=[Message(role="assistant", text="Response")])) entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1")) @@ -448,9 +444,7 @@ class TestAgentEntityFactory: def test_entity_function_handles_run_operation(self) -> None: """Test that the entity function handles the run operation.""" mock_agent = Mock() - mock_agent.run = AsyncMock( - return_value=AgentResponse(messages=[ChatMessage(role="assistant", text="Response")]) - ) + mock_agent.run = AsyncMock(return_value=AgentResponse(messages=[Message(role="assistant", text="Response")])) entity_function = create_agent_entity(mock_agent) @@ -475,9 +469,7 @@ class TestAgentEntityFactory: def test_entity_function_handles_run_agent_operation(self) -> None: """Test that the entity function handles the deprecated run_agent operation for backward compatibility.""" mock_agent = Mock() - mock_agent.run = AsyncMock( - return_value=AgentResponse(messages=[ChatMessage(role="assistant", text="Response")]) - ) + mock_agent.run = AsyncMock(return_value=AgentResponse(messages=[Message(role="assistant", text="Response")])) entity_function = create_agent_entity(mock_agent) @@ -738,14 +730,14 @@ class TestHttpRunRoute: def _get_run_handler(agent: Mock) -> Callable[[func.HttpRequest, Any], Awaitable[func.HttpResponse]]: captured_handlers: dict[str | None, Callable[..., Awaitable[func.HttpResponse]]] = {} - def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: - def decorator(func: TFunc) -> TFunc: + def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: + def decorator(func: FuncT) -> FuncT: return func return decorator - def capture_route(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: - def decorator(func: TFunc) -> TFunc: + def capture_route(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: + def decorator(func: FuncT) -> FuncT: route_key = kwargs.get("route") if kwargs else None captured_handlers[route_key] = func return func @@ -1144,8 +1136,8 @@ class TestMCPToolEndpoint: # Capture the health check handler function captured_handler: Callable[[func.HttpRequest], func.HttpResponse] | None = None - def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: - def decorator(func: TFunc) -> TFunc: + def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: + def decorator(func: FuncT) -> FuncT: nonlocal captured_handler captured_handler = func return func diff --git a/python/packages/azurefunctions/tests/test_entities.py b/python/packages/azurefunctions/tests/test_entities.py index 2294101164..2fdbc3463e 100644 --- a/python/packages/azurefunctions/tests/test_entities.py +++ b/python/packages/azurefunctions/tests/test_entities.py @@ -10,16 +10,16 @@ from typing import Any, TypeVar from unittest.mock import AsyncMock, Mock import pytest -from agent_framework import AgentResponse, ChatMessage +from agent_framework import AgentResponse, Message from agent_framework_azurefunctions._entities import create_agent_entity -TFunc = TypeVar("TFunc", bound=Callable[..., Any]) +FuncT = TypeVar("FuncT", bound=Callable[..., Any]) def _agent_response(text: str | None) -> AgentResponse: """Create an AgentResponse with a single assistant message.""" - message = ChatMessage(role="assistant", text=text) if text is not None else ChatMessage(role="assistant", text="") + message = Message(role="assistant", text=text) if text is not None else Message(role="assistant", text="") return AgentResponse(messages=[message]) diff --git a/python/packages/azurefunctions/tests/test_orchestration.py b/python/packages/azurefunctions/tests/test_orchestration.py index 989d391e68..d1be7d9a77 100644 --- a/python/packages/azurefunctions/tests/test_orchestration.py +++ b/python/packages/azurefunctions/tests/test_orchestration.py @@ -6,7 +6,7 @@ from typing import Any from unittest.mock import Mock import pytest -from agent_framework import AgentResponse, ChatMessage +from agent_framework import AgentResponse, Message from agent_framework_durabletask import DurableAIAgent from azure.durable_functions.models.Task import TaskBase, TaskState @@ -136,7 +136,7 @@ class TestAgentResponseHelpers: # Simulate successful entity task completion entity_task.state = TaskState.SUCCEEDED - entity_task.result = AgentResponse(messages=[ChatMessage(role="assistant", text="Test response")]).to_dict() + entity_task.result = AgentResponse(messages=[Message(role="assistant", text="Test response")]).to_dict() # Clear pending_tasks to simulate that parent has processed the child task.pending_tasks.clear() @@ -178,7 +178,7 @@ class TestAgentResponseHelpers: # Simulate successful entity task with JSON response entity_task.state = TaskState.SUCCEEDED - entity_task.result = AgentResponse(messages=[ChatMessage(role="assistant", text='{"answer": "42"}')]).to_dict() + entity_task.result = AgentResponse(messages=[Message(role="assistant", text='{"answer": "42"}')]).to_dict() # Clear pending_tasks to simulate that parent has processed the child task.pending_tasks.clear() @@ -214,10 +214,10 @@ class TestAzureFunctionsFireAndForget: context.call_entity = Mock(return_value=_create_entity_task()) agent = DurableAIAgent(executor, "TestAgent") - thread = agent.get_new_thread() + session = agent.create_session() # Run with wait_for_response=False - result = agent.run("Test message", thread=thread, options={"wait_for_response": False}) + result = agent.run("Test message", session=session, options={"wait_for_response": False}) # Verify signal_entity was called and call_entity was not assert context.signal_entity.call_count == 1 @@ -232,9 +232,9 @@ class TestAzureFunctionsFireAndForget: context.signal_entity = Mock() agent = DurableAIAgent(executor, "TestAgent") - thread = agent.get_new_thread() + session = agent.create_session() - result = agent.run("Test message", thread=thread, options={"wait_for_response": False}) + result = agent.run("Test message", session=session, options={"wait_for_response": False}) # Task should be immediately complete assert isinstance(result, AgentTask) @@ -246,9 +246,9 @@ class TestAzureFunctionsFireAndForget: context.signal_entity = Mock() agent = DurableAIAgent(executor, "TestAgent") - thread = agent.get_new_thread() + session = agent.create_session() - result = agent.run("Test message", thread=thread, options={"wait_for_response": False}) + result = agent.run("Test message", session=session, options={"wait_for_response": False}) # Get the result response = result.result @@ -267,9 +267,9 @@ class TestAzureFunctionsFireAndForget: context.call_entity = Mock(return_value=_create_entity_task()) agent = DurableAIAgent(executor, "TestAgent") - thread = agent.get_new_thread() + session = agent.create_session() - result = agent.run("Test message", thread=thread, options={"wait_for_response": True}) + result = agent.run("Test message", session=session, options={"wait_for_response": True}) # Verify call_entity was called and signal_entity was not assert context.call_entity.call_count == 1 @@ -298,15 +298,15 @@ class TestOrchestrationIntegration: # Create agent directly with executor (not via app.get_agent) agent = DurableAIAgent(executor, "WriterAgent") - # Create thread - thread = agent.get_new_thread() + # Create session + session = agent.create_session() # First call - returns AgentTask - task1 = agent.run("Write something", thread=thread) + task1 = agent.run("Write something", session=session) assert isinstance(task1, AgentTask) # Second call - returns AgentTask - task2 = agent.run("Improve: something", thread=thread) + task2 = agent.run("Improve: something", session=session) assert isinstance(task2, AgentTask) # Verify both calls used the same entity (same session key) @@ -315,7 +315,7 @@ class TestOrchestrationIntegration: # EntityId format is @dafx-writeragent@ expected_entity_id = f"@dafx-writeragent@{uuid_hexes[0]}" assert entity_calls[0]["entity_id"] == expected_entity_id - # generate_unique_id called 3 times: thread + 2 correlation IDs + # generate_unique_id called 3 times: session + 2 correlation IDs assert executor.generate_unique_id.call_count == 3 def test_multiple_agents_in_orchestration(self, executor_with_multiple_uuids: tuple[Any, Mock, list[str]]) -> None: @@ -334,12 +334,12 @@ class TestOrchestrationIntegration: writer = DurableAIAgent(executor, "WriterAgent") editor = DurableAIAgent(executor, "EditorAgent") - writer_thread = writer.get_new_thread() - editor_thread = editor.get_new_thread() + writer_session = writer.create_session() + editor_session = editor.create_session() # Call both agents - returns AgentTasks - writer_task = writer.run("Write", thread=writer_thread) - editor_task = editor.run("Edit", thread=editor_thread) + writer_task = writer.run("Write", session=writer_session) + editor_task = editor.run("Edit", session=editor_session) assert isinstance(writer_task, AgentTask) assert isinstance(editor_task, AgentTask) diff --git a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py index ca851269dc..3520d7b1a1 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py @@ -14,7 +14,6 @@ from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, BaseChatClient, ChatAndFunctionMiddlewareTypes, - ChatMessage, ChatMiddlewareLayer, ChatOptions, ChatResponse, @@ -24,20 +23,19 @@ from agent_framework import ( FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool, + Message, ResponseStream, - ToolProtocol, UsageDetails, get_logger, - 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 @@ -62,7 +60,7 @@ __all__ = [ "BedrockSettings", ] -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None) +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) # region Bedrock Chat Options TypedDict @@ -91,7 +89,7 @@ class BedrockGuardrailConfig(TypedDict, total=False): """How to process guardrails during streaming (sync blocks, async does not).""" -class BedrockChatOptions(ChatOptions[TResponseModel], Generic[TResponseModel], total=False): +class BedrockChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): """Amazon Bedrock Converse API-specific chat options dict. Extends base ChatOptions with Bedrock-specific parameters. @@ -183,7 +181,7 @@ BEDROCK_OPTION_TRANSLATIONS: dict[str, str] = { } """Maps ChatOptions keys to Bedrock Converse API parameter names.""" -TBedrockChatOptions = TypeVar("TBedrockChatOptions", bound=TypedDict, default="BedrockChatOptions", covariant=True) # type: ignore[valid-type] +BedrockChatOptionsT = TypeVar("BedrockChatOptionsT", bound=TypedDict, default="BedrockChatOptions", covariant=True) # type: ignore[valid-type] # endregion @@ -206,24 +204,22 @@ 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( - ChatMiddlewareLayer[TBedrockChatOptions], - FunctionInvocationLayer[TBedrockChatOptions], - ChatTelemetryLayer[TBedrockChatOptions], - BaseChatClient[TBedrockChatOptions], - Generic[TBedrockChatOptions], + ChatMiddlewareLayer[BedrockChatOptionsT], + FunctionInvocationLayer[BedrockChatOptionsT], + ChatTelemetryLayer[BedrockChatOptionsT], + BaseChatClient[BedrockChatOptionsT], + Generic[BedrockChatOptionsT], ): """Async chat client for Amazon Bedrock's Converse API with middleware, telemetry, and function invocation.""" @@ -281,24 +277,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), ) @@ -308,24 +305,24 @@ 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 def _inner_get_response( self, *, - messages: Sequence[ChatMessage], + messages: Sequence[Message], options: Mapping[str, Any], stream: bool = False, **kwargs: Any, @@ -359,7 +356,7 @@ class BedrockChatClient( def _prepare_options( self, - messages: Sequence[ChatMessage], + messages: Sequence[Message], options: Mapping[str, Any], **kwargs: Any, ) -> dict[str, Any]: @@ -410,7 +407,7 @@ class BedrockChatClient( return run_options def _prepare_bedrock_messages( - self, messages: Sequence[ChatMessage] + self, messages: Sequence[Message] ) -> tuple[list[dict[str, str]], list[dict[str, Any]]]: prompts: list[dict[str, str]] = [] conversation: list[dict[str, Any]] = [] @@ -482,7 +479,7 @@ class BedrockChatClient( return aligned_blocks - def _convert_message_to_content_blocks(self, message: ChatMessage) -> list[dict[str, Any]]: + def _convert_message_to_content_blocks(self, message: Message) -> list[dict[str, Any]]: blocks: list[dict[str, Any]] = [] for content in message.contents: block = self._convert_content_to_bedrock_block(content) @@ -530,7 +527,7 @@ class BedrockChatClient( return None def _convert_tool_result_to_blocks(self, result: Any) -> list[dict[str, Any]]: - prepared_result = prepare_function_call_results(result) + prepared_result = result if isinstance(result, str) else FunctionTool.parse_result(result) try: parsed_result = json.loads(prepared_result) except json.JSONDecodeError: @@ -564,7 +561,7 @@ class BedrockChatClient( return {"text": str(value)} return {"text": str(value)} - def _prepare_tools(self, tools: list[ToolProtocol | MutableMapping[str, Any]] | None) -> dict[str, Any] | None: + def _prepare_tools(self, tools: list[FunctionTool | MutableMapping[str, Any]] | None) -> dict[str, Any] | None: converted: list[dict[str, Any]] = [] if not tools: return None @@ -593,7 +590,7 @@ class BedrockChatClient( message = output.get("message", {}) content_blocks = message.get("content", []) or [] contents = self._parse_message_contents(content_blocks) - chat_message = ChatMessage(role="assistant", contents=contents, raw_representation=message) + chat_message = Message(role="assistant", contents=contents, raw_representation=message) usage_details = self._parse_usage(response.get("usage") or output.get("usage")) finish_reason = self._map_finish_reason(output.get("completionReason") or response.get("stopReason")) response_id = response.get("responseId") or message.get("id") diff --git a/python/packages/bedrock/pyproject.toml b/python/packages/bedrock/pyproject.toml index f424cbef6f..6e810db83d 100644 --- a/python/packages/bedrock/pyproject.toml +++ b/python/packages/bedrock/pyproject.toml @@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "boto3>=1.35.0,<2.0.0", "botocore>=1.35.0,<2.0.0", ] diff --git a/python/packages/bedrock/samples/bedrock_sample.py b/python/packages/bedrock/samples/bedrock_sample.py index 15a347997d..188e6bf1da 100644 --- a/python/packages/bedrock/samples/bedrock_sample.py +++ b/python/packages/bedrock/samples/bedrock_sample.py @@ -3,7 +3,7 @@ import asyncio import logging -from agent_framework import ChatAgent, tool +from agent_framework import Agent, tool from agent_framework_bedrock import BedrockChatClient @@ -17,8 +17,8 @@ def get_weather(city: str) -> dict[str, str]: async def main() -> None: """Run the Bedrock sample agent, invoke the weather tool, and log the response.""" - agent = ChatAgent( - chat_client=BedrockChatClient(), + agent = Agent( + client=BedrockChatClient(), instructions="You are a concise travel assistant.", name="BedrockWeatherAgent", tool_choice="auto", diff --git a/python/packages/bedrock/tests/test_bedrock_client.py b/python/packages/bedrock/tests/test_bedrock_client.py index d267691e71..ef896db9c3 100644 --- a/python/packages/bedrock/tests/test_bedrock_client.py +++ b/python/packages/bedrock/tests/test_bedrock_client.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any import pytest -from agent_framework import ChatMessage, Content +from agent_framework import Content, Message from agent_framework.exceptions import ServiceInitializationError from agent_framework_bedrock import BedrockChatClient @@ -41,8 +41,8 @@ async def test_get_response_invokes_bedrock_runtime() -> None: ) messages = [ - ChatMessage(role="system", contents=[Content.from_text(text="You are concise.")]), - ChatMessage(role="user", contents=[Content.from_text(text="hello")]), + Message(role="system", contents=[Content.from_text(text="You are concise.")]), + Message(role="user", contents=[Content.from_text(text="hello")]), ] response = await client.get_response(messages=messages, options={"max_tokens": 32}) @@ -62,7 +62,7 @@ def test_build_request_requires_non_system_messages() -> None: client=_StubBedrockRuntime(), ) - messages = [ChatMessage(role="system", contents=[Content.from_text(text="Only system text")])] + messages = [Message(role="system", contents=[Content.from_text(text="Only system text")])] with pytest.raises(ServiceInitializationError): client._prepare_options(messages, {}) diff --git a/python/packages/bedrock/tests/test_bedrock_settings.py b/python/packages/bedrock/tests/test_bedrock_settings.py index 25df37b11f..016ed8ff05 100644 --- a/python/packages/bedrock/tests/test_bedrock_settings.py +++ b/python/packages/bedrock/tests/test_bedrock_settings.py @@ -6,11 +6,12 @@ from unittest.mock import MagicMock import pytest from agent_framework import ( - ChatMessage, ChatOptions, Content, 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: @@ -46,7 +47,7 @@ def test_build_request_includes_tool_config() -> None: "tools": [tool], "tool_choice": {"mode": "required", "required_function_name": "get_weather"}, } - messages = [ChatMessage(role="user", contents=[Content.from_text(text="hi")])] + messages = [Message(role="user", contents=[Content.from_text(text="hi")])] request = client._prepare_options(messages, options) @@ -58,16 +59,16 @@ def test_build_request_serializes_tool_history() -> None: client = _build_client() options: ChatOptions = {} messages = [ - ChatMessage(role="user", contents=[Content.from_text(text="how's weather?")]), - ChatMessage( + Message(role="user", contents=[Content.from_text(text="how's weather?")]), + Message( role="assistant", contents=[ Content.from_function_call(call_id="call-1", name="get_weather", arguments='{"location": "SEA"}') ], ), - ChatMessage( + Message( role="tool", - contents=[Content.from_function_result(call_id="call-1", result={"answer": "72F"})], + contents=[Content.from_function_result(call_id="call-1", result='{"answer": "72F"}')], ), ] diff --git a/python/packages/chatkit/README.md b/python/packages/chatkit/README.md index cc48016561..874efaa097 100644 --- a/python/packages/chatkit/README.md +++ b/python/packages/chatkit/README.md @@ -7,9 +7,9 @@ Specifically, it mirrors the [Agent SDK integration](https://github.com/openai/c - `stream_agent_response`: A helper to convert a streamed `AgentResponseUpdate` from a Microsoft Agent Framework agent that implements `SupportsAgentRun` to ChatKit events. - `ThreadItemConverter`: A extendable helper class to convert ChatKit thread items to - `ChatMessage` objects that can be consumed by an Agent Framework agent. + `Message` objects that can be consumed by an Agent Framework agent. - `simple_to_agent_input`: A helper function that uses the default implementation - of `ThreadItemConverter` to convert a ChatKit thread to a list of `ChatMessage`, + of `ThreadItemConverter` to convert a ChatKit thread to a list of `Message`, useful for getting started quickly. ## Installation @@ -63,7 +63,7 @@ from azure.identity import AzureCliCredential from fastapi import FastAPI, Request from fastapi.responses import Response, StreamingResponse -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.azure import AzureOpenAIChatClient from agent_framework.chatkit import simple_to_agent_input, stream_agent_response @@ -74,8 +74,8 @@ from chatkit.types import ThreadMetadata, UserMessageItem, ThreadStreamEvent from your_store import YourStore # type: ignore[import-not-found] # Replace with your Store implementation # Define your agent with tools -agent = ChatAgent( - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), +agent = Agent( + client=AzureOpenAIChatClient(credential=AzureCliCredential()), instructions="You are a helpful assistant.", tools=[], # Add your tools here ) @@ -124,4 +124,4 @@ async def chatkit_endpoint(request: Request): return Response(content=result.json, media_type="application/json") # type: ignore[union-attr] ``` -For a complete end-to-end example with a full frontend, see the [weather agent sample](../../samples/demos/chatkit-integration/README.md). +For a complete end-to-end example with a full frontend, see the [weather agent sample](../../samples/05-end-to-end/chatkit-integration/README.md). diff --git a/python/packages/chatkit/agent_framework_chatkit/_converter.py b/python/packages/chatkit/agent_framework_chatkit/_converter.py index ca5127e8c7..5aa953e25a 100644 --- a/python/packages/chatkit/agent_framework_chatkit/_converter.py +++ b/python/packages/chatkit/agent_framework_chatkit/_converter.py @@ -9,8 +9,8 @@ import sys from collections.abc import Awaitable, Callable, Sequence from agent_framework import ( - ChatMessage, Content, + Message, ) from chatkit.types import ( AssistantMessageItem, @@ -39,7 +39,7 @@ logger = logging.getLogger(__name__) class ThreadItemConverter: - """Helper class to convert ChatKit thread items to Agent Framework ChatMessage objects. + """Helper class to convert ChatKit thread items to Agent Framework Message objects. This class provides a base implementation for converting ChatKit thread items to Agent Framework messages. It can be extended to handle attachments, @@ -64,8 +64,8 @@ class ThreadItemConverter: async def user_message_to_input( self, item: UserMessageItem, is_last_message: bool = True - ) -> ChatMessage | list[ChatMessage] | None: - """Convert a ChatKit UserMessageItem to Agent Framework ChatMessage(s). + ) -> Message | list[Message] | None: + """Convert a ChatKit UserMessageItem to Agent Framework Message(s). This method is called internally by `to_agent_input()`. Override this method to customize how user messages are converted. @@ -75,7 +75,7 @@ class ThreadItemConverter: is_last_message: Whether this is the last message in the thread (used for quoted_text handling). Returns: - A ChatMessage, list of messages, or None to skip. + A Message, list of messages, or None to skip. Note: Instead of calling this method directly, use `to_agent_input()` which handles @@ -102,19 +102,19 @@ class ThreadItemConverter: # If only text and no attachments, use text parameter for simplicity if text_content.strip() and not data_contents: - user_message = ChatMessage(role="user", text=text_content.strip()) + user_message = Message(role="user", text=text_content.strip()) else: # Build contents list with both text and attachments contents: list[Content] = [] if text_content.strip(): contents.append(Content.from_text(text=text_content.strip())) contents.extend(data_contents) - user_message = ChatMessage(role="user", contents=contents) + user_message = Message(role="user", contents=contents) # Handle quoted text if this is the last message messages = [user_message] if item.quoted_text and is_last_message: - quoted_context = ChatMessage( + quoted_context = Message( role="user", text=f"The user is referring to this in particular:\n{item.quoted_text}", ) @@ -179,10 +179,8 @@ class ThreadItemConverter: # Subclasses can override this method to provide custom handling return None - def hidden_context_to_input( - self, item: HiddenContextItem | SDKHiddenContextItem - ) -> ChatMessage | list[ChatMessage] | None: - """Convert a ChatKit HiddenContextItem or SDKHiddenContextItem to Agent Framework ChatMessage(s). + def hidden_context_to_input(self, item: HiddenContextItem | SDKHiddenContextItem) -> Message | list[Message] | None: + """Convert a ChatKit HiddenContextItem or SDKHiddenContextItem to Agent Framework Message(s). This method is called internally by `to_agent_input()`. Override this method to customize how hidden context is converted. @@ -195,7 +193,7 @@ class ThreadItemConverter: item: The ChatKit hidden context item to convert. Returns: - A ChatMessage with system role, a list of messages, or None to skip. + A Message with system role, a list of messages, or None to skip. Note: Instead of calling this method directly, use `to_agent_input()` which handles @@ -213,9 +211,9 @@ class ThreadItemConverter: content="User's email: user@example.com", ) message = converter.hidden_context_to_input(hidden_item) - # Returns: ChatMessage(role=SYSTEM, text="User's email: ...") + # Returns: Message(role=SYSTEM, text="User's email: ...") """ - return ChatMessage(role="system", text=f"{item.content}") + return Message(role="system", text=f"{item.content}") def tag_to_message_content(self, tag: UserMessageTagContent) -> Content: """Convert a ChatKit tag (@-mention) to Agent Framework content. @@ -250,8 +248,8 @@ class ThreadItemConverter: name = getattr(tag.data, "name", tag.text if hasattr(tag, "text") else "unknown") return Content.from_text(text=f"Name:{name}") - def task_to_input(self, item: TaskItem) -> ChatMessage | list[ChatMessage] | None: - """Convert a ChatKit TaskItem to Agent Framework ChatMessage(s). + def task_to_input(self, item: TaskItem) -> Message | list[Message] | None: + """Convert a ChatKit TaskItem to Agent Framework Message(s). This method is called internally by `to_agent_input()`. Override this method to customize how tasks are converted. @@ -263,7 +261,7 @@ class ThreadItemConverter: item: The ChatKit task item to convert. Returns: - A ChatMessage, a list of messages, or None to skip the task. + A Message, a list of messages, or None to skip the task. Note: Instead of calling this method directly, use `to_agent_input()` which handles @@ -294,10 +292,10 @@ class ThreadItemConverter: f"A message was displayed to the user that the following task was performed:\n\n{task_text}\n" ) - return ChatMessage(role="user", text=text) + return Message(role="user", text=text) - def workflow_to_input(self, item: WorkflowItem) -> ChatMessage | list[ChatMessage] | None: - """Convert a ChatKit WorkflowItem to Agent Framework ChatMessage(s). + def workflow_to_input(self, item: WorkflowItem) -> Message | list[Message] | None: + """Convert a ChatKit WorkflowItem to Agent Framework Message(s). This method is called internally by `to_agent_input()`. Override this method to customize how workflows are converted. @@ -336,7 +334,7 @@ class ThreadItemConverter: messages = converter.workflow_to_input(workflow_item) # Returns list of messages for each task """ - messages: list[ChatMessage] = [] + messages: list[Message] = [] for task in item.workflow.tasks: if task.type != "custom" or (not task.title and not task.content): continue @@ -349,12 +347,12 @@ class ThreadItemConverter: f"\n{task_text}\n" ) - messages.append(ChatMessage(role="user", text=text)) + messages.append(Message(role="user", text=text)) return messages if messages else None - def widget_to_input(self, item: WidgetItem) -> ChatMessage | list[ChatMessage] | None: - """Convert a ChatKit WidgetItem to Agent Framework ChatMessage(s). + def widget_to_input(self, item: WidgetItem) -> Message | list[Message] | None: + """Convert a ChatKit WidgetItem to Agent Framework Message(s). This method is called internally by `to_agent_input()`. Override this method to customize how widgets are converted. @@ -367,7 +365,7 @@ class ThreadItemConverter: item: The ChatKit widget item to convert. Returns: - A ChatMessage describing the widget, or None to skip. + A Message describing the widget, or None to skip. Note: Instead of calling this method directly, use `to_agent_input()` which handles @@ -391,13 +389,13 @@ class ThreadItemConverter: try: widget_json = item.widget.model_dump_json(exclude_unset=True, exclude_none=True) text = f"The following graphical UI widget (id: {item.id}) was displayed to the user:{widget_json}" - return ChatMessage(role="user", text=text) + return Message(role="user", text=text) except Exception: # If JSON serialization fails, skip the widget return None - async def assistant_message_to_input(self, item: AssistantMessageItem) -> ChatMessage | list[ChatMessage] | None: - """Convert a ChatKit AssistantMessageItem to Agent Framework ChatMessage(s). + async def assistant_message_to_input(self, item: AssistantMessageItem) -> Message | list[Message] | None: + """Convert a ChatKit AssistantMessageItem to Agent Framework Message(s). The default implementation extracts text from all content parts and creates an assistant message. @@ -406,7 +404,7 @@ class ThreadItemConverter: item: The ChatKit assistant message item to convert. Returns: - A ChatMessage with assistant role, or None to skip. + A Message with assistant role, or None to skip. Note: Instead of calling this method directly, use `to_agent_input()` which handles @@ -417,10 +415,10 @@ class ThreadItemConverter: if not text_parts: return None - return ChatMessage(role="assistant", text="".join(text_parts)) + return Message(role="assistant", text="".join(text_parts)) - async def client_tool_call_to_input(self, item: ClientToolCallItem) -> ChatMessage | list[ChatMessage] | None: - """Convert a ChatKit ClientToolCallItem to Agent Framework ChatMessage(s). + async def client_tool_call_to_input(self, item: ClientToolCallItem) -> Message | list[Message] | None: + """Convert a ChatKit ClientToolCallItem to Agent Framework Message(s). The default implementation converts completed tool calls into function call and result content. @@ -442,7 +440,7 @@ class ThreadItemConverter: import json # Create function call message - function_call_msg = ChatMessage( + function_call_msg = Message( role="assistant", contents=[ Content.from_function_call( @@ -454,7 +452,7 @@ class ThreadItemConverter: ) # Create function result message - function_result_msg = ChatMessage( + function_result_msg = Message( role="tool", contents=[ Content.from_function_result( @@ -466,8 +464,8 @@ class ThreadItemConverter: return [function_call_msg, function_result_msg] - async def end_of_turn_to_input(self, item: EndOfTurnItem) -> ChatMessage | list[ChatMessage] | None: - """Convert a ChatKit EndOfTurnItem to Agent Framework ChatMessage(s). + async def end_of_turn_to_input(self, item: EndOfTurnItem) -> Message | list[Message] | None: + """Convert a ChatKit EndOfTurnItem to Agent Framework Message(s). The default implementation skips end-of-turn markers as they are only UI hints. @@ -488,15 +486,15 @@ class ThreadItemConverter: self, item: ThreadItem, is_last_message: bool = True, - ) -> list[ChatMessage]: - """Internal method to convert a single ThreadItem to ChatMessage(s). + ) -> list[Message]: + """Internal method to convert a single ThreadItem to Message(s). Args: item: The thread item to convert. is_last_message: Whether this is the last item in the thread. Returns: - A list of ChatMessage objects (may be empty). + A list of Message objects (may be empty). """ match item: case UserMessageItem(): @@ -535,7 +533,7 @@ class ThreadItemConverter: async def to_agent_input( self, thread_items: Sequence[ThreadItem] | ThreadItem, - ) -> list[ChatMessage]: + ) -> list[Message]: """Convert ChatKit thread items to Agent Framework ChatMessages. This is the main entry point for converting ChatKit thread items. It handles @@ -546,7 +544,7 @@ class ThreadItemConverter: thread_items: A single ThreadItem or a sequence of ThreadItems to convert. Returns: - A list of ChatMessage objects that can be sent to an Agent Framework agent. + A list of Message objects that can be sent to an Agent Framework agent. Examples: .. code-block:: python @@ -562,14 +560,14 @@ class ThreadItemConverter: messages = await converter.to_agent_input([user_message_item, assistant_message_item, task_item]) # Use with agent - from agent_framework import ChatAgent + from agent_framework import Agent - agent = ChatAgent(...) + agent = Agent(...) response = await agent.run(messages) """ thread_items = list(thread_items) if isinstance(thread_items, Sequence) else [thread_items] - output: list[ChatMessage] = [] + output: list[Message] = [] for item in thread_items: output.extend( await self._thread_item_to_input_item( @@ -584,7 +582,7 @@ class ThreadItemConverter: _DEFAULT_CONVERTER = ThreadItemConverter() -async def simple_to_agent_input(thread_items: Sequence[ThreadItem] | ThreadItem) -> list[ChatMessage]: +async def simple_to_agent_input(thread_items: Sequence[ThreadItem] | ThreadItem) -> list[Message]: """Helper function that uses the default ThreadItemConverter. This function provides a quick way to get started with ChatKit integration @@ -594,7 +592,7 @@ async def simple_to_agent_input(thread_items: Sequence[ThreadItem] | ThreadItem) thread_items: A single ThreadItem or a sequence of ThreadItems to convert. Returns: - A list of ChatMessage objects that can be sent to an Agent Framework agent. + A list of Message objects that can be sent to an Agent Framework agent. Examples: .. code-block:: python diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index 89e95b1ab7..57381139ea 100644 --- a/python/packages/chatkit/pyproject.toml +++ b/python/packages/chatkit/pyproject.toml @@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "openai-chatkit>=1.4.0,<2.0.0", ] diff --git a/python/packages/chatkit/tests/test_converter.py b/python/packages/chatkit/tests/test_converter.py index 71400527aa..907a1ad0a9 100644 --- a/python/packages/chatkit/tests/test_converter.py +++ b/python/packages/chatkit/tests/test_converter.py @@ -5,7 +5,7 @@ from unittest.mock import Mock import pytest -from agent_framework import ChatMessage +from agent_framework import Message from chatkit.types import UserMessageTextContent from agent_framework_chatkit import ThreadItemConverter, simple_to_agent_input @@ -43,7 +43,7 @@ class TestThreadItemConverter: result = await converter.to_agent_input(input_item) assert len(result) == 1 - assert isinstance(result[0], ChatMessage) + assert isinstance(result[0], Message) assert result[0].role == "user" assert result[0].text == "Hello, how can you help me?" @@ -110,13 +110,13 @@ class TestThreadItemConverter: assert result[0].text == "Hello world!" def test_hidden_context_to_input(self, converter): - """Test converting hidden context item to ChatMessage.""" + """Test converting hidden context item to Message.""" hidden_item = Mock() hidden_item.content = "This is hidden context information" result = converter.hidden_context_to_input(hidden_item) - assert isinstance(result, ChatMessage) + assert isinstance(result, Message) assert result.role == "system" assert result.text == "This is hidden context information" @@ -288,7 +288,7 @@ class TestThreadItemConverter: assert message.contents[1].media_type == "application/pdf" def test_task_to_input(self, converter): - """Test converting TaskItem to ChatMessage.""" + """Test converting TaskItem to Message.""" from datetime import datetime from chatkit.types import CustomTask, TaskItem @@ -302,7 +302,7 @@ class TestThreadItemConverter: ) result = converter.task_to_input(task_item) - assert isinstance(result, ChatMessage) + assert isinstance(result, Message) assert result.role == "user" assert "Analysis: Analyzed the data" in result.text assert "" in result.text @@ -347,7 +347,7 @@ class TestThreadItemConverter: result = converter.workflow_to_input(workflow_item) assert isinstance(result, list) assert len(result) == 2 - assert all(isinstance(msg, ChatMessage) for msg in result) + assert all(isinstance(msg, Message) for msg in result) assert "Step 1: First step" in result[0].text assert "Step 2: Second step" in result[1].text @@ -369,7 +369,7 @@ class TestThreadItemConverter: assert result is None def test_widget_to_input(self, converter): - """Test converting WidgetItem to ChatMessage.""" + """Test converting WidgetItem to Message.""" from datetime import datetime from chatkit.types import WidgetItem @@ -384,7 +384,7 @@ class TestThreadItemConverter: ) result = converter.widget_to_input(widget_item) - assert isinstance(result, ChatMessage) + assert isinstance(result, Message) assert result.role == "user" assert "widget_1" in result.text assert "graphical UI widget" in result.text @@ -417,6 +417,6 @@ class TestSimpleToAgentInput: result = await simple_to_agent_input(input_item) assert len(result) == 1 - assert isinstance(result[0], ChatMessage) + assert isinstance(result[0], Message) assert result[0].role == "user" assert result[0].text == "Test message" diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index 579c2187ef..10ef5cbf45 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -12,18 +12,19 @@ from agent_framework import ( AgentMiddlewareTypes, AgentResponse, AgentResponseUpdate, - AgentThread, + AgentSession, BaseAgent, - ChatMessage, + BaseContextProvider, Content, - ContextProvider, FunctionTool, - ToolProtocol, + Message, + ResponseStream, 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, @@ -35,7 +36,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 @@ -139,15 +139,15 @@ class ClaudeAgentOptions(TypedDict, total=False): """Beta features to enable.""" -TOptions = TypeVar( - "TOptions", +OptionsT = TypeVar( + "OptionsT", bound=TypedDict, # type: ignore[valid-type] default="ClaudeAgentOptions", covariant=True, ) -class ClaudeAgent(BaseAgent, Generic[TOptions]): +class ClaudeAgent(BaseAgent, Generic[OptionsT]): """Claude Agent using Claude Code CLI. Wraps the Claude Agent SDK to provide agentic capabilities including @@ -185,9 +185,9 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): .. code-block:: python async with ClaudeAgent() as agent: - thread = agent.get_new_thread() - await agent.run("Remember my name is Alice", thread=thread) - response = await agent.run("What's my name?", thread=thread) + session = agent.create_session() + await agent.run("Remember my name is Alice", session=session) + response = await agent.run("What's my name?", session=session) # Claude will remember "Alice" from the same session With Agent Framework tools: @@ -215,15 +215,15 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): id: str | None = None, name: str | None = None, description: str | None = None, - context_provider: ContextProvider | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, middleware: Sequence[AgentMiddlewareTypes] | None = None, - tools: ToolProtocol + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | str - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any] | str] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | str] | None = None, - default_options: TOptions | MutableMapping[str, Any] | None = None, + default_options: OptionsT | MutableMapping[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -238,11 +238,11 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): id: Unique identifier for the agent. name: Name of the agent. description: Description of the agent. - context_provider: Context provider for the agent. + context_providers: Context providers for the agent. middleware: List of middleware. tools: Tools for the agent. Can be: - Strings for built-in tools (e.g., "Read", "Write", "Bash", "Glob") - - Functions or ToolProtocol instances for custom tools + - Functions for custom tools default_options: Default ClaudeAgentOptions including system_prompt, model, etc. env_file_path: Path to .env file. env_file_encoding: Encoding of .env file. @@ -251,7 +251,7 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): id=id, name=name, description=description, - context_provider=context_provider, + context_providers=context_providers, middleware=middleware, ) @@ -274,23 +274,22 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): 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/ToolProtocol) + # Separate built-in tools (strings) from custom tools (callables/FunctionTool) self._builtin_tools: list[str] = [] - self._custom_tools: list[ToolProtocol | MutableMapping[str, Any]] = [] + self._custom_tools: list[FunctionTool | MutableMapping[str, Any]] = [] self._normalize_tools(tools) self._default_options = opts @@ -299,11 +298,11 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): def _normalize_tools( self, - tools: ToolProtocol + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | str - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any] | str] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | str] | None, ) -> None: """Separate built-in tools (strings) from custom tools. @@ -317,7 +316,7 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): # Normalize to sequence if isinstance(tools, str): tools_list: Sequence[Any] = [tools] - elif isinstance(tools, (ToolProtocol, MutableMapping)) or callable(tools): + elif isinstance(tools, (FunctionTool, MutableMapping)) or callable(tools): tools_list = [tools] else: tools_list = list(tools) @@ -330,7 +329,7 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): normalized = normalize_tools(tool) self._custom_tools.extend(normalized) - async def __aenter__(self) -> ClaudeAgent[TOptions]: + async def __aenter__(self) -> ClaudeAgent[OptionsT]: """Start the agent when entering async context.""" await self.start() return self @@ -412,18 +411,18 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): 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(): @@ -458,7 +457,7 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): def _prepare_tools( self, - tools: list[ToolProtocol | MutableMapping[str, Any]], + tools: list[FunctionTool | MutableMapping[str, Any]], ) -> tuple[Any, list[str]]: """Convert Agent Framework tools to SDK MCP server. @@ -476,7 +475,8 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): sdk_tools.append(self._function_tool_to_sdk_mcp_tool(tool)) # Claude Agent SDK convention: MCP tools use format "mcp__{server}__{tool}" tool_names.append(f"mcp__{TOOLS_MCP_SERVER_NAME}__{tool.name}") - elif isinstance(tool, ToolProtocol): + else: + # Non-FunctionTool items (e.g., dict-based hosted tools) cannot be converted to SDK MCP tools logger.debug(f"Unsupported tool type: {type(tool)}") if not sdk_tools: @@ -484,7 +484,7 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): return create_sdk_mcp_server(name=TOOLS_MCP_SERVER_NAME, tools=sdk_tools), tool_names - def _function_tool_to_sdk_mcp_tool(self, func_tool: FunctionTool[Any, Any]) -> SdkMcpTool[Any]: + def _function_tool_to_sdk_mcp_tool(self, func_tool: FunctionTool[Any]) -> SdkMcpTool[Any]: """Convert a FunctionTool to an SDK MCP tool. Args: @@ -541,7 +541,7 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): if "permission_mode" in options: await self._client.set_permission_mode(options["permission_mode"]) - def _format_prompt(self, messages: list[ChatMessage] | None) -> str: + def _format_prompt(self, messages: list[Message] | None) -> str: """Format messages into a prompt string. Args: @@ -557,32 +557,32 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): @overload def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[True], - thread: AgentThread | None = None, - options: TOptions | MutableMapping[str, Any] | None = None, + session: AgentSession | None = None, + options: OptionsT | MutableMapping[str, Any] | None = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseUpdate]: ... @overload async def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[False] = ..., - thread: AgentThread | None = None, - options: TOptions | MutableMapping[str, Any] | None = None, + session: AgentSession | None = None, + options: OptionsT | MutableMapping[str, Any] | None = None, **kwargs: Any, ) -> AgentResponse[Any]: ... def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, - options: TOptions | MutableMapping[str, Any] | None = None, + session: AgentSession | None = None, + options: OptionsT | MutableMapping[str, Any] | None = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseUpdate] | Awaitable[AgentResponse[Any]]: """Run the agent with the given messages. @@ -593,46 +593,36 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): Keyword Args: stream: If True, returns an async iterable of updates. If False (default), returns an awaitable AgentResponse. - thread: The conversation thread. If thread has service_thread_id set, + session: The conversation session. If session has service_session_id set, the agent will resume that session. options: Runtime options (model, permission_mode can be changed per-request). kwargs: Additional keyword arguments. Returns: - When stream=True: An AsyncIterable[AgentResponseUpdate] for streaming updates. + When stream=True: An ResponseStream for streaming updates. When stream=False: An Awaitable[AgentResponse] with the complete response. """ - if stream: - return self._run_streaming(messages, thread=thread, options=options, **kwargs) - return self._run_non_streaming(messages, thread=thread, options=options, **kwargs) - - async def _run_non_streaming( - self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, - *, - thread: AgentThread | None = None, - options: TOptions | MutableMapping[str, Any] | None = None, - **kwargs: Any, - ) -> AgentResponse[Any]: - """Internal non-streaming implementation.""" - thread = thread or self.get_new_thread() - return await AgentResponse.from_update_generator( - self._run_streaming(messages, thread=thread, options=options, **kwargs) + response = ResponseStream( + self._get_stream(messages, session=session, options=options, **kwargs), + finalizer=AgentResponse.from_updates, ) + if stream: + return response + return response.get_final_response() - async def _run_streaming( + async def _get_stream( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, - thread: AgentThread | None = None, - options: TOptions | MutableMapping[str, Any] | None = None, + session: AgentSession | None = None, + options: OptionsT | MutableMapping[str, Any] | None = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseUpdate]: """Internal streaming implementation.""" - thread = thread or self.get_new_thread() + session = session or self.create_session() # Ensure we're connected to the right session - await self._ensure_session(thread.service_thread_id) + await self._ensure_session(session.service_session_id) if not self._client: raise ServiceException("Claude SDK client not initialized.") @@ -697,6 +687,6 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): raise ServiceException(f"Claude API error: {error_msg}") session_id = message.session_id - # Update thread with session ID + # Update session with session ID if session_id: - thread.service_thread_id = session_id + session.service_session_id = session_id 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/pyproject.toml b/python/packages/claude/pyproject.toml index 88dae15d01..bdc76e9d92 100644 --- a/python/packages/claude/pyproject.toml +++ b/python/packages/claude/pyproject.toml @@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "claude-agent-sdk>=0.1.25", ] diff --git a/python/packages/claude/tests/test_claude_agent.py b/python/packages/claude/tests/test_claude_agent.py index 3025962f26..88b63389e8 100644 --- a/python/packages/claude/tests/test_claude_agent.py +++ b/python/packages/claude/tests/test_claude_agent.py @@ -4,7 +4,8 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest -from agent_framework import AgentResponseUpdate, AgentThread, ChatMessage, Content, tool +from agent_framework import AgentResponseUpdate, AgentSession, 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.""" @@ -268,12 +267,12 @@ class TestClaudeAgentRun: with patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client): agent = ClaudeAgent() - thread = agent.get_new_thread() - await agent.run("Hello", thread=thread) - assert thread.service_thread_id == "test-session-id" + session = agent.create_session() + await agent.run("Hello", session=session) + assert session.service_session_id == "test-session-id" - async def test_run_with_thread(self) -> None: - """Test run with existing thread.""" + async def test_run_with_session(self) -> None: + """Test run with existing session.""" from claude_agent_sdk import AssistantMessage, ResultMessage, TextBlock from claude_agent_sdk.types import StreamEvent @@ -303,9 +302,9 @@ class TestClaudeAgentRun: with patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client): agent = ClaudeAgent() - thread = agent.get_new_thread() - thread.service_thread_id = "existing-session" - await agent.run("Hello", thread=thread) + session = agent.create_session() + session.service_session_id = "existing-session" + await agent.run("Hello", session=session) # region Test ClaudeAgent Run Stream @@ -441,26 +440,18 @@ class TestClaudeAgentRunStream: class TestClaudeAgentSessionManagement: """Tests for ClaudeAgent session management.""" - def test_get_new_thread(self) -> None: - """Test get_new_thread creates a new thread.""" + def test_create_session(self) -> None: + """Test create_session creates a new session.""" agent = ClaudeAgent() - thread = agent.get_new_thread() - assert isinstance(thread, AgentThread) - assert thread.service_thread_id is None + session = agent.create_session() + assert isinstance(session, AgentSession) + assert session.service_session_id is None - def test_get_new_thread_with_service_thread_id(self) -> None: - """Test get_new_thread with existing service_thread_id.""" + def test_create_session_with_service_session_id(self) -> None: + """Test create_session with existing service_session_id.""" agent = ClaudeAgent() - thread = agent.get_new_thread(service_thread_id="existing-session-123") - assert isinstance(thread, AgentThread) - assert thread.service_thread_id == "existing-session-123" - - def test_thread_inherits_context_provider(self) -> None: - """Test that thread inherits context provider.""" - mock_provider = MagicMock() - agent = ClaudeAgent(context_provider=mock_provider) - thread = agent.get_new_thread() - assert thread.context_provider == mock_provider + session = agent.create_session(session_id="existing-session-123") + assert isinstance(session, AgentSession) async def test_ensure_session_creates_client(self) -> None: """Test _ensure_session creates client when not started.""" @@ -620,13 +611,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 +625,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 @@ -686,7 +677,7 @@ class TestFormatPrompt: def test_format_user_message(self) -> None: """Test formatting user message.""" agent = ClaudeAgent() - msg = ChatMessage( + msg = Message( role="user", contents=[Content.from_text(text="Hello")], ) @@ -697,9 +688,9 @@ class TestFormatPrompt: """Test formatting multiple messages.""" agent = ClaudeAgent() messages = [ - ChatMessage(role="user", contents=[Content.from_text(text="Hi")]), - ChatMessage(role="assistant", contents=[Content.from_text(text="Hello!")]), - ChatMessage(role="user", contents=[Content.from_text(text="How are you?")]), + Message(role="user", contents=[Content.from_text(text="Hi")]), + Message(role="assistant", contents=[Content.from_text(text="Hello!")]), + Message(role="user", contents=[Content.from_text(text="How are you?")]), ] result = agent._format_prompt(messages) # type: ignore[reportPrivateUsage] assert "Hi" in result diff --git a/python/packages/copilotstudio/README.md b/python/packages/copilotstudio/README.md index ea88f22ee4..08cad5331e 100644 --- a/python/packages/copilotstudio/README.md +++ b/python/packages/copilotstudio/README.md @@ -89,7 +89,7 @@ The package uses MSAL (Microsoft Authentication Library) for authentication with ### Examples -For more comprehensive examples, see the [Copilot Studio examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/copilotstudio/) which demonstrate: +For more comprehensive examples, see the [Copilot Studio examples](../../samples/02-agents/providers/copilotstudio/) which demonstrate: - Basic non-streaming and streaming execution - Explicit settings and manual token acquisition diff --git a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py index 40f93eee6a..a3729d325d 100644 --- a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py +++ b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py @@ -3,37 +3,35 @@ 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, AgentResponse, AgentResponseUpdate, - AgentThread, + AgentSession, BaseAgent, - ChatMessage, + BaseContextProvider, Content, - ContextProvider, + Message, 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): @@ -81,7 +59,7 @@ class CopilotStudioAgent(BaseAgent): id: str | None = None, name: str | None = None, description: str | None = None, - context_provider: ContextProvider | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, middleware: list[AgentMiddlewareTypes] | None = None, environment_id: str | None = None, agent_identifier: str | None = None, @@ -109,7 +87,7 @@ class CopilotStudioAgent(BaseAgent): id: id of the CopilotAgent name: Name of the CopilotAgent description: Description of the CopilotAgent - context_provider: Context Provider, to be used by the copilot agent. + context_providers: Context Providers, to be used by the copilot agent. middleware: Agent middleware used by the agent, should be a list of AgentMiddlewareTypes. environment_id: Environment ID of the Power Platform environment containing the Copilot Studio app. Can also be set via COPILOTSTUDIOAGENT__ENVIRONMENTID @@ -140,58 +118,57 @@ class CopilotStudioAgent(BaseAgent): id=id, name=name, description=description, - context_provider=context_provider, + context_providers=context_providers, 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, @@ -210,29 +187,29 @@ class CopilotStudioAgent(BaseAgent): @overload def run( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, stream: Literal[False] = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse]: ... @overload def run( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, stream: Literal[True], - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ... def run( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: """Get a response from the agent. @@ -246,7 +223,7 @@ class CopilotStudioAgent(BaseAgent): Keyword Args: stream: Whether to stream the response. Defaults to False. - thread: The conversation thread associated with the message(s). + session: The conversation session associated with the message(s). kwargs: Additional keyword arguments. Returns: @@ -254,27 +231,27 @@ class CopilotStudioAgent(BaseAgent): When stream=True: A ResponseStream of AgentResponseUpdate items. """ if stream: - return self._run_stream_impl(messages=messages, thread=thread, **kwargs) - return self._run_impl(messages=messages, thread=thread, **kwargs) + return self._run_stream_impl(messages=messages, session=session, **kwargs) + return self._run_impl(messages=messages, session=session, **kwargs) async def _run_impl( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> AgentResponse: """Non-streaming implementation of run.""" - if not thread: - thread = self.get_new_thread() - thread.service_thread_id = await self._start_new_conversation() + if not session: + session = self.create_session() + session.service_session_id = await self._start_new_conversation() input_messages = normalize_messages(messages) question = "\n".join([message.text for message in input_messages]) - activities = self.client.ask_question(question, thread.service_thread_id) - response_messages: list[ChatMessage] = [] + activities = self.client.ask_question(question, session.service_session_id) + response_messages: list[Message] = [] response_id: str | None = None response_messages = [message async for message in self._process_activities(activities, streaming=False)] @@ -284,24 +261,24 @@ class CopilotStudioAgent(BaseAgent): def _run_stream_impl( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: """Streaming implementation of run.""" async def _stream() -> AsyncIterable[AgentResponseUpdate]: - nonlocal thread - if not thread: - thread = self.get_new_thread() - thread.service_thread_id = await self._start_new_conversation() + nonlocal session + if not session: + session = self.create_session() + session.service_session_id = await self._start_new_conversation() input_messages = normalize_messages(messages) question = "\n".join([message.text for message in input_messages]) - activities = self.client.ask_question(question, thread.service_thread_id) + activities = self.client.ask_question(question, session.service_session_id) async for message in self._process_activities(activities, streaming=True): yield AgentResponseUpdate( @@ -338,7 +315,7 @@ class CopilotStudioAgent(BaseAgent): return conversation_id - async def _process_activities(self, activities: AsyncIterable[Any], streaming: bool) -> AsyncIterable[ChatMessage]: + async def _process_activities(self, activities: AsyncIterable[Any], streaming: bool) -> AsyncIterable[Message]: """Process activities from the Copilot Studio agent. Args: @@ -347,13 +324,13 @@ class CopilotStudioAgent(BaseAgent): or non-streaming (message activities) responses. Yields: - ChatMessage objects created from the activities. + Message objects created from the activities. """ async for activity in activities: if activity.text and ( (activity.type == "message" and not streaming) or (activity.type == "typing" and streaming) ): - yield ChatMessage( + yield Message( role="assistant", contents=[Content.from_text(activity.text)], author_name=activity.from_property.name if activity.from_property else None, diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index 7e4c61bd7e..11cd853d27 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "microsoft-agents-copilotstudio-client>=0.3.1", ] diff --git a/python/packages/copilotstudio/tests/test_copilot_agent.py b/python/packages/copilotstudio/tests/test_copilot_agent.py index cd11c7a6ef..2bc97fe650 100644 --- a/python/packages/copilotstudio/tests/test_copilot_agent.py +++ b/python/packages/copilotstudio/tests/test_copilot_agent.py @@ -4,7 +4,7 @@ from typing import Any from unittest.mock import MagicMock, patch import pytest -from agent_framework import AgentResponse, AgentResponseUpdate, AgentThread, ChatMessage, Content +from agent_framework import AgentResponse, AgentResponseUpdate, AgentSession, Content, Message from agent_framework.exceptions import ServiceException, ServiceInitializationError from microsoft_agents.copilotstudio.client import CopilotClient @@ -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() @@ -134,7 +146,7 @@ class TestCopilotStudioAgent: assert response.messages[0].role == "assistant" async def test_run_with_chat_message(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None: - """Test run method with ChatMessage.""" + """Test run method with Message.""" agent = CopilotStudioAgent(client=mock_copilot_client) conversation_activity = MagicMock() @@ -143,7 +155,7 @@ class TestCopilotStudioAgent: mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity]) mock_copilot_client.ask_question.return_value = create_async_generator([mock_activity]) - chat_message = ChatMessage(role="user", contents=[Content.from_text("test message")]) + chat_message = Message(role="user", contents=[Content.from_text("test message")]) response = await agent.run(chat_message) assert isinstance(response, AgentResponse) @@ -153,10 +165,10 @@ class TestCopilotStudioAgent: assert content.text == "Test response" assert response.messages[0].role == "assistant" - async def test_run_with_thread(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None: - """Test run method with existing thread.""" + async def test_run_with_session(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None: + """Test run method with existing session.""" agent = CopilotStudioAgent(client=mock_copilot_client) - thread = AgentThread() + session = AgentSession() conversation_activity = MagicMock() conversation_activity.conversation.id = "test-conversation-id" @@ -164,11 +176,11 @@ class TestCopilotStudioAgent: mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity]) mock_copilot_client.ask_question.return_value = create_async_generator([mock_activity]) - response = await agent.run("test message", thread=thread) + response = await agent.run("test message", session=session) assert isinstance(response, AgentResponse) assert len(response.messages) == 1 - assert thread.service_thread_id == "test-conversation-id" + assert session.service_session_id == "test-conversation-id" async def test_run_start_conversation_failure(self, mock_copilot_client: MagicMock) -> None: """Test run method when conversation start fails.""" @@ -205,10 +217,10 @@ class TestCopilotStudioAgent: assert response_count == 1 - async def test_run_streaming_with_thread(self, mock_copilot_client: MagicMock) -> None: - """Test run(stream=True) method with existing thread.""" + async def test_run_streaming_with_session(self, mock_copilot_client: MagicMock) -> None: + """Test run(stream=True) method with existing session.""" agent = CopilotStudioAgent(client=mock_copilot_client) - thread = AgentThread() + session = AgentSession() conversation_activity = MagicMock() conversation_activity.conversation.id = "test-conversation-id" @@ -223,7 +235,7 @@ class TestCopilotStudioAgent: mock_copilot_client.ask_question.return_value = create_async_generator([typing_activity]) response_count = 0 - async for response in agent.run("test message", thread=thread, stream=True): + async for response in agent.run("test message", session=session, stream=True): assert isinstance(response, AgentResponseUpdate) content = response.contents[0] assert content.type == "text" @@ -231,7 +243,7 @@ class TestCopilotStudioAgent: response_count += 1 assert response_count == 1 - assert thread.service_thread_id == "test-conversation-id" + assert session.service_session_id == "test-conversation-id" async def test_run_streaming_no_typing_activity(self, mock_copilot_client: MagicMock) -> None: """Test run(stream=True) method with non-typing activity.""" diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 2a308c245d..a270bc1686 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -9,11 +9,10 @@ agent_framework/ ├── __init__.py # Public API exports ├── _agents.py # Agent implementations ├── _clients.py # Chat client base classes and protocols -├── _types.py # Core types (ChatMessage, ChatResponse, Content, etc.) +├── _types.py # Core types (Message, ChatResponse, Content, etc.) ├── _tools.py # Tool definitions and function invocation ├── _middleware.py # Middleware system for request/response interception -├── _threads.py # AgentThread and message store abstractions -├── _memory.py # Context providers for memory/RAG +├── _sessions.py # AgentSession and context provider abstractions ├── _mcp.py # Model Context Protocol support ├── _workflows/ # Workflow orchestration (sequential, concurrent, handoff, etc.) ├── openai/ # Built-in OpenAI client @@ -27,16 +26,16 @@ agent_framework/ - **`SupportsAgentRun`** - Protocol defining the agent interface - **`BaseAgent`** - Abstract base class for agents -- **`ChatAgent`** - Main agent class wrapping a chat client with tools, instructions, and middleware +- **`Agent`** - Main agent class wrapping a chat client with tools, instructions, and middleware ### Chat Clients (`_clients.py`) -- **`ChatClientProtocol`** - Protocol for chat client implementations +- **`SupportsChatGetResponse`** - Protocol for chat client implementations - **`BaseChatClient`** - Abstract base class with middleware support; subclasses implement `_inner_get_response()` and `_inner_get_streaming_response()` ### Types (`_types.py`) -- **`ChatMessage`** - Represents a chat message with role, content, and metadata +- **`Message`** - Represents a chat message with role, content, and metadata - **`ChatResponse`** - Response from a chat client containing messages and usage - **`ChatResponseUpdate`** - Streaming response update - **`AgentResponse`** / **`AgentResponseUpdate`** - Agent-level response wrappers @@ -57,16 +56,12 @@ agent_framework/ - **`FunctionMiddleware`** - Intercepts function/tool invocations - **`AgentContext`** / **`ChatContext`** / **`FunctionInvocationContext`** - Context objects passed through middleware -### Threads (`_threads.py`) +### Sessions (`_sessions.py`) -- **`AgentThread`** - Manages conversation history for an agent -- **`ChatMessageStoreProtocol`** - Protocol for persistent message storage -- **`ChatMessageStore`** - Default in-memory implementation - -### Memory (`_memory.py`) - -- **`ContextProvider`** - Protocol for providing additional context to agents (RAG, memory systems) -- **`Context`** - Container for context data +- **`AgentSession`** - Manages conversation state and session metadata +- **`SessionContext`** - Context object for session-scoped data during agent runs +- **`BaseContextProvider`** - Base class for context providers (RAG, memory systems) +- **`BaseHistoryProvider`** - Base class for conversation history storage ### Workflows (`_workflows/`) @@ -91,11 +86,11 @@ agent_framework/ ### Creating an Agent ```python -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.openai import OpenAIChatClient -agent = ChatAgent( - chat_client=OpenAIChatClient(), +agent = Agent( + client=OpenAIChatClient(), instructions="You are helpful.", tools=[my_function], ) @@ -114,26 +109,26 @@ agent = OpenAIChatClient().as_agent( ### Middleware Pipeline ```python -from agent_framework import ChatAgent, AgentMiddleware, AgentContext +from agent_framework import Agent, AgentMiddleware, AgentContext class LoggingMiddleware(AgentMiddleware): async def process(self, context: AgentContext, call_next) -> None: print(f"Input: {context.messages}") - await call_next(context) + await call_next() print(f"Output: {context.result}") -agent = ChatAgent(..., middleware=[LoggingMiddleware()]) +agent = Agent(..., middleware=[LoggingMiddleware()]) ``` ### Custom Chat Client ```python -from agent_framework import BaseChatClient, ChatResponse, ChatMessage +from agent_framework import BaseChatClient, ChatResponse, Message class MyClient(BaseChatClient): async def _inner_get_response(self, *, messages, options, **kwargs) -> ChatResponse: # Call your LLM here - return ChatResponse(messages=[ChatMessage(role="assistant", text="Hi!")]) + return ChatResponse(messages=[Message(role="assistant", text="Hi!")]) async def _inner_get_streaming_response(self, *, messages, options, **kwargs): yield ChatResponseUpdate(...) diff --git a/python/packages/core/README.md b/python/packages/core/README.md index a56badd777..1cb15e16f7 100644 --- a/python/packages/core/README.md +++ b/python/packages/core/README.md @@ -45,7 +45,7 @@ You can also override environment variables by explicitly passing configuration ```python from agent_framework.azure import AzureOpenAIChatClient -chat_client = AzureOpenAIChatClient( +client = AzureOpenAIChatClient( api_key="", endpoint="", deployment_name="", @@ -53,7 +53,7 @@ chat_client = AzureOpenAIChatClient( ) ``` -See the following [setup guide](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started) for more information. +See the following [setup guide](../../samples/01-get-started) for more information. ## 2. Create a Simple Agent @@ -61,12 +61,12 @@ Create agents and invoke them directly: ```python import asyncio -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.openai import OpenAIChatClient async def main(): - agent = ChatAgent( - chat_client=OpenAIChatClient(), + agent = Agent( + client=OpenAIChatClient(), instructions=""" 1) A robot may not injure a human being... 2) A robot must obey orders given it by human beings... @@ -90,14 +90,14 @@ You can use the chat client classes directly for advanced workflows: ```python import asyncio from agent_framework.openai import OpenAIChatClient -from agent_framework import ChatMessage, Role +from agent_framework import Message, Role async def main(): client = OpenAIChatClient() messages = [ - ChatMessage("system", ["You are a helpful assistant."]), - ChatMessage("user", ["Write a haiku about Agent Framework."]) + Message("system", ["You are a helpful assistant."]), + Message("user", ["Write a haiku about Agent Framework."]) ] response = await client.get_response(messages) @@ -123,7 +123,7 @@ import asyncio from typing import Annotated from random import randint from pydantic import Field -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.openai import OpenAIChatClient @@ -145,8 +145,8 @@ def get_menu_specials() -> str: async def main(): - agent = ChatAgent( - chat_client=OpenAIChatClient(), + agent = Agent( + client=OpenAIChatClient(), instructions="You are a helpful assistant that can provide weather and restaurant information.", tools=[get_weather, get_menu_specials] ) @@ -161,7 +161,7 @@ async def main(): asyncio.run(main()) ``` -You can explore additional agent samples [here](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents). +You can explore additional agent samples [here](../../samples/02-agents). ## 5. Multi-Agent Orchestration @@ -169,20 +169,20 @@ Coordinate multiple agents to collaborate on complex tasks using orchestration p ```python import asyncio -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.openai import OpenAIChatClient async def main(): # Create specialized agents - writer = ChatAgent( - chat_client=OpenAIChatClient(), + writer = Agent( + client=OpenAIChatClient(), name="Writer", instructions="You are a creative content writer. Generate and refine slogans based on feedback." ) - reviewer = ChatAgent( - chat_client=OpenAIChatClient(), + reviewer = Agent( + client=OpenAIChatClient(), name="Reviewer", instructions="You are a critical reviewer. Provide detailed feedback on proposed slogans." ) @@ -213,12 +213,12 @@ if __name__ == "__main__": asyncio.run(main()) ``` -**Note**: Sequential, Concurrent, Group Chat, Handoff, and Magentic orchestrations are available. See examples in [orchestration samples](../../samples/getting_started/orchestrations). +**Note**: Sequential, Concurrent, Group Chat, Handoff, and Magentic orchestrations are available. See examples in [orchestration samples](../../samples/03-workflows/orchestrations). ## More Examples & Samples -- [Getting Started with Agents](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents): Basic agent creation and tool usage -- [Chat Client Examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/chat_client): Direct chat client usage patterns +- [Getting Started with Agents](../../samples/02-agents): Basic agent creation and tool usage +- [Chat Client Examples](../../samples/02-agents/chat_client): Direct chat client usage patterns - [Azure AI Integration](https://github.com/microsoft/agent-framework/tree/main/python/packages/azure-ai): Azure AI integration - [.NET Workflows Samples](https://github.com/microsoft/agent-framework/tree/main/dotnet/samples/GettingStarted/Workflows): Advanced multi-agent patterns (.NET) diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 1e408169d1..48095326de 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -13,10 +13,9 @@ from ._agents import * # noqa: F403 from ._clients import * # noqa: F403 from ._logging import * # noqa: F403 from ._mcp import * # noqa: F403 -from ._memory import * # noqa: F403 from ._middleware import * # noqa: F403 +from ._sessions import * # noqa: F403 from ._telemetry import * # noqa: F403 -from ._threads import * # noqa: F403 from ._tools import * # noqa: F403 from ._types import * # noqa: F403 from ._workflows import * # noqa: F403 diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 8d87e65000..49bdd64387 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -28,29 +28,27 @@ from mcp.server.lowlevel import Server from mcp.shared.exceptions import McpError from pydantic import BaseModel, Field, create_model -from ._clients import BaseChatClient, ChatClientProtocol +from ._clients import BaseChatClient, SupportsChatGetResponse from ._logging import get_logger from ._mcp import LOG_LEVEL_MAPPING, MCPTool -from ._memory import Context, ContextProvider from ._middleware import AgentMiddlewareLayer, MiddlewareTypes from ._serialization import SerializationMixin -from ._threads import AgentThread, ChatMessageStoreProtocol +from ._sessions import AgentSession, BaseContextProvider, BaseHistoryProvider, InMemoryHistoryProvider, SessionContext from ._tools import ( FunctionInvocationLayer, FunctionTool, - ToolProtocol, ) from ._types import ( AgentResponse, AgentResponseUpdate, - ChatMessage, ChatResponse, ChatResponseUpdate, + Message, ResponseStream, map_chat_to_agent_update, normalize_messages, ) -from .exceptions import AgentExecutionException, AgentInitializationError +from .exceptions import AgentExecutionException from .observability import AgentTelemetryLayer if sys.version_info >= (3, 13): @@ -58,9 +56,9 @@ if sys.version_info >= (3, 13): else: from typing_extensions import TypeVar # type: ignore # pragma: no cover if sys.version_info >= (3, 12): - from typing import override # type: ignore # pragma: no cover + pass # type: ignore # pragma: no cover else: - from typing_extensions import override # type: ignore[import] # pragma: no cover + pass # type: ignore[import] # pragma: no cover if sys.version_info >= (3, 11): from typing import Self, TypedDict # pragma: no cover else: @@ -69,16 +67,11 @@ else: if TYPE_CHECKING: from ._types import ChatOptions - -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None, covariant=True) -TResponseModelT = TypeVar("TResponseModelT", bound=BaseModel) - - logger = get_logger("agent_framework") -TThreadType = TypeVar("TThreadType", bound="AgentThread") -TOptions_co = TypeVar( - "TOptions_co", +ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) +OptionsCoT = TypeVar( + "OptionsCoT", bound=TypedDict, # type: ignore[valid-type] default="ChatOptions[None]", covariant=True, @@ -156,16 +149,17 @@ def _sanitize_agent_name(agent_name: str | None) -> str | None: class _RunContext(TypedDict): - thread: AgentThread - input_messages: list[ChatMessage] - thread_messages: list[ChatMessage] + session: AgentSession | None + session_context: SessionContext + input_messages: list[Message] + session_messages: list[Message] agent_name: str chat_options: dict[str, Any] filtered_kwargs: dict[str, Any] finalize_kwargs: dict[str, Any] -__all__ = ["BareAgent", "BaseAgent", "ChatAgent", "RawChatAgent", "SupportsAgentRun"] +__all__ = ["Agent", "BaseAgent", "RawAgent", "SupportsAgentRun"] # region Agent Protocol @@ -198,7 +192,7 @@ class SupportsAgentRun(Protocol): self.name = "Custom Agent" self.description = "A fully custom agent implementation" - async def run(self, messages=None, *, stream=False, thread=None, **kwargs): + async def run(self, messages=None, *, stream=False, session=None, **kwargs): if stream: # Your custom streaming implementation async def _stream(): @@ -213,9 +207,15 @@ class SupportsAgentRun(Protocol): return AgentResponse(messages=[], response_id="custom-response") - def get_new_thread(self, **kwargs): - # Return your own thread implementation - return {"id": "custom-thread", "messages": []} + def create_session(self, **kwargs): + from agent_framework import AgentSession + + return AgentSession(**kwargs) + + def get_session(self, *, service_session_id, **kwargs): + from agent_framework import AgentSession + + return AgentSession(service_session_id=service_session_id, **kwargs) # Verify the instance satisfies the protocol @@ -230,10 +230,10 @@ class SupportsAgentRun(Protocol): @overload def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[False] = ..., - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: """Get a response from the agent (non-streaming).""" @@ -242,10 +242,10 @@ class SupportsAgentRun(Protocol): @overload def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[True], - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Get a streaming response from the agent.""" @@ -253,10 +253,10 @@ class SupportsAgentRun(Protocol): def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Get a response from the agent. @@ -270,7 +270,7 @@ class SupportsAgentRun(Protocol): Keyword Args: stream: Whether to stream the response. Defaults to False. - thread: The conversation thread associated with the message(s). + session: The conversation session associated with the message(s). kwargs: Additional keyword arguments. Returns: @@ -280,8 +280,12 @@ class SupportsAgentRun(Protocol): """ ... - def get_new_thread(self, **kwargs: Any) -> AgentThread: - """Creates a new conversation thread for the agent.""" + def create_session(self, **kwargs: Any) -> AgentSession: + """Creates a new conversation session.""" + ... + + def get_session(self, *, service_session_id: str, **kwargs: Any) -> AgentSession: + """Gets or creates a session for a service-managed session ID.""" ... @@ -292,25 +296,25 @@ class BaseAgent(SerializationMixin): """Base class for all Agent Framework agents. This is the minimal base class without middleware or telemetry layers. - For most use cases, prefer :class:`ChatAgent` which includes all standard layers. + For most use cases, prefer :class:`Agent` which includes all standard layers. This class provides core functionality for agent implementations, including - context providers, middleware support, and thread management. + context providers, middleware support, and session management. Note: BaseAgent cannot be instantiated directly as it doesn't implement the ``run()`` and other methods required by SupportsAgentRun. - Use a concrete implementation like ChatAgent or create a subclass. + Use a concrete implementation like Agent or create a subclass. Examples: .. code-block:: python - from agent_framework import BaseAgent, AgentThread, AgentResponse + from agent_framework import BaseAgent, AgentSession, AgentResponse # Create a concrete subclass that implements the protocol class SimpleAgent(BaseAgent): - async def run(self, messages=None, *, stream=False, thread=None, **kwargs): + async def run(self, messages=None, *, stream=False, session=None, **kwargs): if stream: async def _stream(): @@ -346,7 +350,7 @@ class BaseAgent(SerializationMixin): id: str | None = None, name: str | None = None, description: str | None = None, - context_provider: ContextProvider | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, additional_properties: MutableMapping[str, Any] | None = None, **kwargs: Any, @@ -358,7 +362,7 @@ class BaseAgent(SerializationMixin): a new UUID will be generated. name: The name of the agent, can be None. description: The description of the agent. - context_provider: The context provider to include during agent invocation. + context_providers: Context providers to include during agent invocation. middleware: List of middleware. additional_properties: Additional properties set on the agent. kwargs: Additional keyword arguments (merged into additional_properties). @@ -368,7 +372,7 @@ class BaseAgent(SerializationMixin): self.id = id self.name = name self.description = description - self.context_provider = context_provider + self.context_providers: list[BaseContextProvider] = list(context_providers or []) self.middleware: list[MiddlewareTypes] | None = ( cast(list[MiddlewareTypes], middleware) if middleware is not None else None ) @@ -377,56 +381,53 @@ class BaseAgent(SerializationMixin): self.additional_properties: dict[str, Any] = cast(dict[str, Any], additional_properties or {}) self.additional_properties.update(kwargs) - async def _notify_thread_of_new_messages( - self, - thread: AgentThread, - input_messages: ChatMessage | Sequence[ChatMessage], - response_messages: ChatMessage | Sequence[ChatMessage], - **kwargs: Any, - ) -> None: - """Notify the thread of new messages. - - This also calls the invoked method of a potential context provider on the thread. - - Args: - thread: The thread to notify of new messages. - input_messages: The input messages to notify about. - response_messages: The response messages to notify about. - **kwargs: Any extra arguments to pass from the agent run. - """ - if isinstance(input_messages, ChatMessage) or len(input_messages) > 0: - await thread.on_new_messages(input_messages) - if isinstance(response_messages, ChatMessage) or len(response_messages) > 0: - await thread.on_new_messages(response_messages) - if thread.context_provider: - await thread.context_provider.invoked(input_messages, response_messages, **kwargs) - - def get_new_thread(self, **kwargs: Any) -> AgentThread: - """Return a new AgentThread instance that is compatible with the agent. - - Keyword Args: - kwargs: Additional keyword arguments passed to AgentThread. - - Returns: - A new AgentThread instance configured with the agent's context provider. - """ - return AgentThread(**kwargs, context_provider=self.context_provider) - - async def deserialize_thread(self, serialized_thread: Any, **kwargs: Any) -> AgentThread: - """Deserialize a thread from its serialized state. - - Args: - serialized_thread: The serialized thread data. + def create_session(self, *, session_id: str | None = None, **kwargs: Any) -> AgentSession: + """Create a new lightweight session. Keyword Args: + session_id: Optional session ID (generated if not provided). kwargs: Additional keyword arguments. Returns: - A new AgentThread instance restored from the serialized state. + A new AgentSession instance. """ - thread: AgentThread = self.get_new_thread() - await thread.update_from_thread_state(serialized_thread, **kwargs) - return thread + return AgentSession(session_id=session_id) + + def get_session(self, *, service_session_id: str, session_id: str | None = None, **kwargs: Any) -> AgentSession: + """Get or create a session for a service-managed session ID. + + Args: + service_session_id: The service-managed session ID. + + Keyword Args: + session_id: Optional local session ID (generated if not provided). + kwargs: Additional keyword arguments. + + Returns: + A new AgentSession instance with service_session_id set. + """ + return AgentSession(session_id=session_id, service_session_id=service_session_id) + + async def _run_after_providers( + self, + *, + session: AgentSession | None, + context: SessionContext, + ) -> None: + """Run after_run on all context providers in reverse order. + + Keyword Args: + session: The conversation session. + context: The invocation context with response populated. + """ + state = session.state if session else {} + for provider in reversed(self.context_providers): + await provider.after_run( + agent=self, # type: ignore[arg-type] + session=session, # type: ignore[arg-type] + context=context, + state=state, + ) def as_tool( self, @@ -438,7 +439,7 @@ class BaseAgent(SerializationMixin): stream_callback: Callable[[AgentResponseUpdate], None] | Callable[[AgentResponseUpdate], Awaitable[None]] | None = None, - ) -> FunctionTool[BaseModel, str]: + ) -> FunctionTool[BaseModel]: """Create a FunctionTool that wraps this agent. Keyword Args: @@ -459,16 +460,16 @@ class BaseAgent(SerializationMixin): Examples: .. code-block:: python - from agent_framework import ChatAgent + from agent_framework import Agent # Create an agent - agent = ChatAgent(chat_client=client, name="research-agent", description="Performs research tasks") + agent = Agent(client=client, name="research-agent", description="Performs research tasks") # Convert the agent to a tool research_tool = agent.as_tool() # Use the tool with another agent - coordinator = ChatAgent(chat_client=client, name="coordinator", tools=research_tool) + coordinator = Agent(client=client, name="coordinator", tools=research_tool) """ # Verify that self implements SupportsAgentRun if not isinstance(self, SupportsAgentRun): @@ -512,7 +513,7 @@ class BaseAgent(SerializationMixin): # Create final text from accumulated updates return AgentResponse.from_updates(response_updates).text - agent_tool: FunctionTool[BaseModel, str] = FunctionTool( + agent_tool: FunctionTool[BaseModel] = FunctionTool( name=tool_name, description=tool_description, func=agent_wrapper, @@ -523,18 +524,14 @@ class BaseAgent(SerializationMixin): return agent_tool -# Backward compatibility alias -BareAgent = BaseAgent +# region Agent -# region ChatAgent - - -class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] +class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] """A Chat Client Agent without middleware or telemetry layers. This is the core chat agent implementation. For most use cases, - prefer :class:`ChatAgent` which includes all standard layers. + prefer :class:`Agent` which includes all standard layers. This is the primary agent implementation that uses a chat client to interact with language models. It supports tools, context providers, middleware, and @@ -548,12 +545,12 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] .. code-block:: python - from agent_framework import ChatAgent + from agent_framework import Agent from agent_framework.openai import OpenAIChatClient # Create a basic chat agent client = OpenAIChatClient(model_id="gpt-4") - agent = ChatAgent(chat_client=client, name="assistant", description="A helpful assistant") + agent = Agent(client=client, name="assistant", description="A helpful assistant") # Run the agent with a simple message response = await agent.run("Hello, how are you?") @@ -568,8 +565,8 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] return f"The weather in {location} is sunny." - agent = ChatAgent( - chat_client=client, + agent = Agent( + client=client, name="weather-agent", instructions="You are a weather assistant.", tools=get_weather, @@ -587,12 +584,12 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] .. code-block:: python - from agent_framework import ChatAgent + from agent_framework import Agent from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions client = OpenAIChatClient(model_id="gpt-4o") - agent: ChatAgent[OpenAIChatOptions] = ChatAgent( - chat_client=client, + agent: Agent[OpenAIChatOptions] = Agent( + client=client, name="reasoning-agent", instructions="You are a reasoning assistant.", options={ @@ -613,26 +610,26 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] def __init__( self, - chat_client: ChatClientProtocol[TOptions_co], + client: SupportsChatGetResponse[OptionsCoT], instructions: str | None = None, *, id: str | None = None, name: str | None = None, description: str | None = None, - tools: ToolProtocol + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Any + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any] | None = None, - default_options: TOptions_co | None = None, - chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None, - context_provider: ContextProvider | None = None, + default_options: OptionsCoT | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, **kwargs: Any, ) -> None: - """Initialize a ChatAgent instance. + """Initialize a Agent instance. Args: - chat_client: The chat client to use for the agent. + client: The chat client to use for the agent. instructions: Optional instructions for the agent. These will be put into the messages sent to the chat client service as a system message. @@ -640,12 +637,10 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] id: The unique identifier for the agent. Will be created automatically if not provided. name: The name of the agent. description: A brief description of the agent's purpose. - chat_message_store_factory: Factory function to create an instance of ChatMessageStoreProtocol. - If not provided, the default in-memory store will be used. - context_provider: The context providers to include during agent invocation. + context_providers: Context providers to include during agent invocation. middleware: List of middleware to intercept agent and function invocations. default_options: A TypedDict containing chat options. When using a typed agent like - ``ChatAgent[OpenAIChatOptions]``, this enables IDE autocomplete for + ``Agent[OpenAIChatOptions]``, this enables IDE autocomplete for provider-specific options including temperature, max_tokens, model_id, tool_choice, and provider-specific options like reasoning_effort. You can also create your own TypedDict for custom chat clients. @@ -653,21 +648,10 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] These can be overridden at runtime via the ``options`` parameter of ``run()``. tools: The tools to use for the request. kwargs: Any additional keyword arguments. Will be stored as ``additional_properties``. - - Raises: - AgentInitializationError: If both conversation_id and chat_message_store_factory are provided. """ - # Extract conversation_id from options for validation opts = dict(default_options) if default_options else {} - conversation_id = opts.get("conversation_id") - if conversation_id is not None and chat_message_store_factory is not None: - raise AgentInitializationError( - "Cannot specify both conversation_id and chat_message_store_factory. " - "Use conversation_id for service-managed threads or chat_message_store_factory for local storage." - ) - - if not isinstance(chat_client, FunctionInvocationLayer) and isinstance(chat_client, BaseChatClient): + if not isinstance(client, FunctionInvocationLayer) and isinstance(client, BaseChatClient): logger.warning( "The provided chat client does not support function invoking, this might limit agent capabilities." ) @@ -676,19 +660,18 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] id=id, name=name, description=description, - context_provider=context_provider, + context_providers=context_providers, **kwargs, ) - self.chat_client = chat_client - self.chat_message_store_factory = chat_message_store_factory + self.client = client # Get tools from options or named parameter (named param takes precedence) tools_ = tools if tools is not None else opts.pop("tools", None) tools_ = cast( - ToolProtocol + FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None, tools_, ) @@ -698,17 +681,17 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] # We ignore the MCP Servers here and store them separately, # we add their functions to the tools list at runtime - normalized_tools: list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] = ( # type:ignore[reportUnknownVariableType] + normalized_tools: list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] = ( # type:ignore[reportUnknownVariableType] [] if tools_ is None else tools_ if isinstance(tools_, list) else [tools_] # type: ignore[list-item] ) - self.mcp_tools: list[MCPTool] = [tool for tool in normalized_tools if isinstance(tool, MCPTool)] + self.mcp_tools: list[MCPTool] = [tool for tool in normalized_tools if isinstance(tool, MCPTool)] # type: ignore[misc] agent_tools = [tool for tool in normalized_tools if not isinstance(tool, MCPTool)] # Build chat options dict self.default_options: dict[str, Any] = { - "model_id": opts.pop("model_id", None) or (getattr(self.chat_client, "model_id", None)), + "model_id": opts.pop("model_id", None) or (getattr(self.client, "model_id", None)), "allow_multiple_tool_calls": opts.pop("allow_multiple_tool_calls", None), - "conversation_id": conversation_id, + "conversation_id": opts.pop("conversation_id", None), "frequency_penalty": opts.pop("frequency_penalty", None), "instructions": instructions_, "logit_bias": opts.pop("logit_bias", None), @@ -734,16 +717,16 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] async def __aenter__(self) -> Self: """Enter the async context manager. - If any of the chat_client or local_mcp_tools are context managers, + If any of the client or local_mcp_tools are context managers, they will be entered into the async exit stack to ensure proper cleanup. Note: This list might be extended in the future. Returns: - The ChatAgent instance. + The Agent instance. """ - for context_manager in chain([self.chat_client], self.mcp_tools): + for context_manager in chain([self.client], self.mcp_tools): if isinstance(context_manager, AbstractAsyncContextManager): await self._async_exit_stack.enter_async_context(context_manager) return self @@ -772,71 +755,75 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] should check if there is already an agent name defined, and if not set it to this value. """ - if hasattr(self.chat_client, "_update_agent_name_and_description") and callable( - self.chat_client._update_agent_name_and_description + if hasattr(self.client, "_update_agent_name_and_description") and callable( + self.client._update_agent_name_and_description ): # type: ignore[reportAttributeAccessIssue, attr-defined] - self.chat_client._update_agent_name_and_description(self.name, self.description) # type: ignore[reportAttributeAccessIssue, attr-defined] + self.client._update_agent_name_and_description(self.name, self.description) # type: ignore[reportAttributeAccessIssue, attr-defined] @overload def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[False] = ..., - thread: AgentThread | None = None, - tools: ToolProtocol + session: AgentSession | None = None, + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Any + | list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any] | None = None, - options: ChatOptions[TResponseModelT], + options: ChatOptions[ResponseModelBoundT], **kwargs: Any, - ) -> Awaitable[AgentResponse[TResponseModelT]]: ... + ) -> Awaitable[AgentResponse[ResponseModelBoundT]]: ... @overload def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[False] = ..., - thread: AgentThread | None = None, - tools: ToolProtocol + session: AgentSession | None = None, + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Any + | list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any] | None = None, - options: TOptions_co | ChatOptions[None] | None = None, + options: OptionsCoT | ChatOptions[None] | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @overload def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[True], - thread: AgentThread | None = None, - tools: ToolProtocol + session: AgentSession | None = None, + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Any + | list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any] | None = None, - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, - tools: ToolProtocol + session: AgentSession | None = None, + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Any + | list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any] | None = None, - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Run the agent with the given messages and options. @@ -852,10 +839,12 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] stream: Whether to stream the response. Defaults to False. Keyword Args: - thread: The thread to use for the agent. + session: The session to use for the agent. + If None, and no settings for the chat client that indicate otherwise, + the run will be stateless. tools: The tools to use for this specific run (merged with default tools). options: A TypedDict containing chat options. When using a typed agent like - ``ChatAgent[OpenAIChatOptions]``, this enables IDE autocomplete for + ``Agent[OpenAIChatOptions]``, this enables IDE autocomplete for provider-specific options including temperature, max_tokens, model_id, tool_choice, and provider-specific options like reasoning_effort. kwargs: Additional keyword arguments for the agent. @@ -871,13 +860,13 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] async def _run_non_streaming() -> AgentResponse[Any]: ctx = await self._prepare_run_context( messages=messages, - thread=thread, + session=session, tools=tools, options=options, kwargs=kwargs, ) - response = await self.chat_client.get_response( # type: ignore[call-overload] - messages=ctx["thread_messages"], + response = await self.client.get_response( # type: ignore[call-overload] + messages=ctx["session_messages"], stream=False, options=ctx["chat_options"], **ctx["filtered_kwargs"], @@ -886,12 +875,11 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] if not response: raise AgentExecutionException("Chat client did not return a response.") - await self._finalize_response_and_update_thread( + await self._finalize_response( response=response, agent_name=ctx["agent_name"], - thread=ctx["thread"], - input_messages=ctx["input_messages"], - kwargs=ctx["finalize_kwargs"], + session=ctx["session"], + session_context=ctx["session_context"], ) response_format = ctx["chat_options"].get("response_format") if not ( @@ -908,6 +896,7 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] usage_details=response.usage_details, value=response.value, response_format=response_format, + continuation_token=response.continuation_token, raw_representation=response, additional_properties=response.additional_properties, ) @@ -922,33 +911,41 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] if ctx is None: return # No context available (shouldn't happen in normal flow) - # Update thread with conversation_id - await self._update_thread_with_type_and_conversation_id(ctx["thread"], response.response_id) - + # Update thread with conversation_id derived from streaming raw updates. + # Using response_id here can break function-call continuation for APIs + # where response IDs are not valid conversation handles. + conversation_id = self._extract_conversation_id_from_streaming_response(response) # Ensure author names are set for all messages for message in response.messages: if message.author_name is None: message.author_name = ctx["agent_name"] - # Notify thread of new messages - await self._notify_thread_of_new_messages( - ctx["thread"], - ctx["input_messages"], - response.messages, - **{k: v for k, v in ctx["finalize_kwargs"].items() if k != "thread"}, + # Propagate conversation_id back to session from streaming updates. + # For Responses-style APIs this can rotate every turn (response_id-based continuation), + # so refresh when a newer value is returned. + sess = ctx["session"] + if sess and conversation_id and sess.service_session_id != conversation_id: + sess.service_session_id = conversation_id + + # Run after_run providers (reverse order) + session_context = ctx["session_context"] + session_context._response = AgentResponse( # type: ignore[assignment] + messages=response.messages, + response_id=response.response_id, ) + await self._run_after_providers(session=ctx["session"], context=session_context) async def _get_stream() -> ResponseStream[ChatResponseUpdate, ChatResponse]: ctx_holder["ctx"] = await self._prepare_run_context( messages=messages, - thread=thread, + session=session, tools=tools, options=options, kwargs=kwargs, ) ctx: _RunContext = ctx_holder["ctx"] # type: ignore[assignment] # Safe: we just assigned it - return self.chat_client.get_response( # type: ignore[call-overload, no-any-return] - messages=ctx["thread_messages"], + return self.client.get_response( # type: ignore[call-overload, no-any-return] + messages=ctx["session_messages"], stream=True, options=ctx["chat_options"], **ctx["filtered_kwargs"], @@ -979,15 +976,37 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] output_format_type = response_format if isinstance(response_format, type) else None return AgentResponse.from_updates(updates, output_format_type=output_format_type) + @staticmethod + def _extract_conversation_id_from_streaming_response(response: AgentResponse[Any]) -> str | None: + """Extract conversation_id from streaming raw updates, if present.""" + raw = response.raw_representation + if raw is None: + return None + + raw_items: list[Any] = raw if isinstance(raw, list) else [raw] + for item in reversed(raw_items): + if isinstance(item, Mapping): + value = item.get("conversation_id") + if isinstance(value, str) and value: + return value + continue + + value = getattr(item, "conversation_id", None) + if isinstance(value, str) and value: + return value + + return None + async def _prepare_run_context( self, *, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None, - thread: AgentThread | None, - tools: ToolProtocol + messages: str | Message | Sequence[str | Message] | None, + session: AgentSession | None, + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Any + | list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any] | None, options: Mapping[str, Any] | None, kwargs: dict[str, Any], @@ -998,18 +1017,33 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] tools_ = tools if tools is not None else opts.pop("tools", None) input_messages = normalize_messages(messages) - thread, run_chat_options, thread_messages = await self._prepare_thread_and_messages( - thread=thread, input_messages=input_messages, **kwargs + + # Auto-inject InMemoryHistoryProvider when session is provided, no context providers + # registered, and no service-side storage indicators + if ( + session is not None + and not self.context_providers + and not session.service_session_id + and not opts.get("conversation_id") + and not opts.get("store") + and not (getattr(self.client, "STORES_BY_DEFAULT", False) and opts.get("store") is not False) + ): + self.context_providers.append(InMemoryHistoryProvider("memory")) + + session_context, chat_options = await self._prepare_session_and_messages( + session=session, + input_messages=input_messages, + options=opts, ) # Normalize tools - normalized_tools: list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] = ( + normalized_tools: list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any] = ( [] if tools_ is None else tools_ if isinstance(tools_, list) else [tools_] ) agent_name = self._get_agent_name() # Resolve final tool list (runtime provided tools + local MCP server tools) - final_tools: list[ToolProtocol | Callable[..., Any] | dict[str, Any]] = [] + final_tools: list[FunctionTool | Callable[..., Any] | dict[str, Any] | Any] = [] for tool in normalized_tools: if isinstance(tool, MCPTool): if not tool.is_connected: @@ -1026,7 +1060,7 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] # Build options dict from run() options merged with provided options run_opts: dict[str, Any] = { "model_id": opts.pop("model_id", None), - "conversation_id": thread.service_thread_id, + "conversation_id": session.service_session_id if session else opts.pop("conversation_id", None), "allow_multiple_tool_calls": opts.pop("allow_multiple_tool_calls", None), "additional_function_arguments": opts.pop("additional_function_arguments", None), "frequency_penalty": opts.pop("frequency_penalty", None), @@ -1047,103 +1081,131 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] } # Remove None values and merge with chat_options run_opts = {k: v for k, v in run_opts.items() if v is not None} - co = _merge_options(run_chat_options, run_opts) + co = _merge_options(chat_options, run_opts) - # Ensure thread is forwarded in kwargs for tool invocation + # Build session_messages from session context: context messages + input messages + session_messages: list[Message] = session_context.get_messages(include_input=True) + + # Ensure session is forwarded in kwargs for tool invocation finalize_kwargs = dict(kwargs) - finalize_kwargs["thread"] = thread + finalize_kwargs["session"] = session # Filter chat_options from kwargs to prevent duplicate keyword argument filtered_kwargs = {k: v for k, v in finalize_kwargs.items() if k != "chat_options"} return { - "thread": thread, + "session": session, + "session_context": session_context, "input_messages": input_messages, - "thread_messages": thread_messages, + "session_messages": session_messages, "agent_name": agent_name, "chat_options": co, "filtered_kwargs": filtered_kwargs, "finalize_kwargs": finalize_kwargs, } - async def _finalize_response_and_update_thread( + async def _finalize_response( self, response: ChatResponse, agent_name: str, - thread: AgentThread, - input_messages: list[ChatMessage], - kwargs: dict[str, Any], + session: AgentSession | None, + session_context: SessionContext, ) -> None: - """Finalize response by updating thread and setting author names. + """Finalize response by setting author names and running after_run providers. Args: response: The chat response to finalize. agent_name: The name of the agent to set as author. - thread: The conversation thread. - input_messages: The input messages. - kwargs: Additional keyword arguments. + session: The conversation session. + session_context: The invocation context. """ - await self._update_thread_with_type_and_conversation_id(thread, response.conversation_id) - # Ensure that the author name is set for each message in the response. for message in response.messages: if message.author_name is None: message.author_name = agent_name - # Only notify the thread of new messages if the chatResponse was successful - # to avoid inconsistent messages state in the thread. - await self._notify_thread_of_new_messages( - thread, - input_messages, - response.messages, - **{k: v for k, v in kwargs.items() if k != "thread"}, + # Propagate conversation_id back to session (e.g. thread ID from Assistants API). + # For Responses-style APIs this can rotate every turn (response_id-based continuation), + # so refresh when a newer value is returned. + if session and response.conversation_id and session.service_session_id != response.conversation_id: + session.service_session_id = response.conversation_id + + # Set the response on the context for after_run providers + session_context._response = AgentResponse( # type: ignore[assignment] + messages=response.messages, + response_id=response.response_id, ) - @override - def get_new_thread( + # Run after_run providers (reverse order) + await self._run_after_providers(session=session, context=session_context) + + async def _prepare_session_and_messages( self, *, - service_thread_id: str | None = None, - **kwargs: Any, - ) -> AgentThread: - """Get a new conversation thread for the agent. + session: AgentSession | None, + input_messages: list[Message] | None = None, + options: dict[str, Any] | None = None, + ) -> tuple[SessionContext, dict[str, Any]]: + """Prepare the session context and messages for agent execution. - If you supply a service_thread_id, the thread will be marked as service managed. - - If you don't supply a service_thread_id but have a conversation_id configured on the agent, - that conversation_id will be used to create a service-managed thread. - - If you don't supply a service_thread_id but have a chat_message_store_factory configured on the agent, - that factory will be used to create a message store for the thread and the thread will be - managed locally. - - When neither is present, the thread will be created without a service ID or message store. - This will be updated based on usage when you run the agent with this thread. - If you run with ``store=True``, the response will include a thread_id and that will be set. - Otherwise a message store is created from the default factory. + Runs the before_run pipeline on all context providers and assembles + the chat options from default options and provider-contributed context. Keyword Args: - service_thread_id: Optional service managed thread ID. - kwargs: Not used at present. + session: The conversation session (None for stateless invocation). + input_messages: Messages to process. + options: Runtime options dict (already copied, safe to mutate). Returns: - A new AgentThread instance. + A tuple containing: + - The SessionContext with provider context populated + - The merged chat options dict """ - if service_thread_id is not None: - return AgentThread( - service_thread_id=service_thread_id, - context_provider=self.context_provider, + # Create a shallow copy of options and deep copy non-tool values + if self.default_options: + chat_options: dict[str, Any] = {} + for key, value in self.default_options.items(): + if key == "tools": + chat_options[key] = list(value) if value else [] + else: + chat_options[key] = deepcopy(value) + else: + chat_options = {} + + session_context = SessionContext( + session_id=session.session_id if session else None, + service_session_id=session.service_session_id if session else None, + input_messages=input_messages or [], + options=options or {}, + ) + + # Run before_run providers (forward order, skip BaseHistoryProvider with load_messages=False) + state = session.state if session else {} + for provider in self.context_providers: + if isinstance(provider, BaseHistoryProvider) and not provider.load_messages: + continue + await provider.before_run( + agent=self, # type: ignore[arg-type] + session=session, # type: ignore[arg-type] + context=session_context, + state=state, ) - if self.default_options.get("conversation_id") is not None: - return AgentThread( - service_thread_id=self.default_options["conversation_id"], - context_provider=self.context_provider, - ) - if self.chat_message_store_factory is not None: - return AgentThread( - message_store=self.chat_message_store_factory(), - context_provider=self.context_provider, - ) - return AgentThread(context_provider=self.context_provider) + + # Merge provider-contributed tools into chat_options + if session_context.tools: + if chat_options.get("tools") is not None: + chat_options["tools"].extend(session_context.tools) + else: + chat_options["tools"] = list(session_context.tools) + + # Merge provider-contributed instructions into chat_options + if session_context.instructions: + combined_instructions = "\n".join(session_context.instructions) + if "instructions" in chat_options: + chat_options["instructions"] = f"{chat_options['instructions']}\n{combined_instructions}" + else: + chat_options["instructions"] = combined_instructions + + return session_context, chat_options def as_mcp_server( self, @@ -1254,115 +1316,6 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] return server - async def _update_thread_with_type_and_conversation_id( - self, thread: AgentThread, response_conversation_id: str | None - ) -> None: - """Update thread with storage type and conversation ID. - - Args: - thread: The thread to update. - response_conversation_id: The conversation ID from the response, if any. - - Raises: - AgentExecutionException: If conversation ID is missing for service-managed thread. - """ - if response_conversation_id is None and thread.service_thread_id is not None: - # We were passed a thread that is service managed, but we got no conversation id back from the chat client, - # meaning the service doesn't support service managed threads, - # so the thread cannot be used with this service. - raise AgentExecutionException( - "Service did not return a valid conversation id when using a service managed thread." - ) - - if response_conversation_id is not None: - # If we got a conversation id back from the chat client, it means that the service - # supports server side thread storage so we should update the thread with the new id. - thread.service_thread_id = response_conversation_id - if thread.context_provider: - await thread.context_provider.thread_created(thread.service_thread_id) - elif thread.message_store is None and self.chat_message_store_factory is not None: - # If the service doesn't use service side thread storage (i.e. we got no id back from invocation), and - # the thread has no message_store yet, and we have a custom messages store, we should update the thread - # with the custom message_store so that it has somewhere to store the chat history. - thread.message_store = self.chat_message_store_factory() - - async def _prepare_thread_and_messages( - self, - *, - thread: AgentThread | None, - input_messages: list[ChatMessage] | None = None, - **kwargs: Any, - ) -> tuple[AgentThread, dict[str, Any], list[ChatMessage]]: - """Prepare the thread and messages for agent execution. - - This method prepares the conversation thread, merges context provider data, - and assembles the final message list for the chat client. - - Keyword Args: - thread: The conversation thread. - input_messages: Messages to process. - **kwargs: Any extra arguments to pass from the agent run. - - Returns: - A tuple containing: - - The validated or created thread - - The merged chat options - - The complete list of messages for the chat client - - Raises: - AgentExecutionException: If the conversation IDs on the thread and agent don't match. - """ - # Create a shallow copy of options and deep copy non-tool values - # Tools containing HTTP clients or other non-copyable objects cannot be deep copied - if self.default_options: - chat_options: dict[str, Any] = {} - for key, value in self.default_options.items(): - if key == "tools": - # Keep tool references as-is (don't deep copy) - chat_options[key] = list(value) if value else [] - else: - # Deep copy other options to prevent mutation - chat_options[key] = deepcopy(value) - else: - chat_options = {} - thread = thread or self.get_new_thread() - if thread.service_thread_id and thread.context_provider: - await thread.context_provider.thread_created(thread.service_thread_id) - thread_messages: list[ChatMessage] = [] - if thread.message_store: - thread_messages.extend(await thread.message_store.list_messages() or []) - context: Context | None = None - if self.context_provider: - # Note: We don't use 'async with' here because the context provider's lifecycle - # should be managed by the user (via async with) or persist across multiple invocations. - # Using async with here would close resources (like retrieval clients) after each query. - context = await self.context_provider.invoking(input_messages or [], **kwargs) - if context: - if context.messages: - thread_messages.extend(context.messages) - if context.tools: - if chat_options.get("tools") is not None: - chat_options["tools"].extend(context.tools) - else: - chat_options["tools"] = list(context.tools) - if context.instructions: - chat_options["instructions"] = ( - context.instructions - if "instructions" not in chat_options - else f"{chat_options['instructions']}\n{context.instructions}" - ) - thread_messages.extend(input_messages or []) - if ( - thread.service_thread_id - and chat_options.get("conversation_id") - and thread.service_thread_id != chat_options["conversation_id"] - ): - raise AgentExecutionException( - "The conversation_id set on the agent is different from the one set on the thread, " - "only one ID can be used for a run." - ) - return thread, chat_options, thread_messages - def _get_agent_name(self) -> str: """Get the agent name for message attribution. @@ -1372,11 +1325,11 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] return self.name or "UnnamedAgent" -class ChatAgent( +class Agent( AgentTelemetryLayer, AgentMiddlewareLayer, - RawChatAgent[TOptions_co], - Generic[TOptions_co], + RawAgent[OptionsCoT], + Generic[OptionsCoT], ): """A Chat Client Agent with middleware, telemetry, and full layer support. @@ -1384,39 +1337,38 @@ class ChatAgent( - Agent middleware support for request/response interception - OpenTelemetry-based telemetry for observability - For a minimal implementation without these features, use :class:`RawChatAgent`. + For a minimal implementation without these features, use :class:`RawAgent`. """ def __init__( self, - chat_client: ChatClientProtocol[TOptions_co], + client: SupportsChatGetResponse[OptionsCoT], instructions: str | None = None, *, id: str | None = None, name: str | None = None, description: str | None = None, - tools: ToolProtocol + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Any + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any] | None = None, - default_options: TOptions_co | None = None, - chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None, - context_provider: ContextProvider | None = None, + default_options: OptionsCoT | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, **kwargs: Any, ) -> None: - """Initialize a ChatAgent instance.""" + """Initialize a Agent instance.""" super().__init__( - chat_client=chat_client, + client=client, instructions=instructions, id=id, name=name, description=description, tools=tools, default_options=default_options, - chat_message_store_factory=chat_message_store_factory, - context_provider=context_provider, + context_providers=context_providers, middleware=middleware, **kwargs, ) diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index d44c8e7f80..57daed6286 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -28,17 +28,15 @@ from typing import ( from pydantic import BaseModel from ._logging import get_logger -from ._memory import ContextProvider from ._serialization import SerializationMixin -from ._threads import ChatMessageStoreProtocol from ._tools import ( FunctionInvocationConfiguration, - ToolProtocol, + FunctionTool, ) from ._types import ( - ChatMessage, ChatResponse, ChatResponseUpdate, + Message, ResponseStream, prepare_messages, validate_chat_options, @@ -51,42 +49,47 @@ else: if TYPE_CHECKING: - from ._agents import ChatAgent + from ._agents import Agent from ._middleware import ( MiddlewareTypes, ) from ._types import ChatOptions -TInput = TypeVar("TInput", contravariant=True) +InputT = TypeVar("InputT", contravariant=True) -TEmbedding = TypeVar("TEmbedding") -TBaseChatClient = TypeVar("TBaseChatClient", bound="BaseChatClient") +EmbeddingT = TypeVar("EmbeddingT") +BaseChatClientT = TypeVar("BaseChatClientT", bound="BaseChatClient") logger = get_logger() __all__ = [ "BaseChatClient", - "ChatClientProtocol", + "SupportsChatGetResponse", + "SupportsCodeInterpreterTool", + "SupportsFileSearchTool", + "SupportsImageGenerationTool", + "SupportsMCPTool", + "SupportsWebSearchTool", ] -# region ChatClientProtocol Protocol +# region SupportsChatGetResponse Protocol # Contravariant for the Protocol -TOptions_contra = TypeVar( - "TOptions_contra", +OptionsContraT = TypeVar( + "OptionsContraT", bound=TypedDict, # type: ignore[valid-type] default="ChatOptions[None]", contravariant=True, ) # Used for the overloads that capture the response model type from options -TResponseModelT = TypeVar("TResponseModelT", bound=BaseModel) +ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) @runtime_checkable -class ChatClientProtocol(Protocol[TOptions_contra]): +class SupportsChatGetResponse(Protocol[OptionsContraT]): """A protocol for a chat client that can generate responses. This protocol defines the interface that all chat clients must implement, @@ -103,7 +106,7 @@ class ChatClientProtocol(Protocol[TOptions_contra]): Examples: .. code-block:: python - from agent_framework import ChatClientProtocol, ChatResponse, ChatMessage + from agent_framework import SupportsChatGetResponse, ChatResponse, Message # Any class implementing the required methods is compatible @@ -128,7 +131,7 @@ class ChatClientProtocol(Protocol[TOptions_contra]): # Verify the instance satisfies the protocol client = CustomChatClient() - assert isinstance(client, ChatClientProtocol) + assert isinstance(client, SupportsChatGetResponse) """ additional_properties: dict[str, Any] @@ -136,39 +139,39 @@ class ChatClientProtocol(Protocol[TOptions_contra]): @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[False] = ..., - options: ChatOptions[TResponseModelT], + options: ChatOptions[ResponseModelBoundT], **kwargs: Any, - ) -> Awaitable[ChatResponse[TResponseModelT]]: ... + ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[False] = ..., - options: TOptions_contra | ChatOptions[None] | None = None, + options: OptionsContraT | ChatOptions[None] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[True], - options: TOptions_contra | ChatOptions[Any] | None = None, + options: OptionsContraT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: bool = False, - options: TOptions_contra | ChatOptions[Any] | None = None, + options: OptionsContraT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Send input and return the response. @@ -195,15 +198,15 @@ class ChatClientProtocol(Protocol[TOptions_contra]): # region ChatClientBase # Covariant for the BaseChatClient -TOptions_co = TypeVar( - "TOptions_co", +OptionsCoT = TypeVar( + "OptionsCoT", bound=TypedDict, # type: ignore[valid-type] default="ChatOptions[None]", covariant=True, ) -class BaseChatClient(SerializationMixin, ABC, Generic[TOptions_co]): +class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): """Abstract base class for chat clients without middleware wrapping. This abstract base class provides core functionality for chat client implementations, @@ -226,7 +229,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[TOptions_co]): Examples: .. code-block:: python - from agent_framework import BaseChatClient, ChatResponse, ChatMessage + from agent_framework import BaseChatClient, ChatResponse, Message from collections.abc import AsyncIterable @@ -243,7 +246,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[TOptions_co]): else: # Non-streaming implementation return ChatResponse( - messages=[ChatMessage(role="assistant", text="Hello!")], response_id="custom-response" + messages=[Message(role="assistant", text="Hello!")], response_id="custom-response" ) @@ -259,7 +262,15 @@ class BaseChatClient(SerializationMixin, ABC, Generic[TOptions_co]): OTEL_PROVIDER_NAME: ClassVar[str] = "unknown" DEFAULT_EXCLUDE: ClassVar[set[str]] = {"additional_properties"} - # This is used for OTel setup, should be overridden in subclasses + STORES_BY_DEFAULT: ClassVar[bool] = False + """Whether this client stores conversation history server-side by default. + + Clients that use server-side storage (e.g., OpenAI Responses API with ``store=True`` + as default, Azure AI Agent sessions) should override this to ``True``. + When ``True``, the agent skips auto-injecting ``InMemoryHistoryProvider`` unless the + user explicitly sets ``store=False``. + """ + # OTEL_PROVIDER_NAME is used for OTel setup, should be overridden in subclasses def __init__( self, @@ -338,7 +349,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[TOptions_co]): def _inner_get_response( self, *, - messages: Sequence[ChatMessage], + messages: Sequence[Message], stream: bool, options: Mapping[str, Any], **kwargs: Any, @@ -365,39 +376,39 @@ class BaseChatClient(SerializationMixin, ABC, Generic[TOptions_co]): @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[False] = ..., - options: ChatOptions[TResponseModelT], + options: ChatOptions[ResponseModelBoundT], **kwargs: Any, - ) -> Awaitable[ChatResponse[TResponseModelT]]: ... + ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[False] = ..., - options: TOptions_co | ChatOptions[None] | None = None, + options: OptionsCoT | ChatOptions[None] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[True], - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: bool = False, - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Get a response from a chat client. @@ -437,21 +448,20 @@ class BaseChatClient(SerializationMixin, ABC, Generic[TOptions_co]): name: str | None = None, description: str | None = None, instructions: str | None = None, - tools: ToolProtocol + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | Mapping[str, Any] | None = None, - chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None, - context_provider: ContextProvider | None = None, + default_options: OptionsCoT | Mapping[str, Any] | None = None, + context_providers: Sequence[Any] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, **kwargs: Any, - ) -> ChatAgent[TOptions_co]: - """Create a ChatAgent with this client. + ) -> Agent[OptionsCoT]: + """Create a Agent with this client. - This is a convenience method that creates a ChatAgent instance with this + This is a convenience method that creates a Agent instance with this chat client already configured. Keyword Args: @@ -466,15 +476,13 @@ class BaseChatClient(SerializationMixin, ABC, Generic[TOptions_co]): including temperature, max_tokens, model_id, tool_choice, and more. Note: response_format typing does not flow into run outputs when set via default_options, and dict literals are accepted without specialized option typing. - chat_message_store_factory: Factory function to create an instance of ChatMessageStoreProtocol. - If not provided, the default in-memory store will be used. - context_provider: Context providers to include during agent invocation. + context_providers: Context providers to include during agent invocation. middleware: List of middleware to intercept agent and function invocations. function_invocation_configuration: Optional function invocation configuration override. kwargs: Any additional keyword arguments. Will be stored as ``additional_properties``. Returns: - A ChatAgent instance configured with this chat client. + A Agent instance configured with this chat client. Examples: .. code-block:: python @@ -494,19 +502,178 @@ class BaseChatClient(SerializationMixin, ABC, Generic[TOptions_co]): # Run the agent response = await agent.run("Hello!") """ - from ._agents import ChatAgent + from ._agents import Agent - return ChatAgent( - chat_client=self, + return Agent( + client=self, id=id, name=name, description=description, instructions=instructions, tools=tools, default_options=cast(Any, default_options), - chat_message_store_factory=chat_message_store_factory, - context_provider=context_provider, + context_providers=context_providers, middleware=middleware, function_invocation_configuration=function_invocation_configuration, **kwargs, ) + + +# endregion + + +# region Tool Support Protocols + + +@runtime_checkable +class SupportsCodeInterpreterTool(Protocol): + """Protocol for clients that support code interpreter tools. + + This protocol enables runtime checking to determine if a client + supports code interpreter functionality. + + Examples: + .. code-block:: python + + from agent_framework import SupportsCodeInterpreterTool + + if isinstance(client, SupportsCodeInterpreterTool): + tool = client.get_code_interpreter_tool() + agent = ChatAgent(client, tools=[tool]) + """ + + @staticmethod + def get_code_interpreter_tool(**kwargs: Any) -> Any: + """Create a code interpreter tool configuration. + + Keyword Args: + **kwargs: Provider-specific configuration options. + + Returns: + A tool configuration ready to pass to ChatAgent. + """ + ... + + +@runtime_checkable +class SupportsWebSearchTool(Protocol): + """Protocol for clients that support web search tools. + + This protocol enables runtime checking to determine if a client + supports web search functionality. + + Examples: + .. code-block:: python + + from agent_framework import SupportsWebSearchTool + + if isinstance(client, SupportsWebSearchTool): + tool = client.get_web_search_tool() + agent = ChatAgent(client, tools=[tool]) + """ + + @staticmethod + def get_web_search_tool(**kwargs: Any) -> Any: + """Create a web search tool configuration. + + Keyword Args: + **kwargs: Provider-specific configuration options. + + Returns: + A tool configuration ready to pass to ChatAgent. + """ + ... + + +@runtime_checkable +class SupportsImageGenerationTool(Protocol): + """Protocol for clients that support image generation tools. + + This protocol enables runtime checking to determine if a client + supports image generation functionality. + + Examples: + .. code-block:: python + + from agent_framework import SupportsImageGenerationTool + + if isinstance(client, SupportsImageGenerationTool): + tool = client.get_image_generation_tool() + agent = ChatAgent(client, tools=[tool]) + """ + + @staticmethod + def get_image_generation_tool(**kwargs: Any) -> Any: + """Create an image generation tool configuration. + + Keyword Args: + **kwargs: Provider-specific configuration options. + + Returns: + A tool configuration ready to pass to ChatAgent. + """ + ... + + +@runtime_checkable +class SupportsMCPTool(Protocol): + """Protocol for clients that support MCP (Model Context Protocol) tools. + + This protocol enables runtime checking to determine if a client + supports MCP server connections. + + Examples: + .. code-block:: python + + from agent_framework import SupportsMCPTool + + if isinstance(client, SupportsMCPTool): + tool = client.get_mcp_tool(name="my_mcp", url="https://...") + agent = ChatAgent(client, tools=[tool]) + """ + + @staticmethod + def get_mcp_tool(**kwargs: Any) -> Any: + """Create an MCP tool configuration. + + Keyword Args: + **kwargs: Provider-specific configuration options including + name and url for the MCP server. + + Returns: + A tool configuration ready to pass to ChatAgent. + """ + ... + + +@runtime_checkable +class SupportsFileSearchTool(Protocol): + """Protocol for clients that support file search tools. + + This protocol enables runtime checking to determine if a client + supports file search functionality with vector stores. + + Examples: + .. code-block:: python + + from agent_framework import SupportsFileSearchTool + + if isinstance(client, SupportsFileSearchTool): + tool = client.get_file_search_tool(vector_store_ids=["vs_123"]) + agent = ChatAgent(client, tools=[tool]) + """ + + @staticmethod + def get_file_search_tool(**kwargs: Any) -> Any: + """Create a file search tool configuration. + + Keyword Args: + **kwargs: Provider-specific configuration options. + + Returns: + A tool configuration ready to pass to ChatAgent. + """ + ... + + +# endregion diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index d716aa0c94..d9a2b5579d 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -12,7 +12,7 @@ from collections.abc import Callable, Collection, Sequence from contextlib import AsyncExitStack, _AsyncGeneratorContextManager # type: ignore from datetime import timedelta from functools import partial -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, TypedDict import httpx from anyio import ClosedResourceError @@ -28,12 +28,11 @@ from pydantic import BaseModel, create_model from ._tools import ( FunctionTool, - HostedMCPSpecificApproval, _build_pydantic_model_from_json_schema, ) from ._types import ( - ChatMessage, Content, + Message, ) from .exceptions import ToolException, ToolExecutionException @@ -43,7 +42,22 @@ else: from typing_extensions import Self # pragma: no cover if TYPE_CHECKING: - from ._clients import ChatClientProtocol + from ._clients import SupportsChatGetResponse + + +class MCPSpecificApproval(TypedDict, total=False): + """Represents the specific approval mode for an MCP tool. + + When using this mode, the user must specify which tools always or never require approval. + + Attributes: + always_require_approval: A sequence of tool names that always require approval. + never_require_approval: A sequence of tool names that never require approval. + """ + + always_require_approval: Collection[str] | None + never_require_approval: Collection[str] | None + logger = logging.getLogger(__name__) @@ -67,65 +81,137 @@ __all__ = [ ] +def _parse_prompt_result_from_mcp( + mcp_type: types.GetPromptResult, +) -> str: + """Parse an MCP GetPromptResult directly into a string representation. + + Converts each message in the prompt result to its string form and combines them. + + Args: + mcp_type: The MCP GetPromptResult object to convert. + + Returns: + A string representation of the prompt result. + """ + import json + + parts: list[str] = [] + for message in mcp_type.messages: + content = message.content + if isinstance(content, types.TextContent): + parts.append(content.text) + elif isinstance(content, (types.ImageContent, types.AudioContent)): + parts.append( + json.dumps( + { + "type": "image" if isinstance(content, types.ImageContent) else "audio", + "data": content.data, + "mimeType": content.mimeType, + }, + default=str, + ) + ) + elif isinstance(content, types.EmbeddedResource): + match content.resource: + case types.TextResourceContents(): + parts.append(content.resource.text) + case types.BlobResourceContents(): + parts.append( + json.dumps( + { + "type": "blob", + "data": content.resource.blob, + "mimeType": content.resource.mimeType, + }, + default=str, + ) + ) + else: + parts.append(str(content)) + if not parts: + return "" + if len(parts) == 1: + return parts[0] + return json.dumps(parts, default=str) + + def _parse_message_from_mcp( mcp_type: types.PromptMessage | types.SamplingMessage, -) -> ChatMessage: +) -> Message: """Parse an MCP container type into an Agent Framework type.""" - return ChatMessage( + return Message( role=mcp_type.role, contents=_parse_content_from_mcp(mcp_type.content), raw_representation=mcp_type, ) -def _parse_contents_from_mcp_tool_result( +def _parse_tool_result_from_mcp( mcp_type: types.CallToolResult, -) -> list[Content]: - """Parse an MCP CallToolResult into Agent Framework content types. +) -> str: + """Parse an MCP CallToolResult directly into a string representation. - This function extracts the complete _meta field from CallToolResult objects - and merges all metadata into the additional_properties field of converted - content items. - - Note: The _meta field from CallToolResult is applied to ALL content items - in the result, as the Agent Framework's content model doesn't have a - result-level metadata container. This ensures metadata is preserved but - means it will be duplicated across multiple content items if present. + Converts each content item in the MCP result to its string form and combines them. + This skips the intermediate Content object step for tool results. Args: mcp_type: The MCP CallToolResult object to convert. Returns: - A list of Agent Framework content items with metadata merged into - additional_properties. + A string representation of the tool result — either plain text or serialized JSON. """ - meta_data = mcp_type.meta + import json - # Prepare merged metadata once if present - merged_meta_props = None - if meta_data: - merged_meta_props = {} - if hasattr(meta_data, "__dict__"): - merged_meta_props.update(meta_data.__dict__) - elif isinstance(meta_data, dict): - merged_meta_props.update(meta_data) - else: - merged_meta_props["_meta"] = meta_data - - # Convert each content item and merge metadata - result_contents = [] + parts: list[str] = [] for item in mcp_type.content: - contents = _parse_content_from_mcp(item) - - if merged_meta_props: - for content in contents: - existing_props = getattr(content, "additional_properties", None) or {} - # Merge with content-specific properties, letting content-specific props override - final_props = merged_meta_props.copy() - final_props.update(existing_props) - content.additional_properties = final_props - result_contents.extend(contents) - return result_contents + match item: + case types.TextContent(): + parts.append(item.text) + case types.ImageContent() | types.AudioContent(): + parts.append( + json.dumps( + { + "type": "image" if isinstance(item, types.ImageContent) else "audio", + "data": item.data, + "mimeType": item.mimeType, + }, + default=str, + ) + ) + case types.ResourceLink(): + parts.append( + json.dumps( + { + "type": "resource_link", + "uri": str(item.uri), + "mimeType": item.mimeType, + }, + default=str, + ) + ) + case types.EmbeddedResource(): + match item.resource: + case types.TextResourceContents(): + parts.append(item.resource.text) + case types.BlobResourceContents(): + parts.append( + json.dumps( + { + "type": "blob", + "data": item.resource.blob, + "mimeType": item.resource.mimeType, + }, + default=str, + ) + ) + case _: + parts.append(str(item)) + if not parts: + return "" + if len(parts) == 1: + return parts[0] + return json.dumps(parts, default=str) def _parse_content_from_mcp( @@ -256,9 +342,9 @@ def _prepare_content_for_mcp( def _prepare_message_for_mcp( - content: ChatMessage, + content: Message, ) -> list[types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink]: - """Prepare a ChatMessage for MCP format.""" + """Prepare a Message for MCP format.""" messages: list[ types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink ] = [] @@ -327,15 +413,15 @@ class MCPTool: self, name: str, description: str | None = None, - approval_mode: (Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None) = None, + approval_mode: (Literal["always_require", "never_require"] | MCPSpecificApproval | None) = None, allowed_tools: Collection[str] | None = None, load_tools: bool = True, - parse_tool_results: Literal[True] | Callable[[types.CallToolResult], Any] | None = True, + parse_tool_results: Callable[[types.CallToolResult], str] | None = None, load_prompts: bool = True, - parse_prompt_results: Literal[True] | Callable[[types.GetPromptResult], Any] | None = True, + parse_prompt_results: Callable[[types.GetPromptResult], str] | None = None, session: ClientSession | None = None, request_timeout: int | None = None, - chat_client: ChatClientProtocol | None = None, + client: SupportsChatGetResponse | None = None, additional_properties: dict[str, Any] | None = None, ) -> None: """Initialize the MCP Tool base. @@ -343,6 +429,30 @@ class MCPTool: Note: Do not use this method, use one of the subclasses: MCPStreamableHTTPTool, MCPWebsocketTool or MCPStdioTool. + + Args: + name: The name of the MCP tool. + description: A description of the MCP tool. + approval_mode: Whether approval is required to run tools. + allowed_tools: A collection of tool names to allow. + load_tools: Whether to load tools from the MCP server. + parse_tool_results: An optional callable with signature + ``Callable[[types.CallToolResult], str]`` that overrides the default result + parsing. When ``None`` (the default), the built-in parser converts MCP types + directly to a string. If you need per-function result parsing, access the + ``.functions`` list after connecting and set ``result_parser`` on individual + ``FunctionTool`` instances. + load_prompts: Whether to load prompts from the MCP server. + parse_prompt_results: An optional callable with signature + ``Callable[[types.GetPromptResult], str]`` that overrides the default prompt + result parsing. When ``None`` (the default), the built-in parser converts + MCP prompt results to a string. If you need per-function result parsing, + access the ``.functions`` list after connecting and set ``result_parser`` on + individual ``FunctionTool`` instances. + session: An existing MCP client session to use. + request_timeout: Timeout in seconds for MCP requests. + client: A chat client for sampling callbacks. + additional_properties: Additional properties for the tool. """ self.name = name self.description = description or "" @@ -356,8 +466,8 @@ class MCPTool: self._exit_stack = AsyncExitStack() self.session = session self.request_timeout = request_timeout - self.chat_client = chat_client - self._functions: list[FunctionTool[Any, Any]] = [] + self.client = client + self._functions: list[FunctionTool[Any]] = [] self.is_connected: bool = False self._tools_loaded: bool = False self._prompts_loaded: bool = False @@ -366,7 +476,7 @@ class MCPTool: return f"MCPTool(name={self.name}, description={self.description})" @property - def functions(self) -> list[FunctionTool[Any, Any]]: + def functions(self) -> list[FunctionTool[Any]]: """Get the list of functions that are allowed.""" if not self.allowed_tools: return self._functions @@ -507,17 +617,17 @@ class MCPTool: Returns: Either a CreateMessageResult with the generated message or ErrorData if generation fails. """ - if not self.chat_client: + if not self.client: return types.ErrorData( code=types.INTERNAL_ERROR, message="No chat client available. Please set a chat client.", ) logger.debug("Sampling callback called with params: %s", params) - messages: list[ChatMessage] = [] + messages: list[Message] = [] for msg in params.messages: messages.append(_parse_message_from_mcp(msg)) try: - response = await self.chat_client.get_response( + response = await self.client.get_response( messages, temperature=params.temperature, max_tokens=params.maxTokens, @@ -634,7 +744,7 @@ class MCPTool: input_model = _get_input_model_from_mcp_prompt(prompt) approval_mode = self._determine_approval_mode(local_name) - func: FunctionTool[BaseModel, list[ChatMessage] | Any | types.GetPromptResult] = FunctionTool( + func: FunctionTool[BaseModel] = FunctionTool( func=partial(self.get_prompt, prompt.name), name=local_name, description=prompt.description or "", @@ -678,7 +788,7 @@ class MCPTool: input_model = _get_input_model_from_mcp_tool(tool) approval_mode = self._determine_approval_mode(local_name) # Create FunctionTools out of each tool - func: FunctionTool[BaseModel, list[Content] | Any | types.CallToolResult] = FunctionTool( + func: FunctionTool[BaseModel] = FunctionTool( func=partial(self.call_tool, tool.name), name=local_name, description=tool.description or "", @@ -732,7 +842,7 @@ class MCPTool: inner_exception=ex, ) from ex - async def call_tool(self, tool_name: str, **kwargs: Any) -> list[Content] | Any | types.CallToolResult: + async def call_tool(self, tool_name: str, **kwargs: Any) -> str: """Call a tool with the given arguments. Args: @@ -742,7 +852,7 @@ class MCPTool: kwargs: Arguments to pass to the tool. Returns: - A list of content items returned by the tool. + A string representation of the tool result — either plain text or serialized JSON. Raises: ToolExecutionException: If the MCP server is not connected, tools are not loaded, @@ -762,20 +872,25 @@ class MCPTool: k: v for k, v in kwargs.items() if k - not in {"chat_options", "tools", "tool_choice", "thread", "conversation_id", "options", "response_format"} + not in { + "chat_options", + "tools", + "tool_choice", + "session", + "thread", + "conversation_id", + "options", + "response_format", + } } + parser = self.parse_tool_results or _parse_tool_result_from_mcp + # Try the operation, reconnecting once if the connection is closed for attempt in range(2): try: result = await self.session.call_tool(tool_name, arguments=filtered_kwargs) # type: ignore - if self.parse_tool_results is None: - return result - if self.parse_tool_results is True: - return _parse_contents_from_mcp_tool_result(result) - if callable(self.parse_tool_results): - return self.parse_tool_results(result) - return result + return parser(result) except ClosedResourceError as cl_ex: if attempt == 0: # First attempt failed, try reconnecting @@ -801,7 +916,7 @@ class MCPTool: raise ToolExecutionException(f"Failed to call tool '{tool_name}'.", inner_exception=ex) from ex raise ToolExecutionException(f"Failed to call tool '{tool_name}' after retries.") - async def get_prompt(self, prompt_name: str, **kwargs: Any) -> list[ChatMessage] | Any | types.GetPromptResult: + async def get_prompt(self, prompt_name: str, **kwargs: Any) -> str: """Call a prompt with the given arguments. Args: @@ -811,7 +926,7 @@ class MCPTool: kwargs: Arguments to pass to the prompt. Returns: - A list of chat messages returned by the prompt. + A string representation of the prompt result — either plain text or serialized JSON. Raises: ToolExecutionException: If the MCP server is not connected, prompts are not loaded, @@ -822,17 +937,13 @@ class MCPTool: "Prompts are not loaded for this server, please set load_prompts=True in the constructor." ) + parser = self.parse_prompt_results or _parse_prompt_result_from_mcp + # Try the operation, reconnecting once if the connection is closed for attempt in range(2): try: prompt_result = await self.session.get_prompt(prompt_name, arguments=kwargs) # type: ignore - if self.parse_prompt_results is None: - return prompt_result - if self.parse_prompt_results is True: - return [_parse_message_from_mcp(message) for message in prompt_result.messages] - if callable(self.parse_prompt_results): - return self.parse_prompt_results(prompt_result) - return prompt_result + return parser(prompt_result) except ClosedResourceError as cl_ex: if attempt == 0: # First attempt failed, try reconnecting @@ -909,7 +1020,7 @@ class MCPStdioTool(MCPTool): Examples: .. code-block:: python - from agent_framework import MCPStdioTool, ChatAgent + from agent_framework import MCPStdioTool, Agent # Create an MCP stdio tool mcp_tool = MCPStdioTool( @@ -921,7 +1032,7 @@ class MCPStdioTool(MCPTool): # Use with a chat agent async with mcp_tool: - agent = ChatAgent(chat_client=client, name="assistant", tools=mcp_tool) + agent = Agent(client=client, name="assistant", tools=mcp_tool) response = await agent.run("List files in the directory") """ @@ -931,18 +1042,18 @@ class MCPStdioTool(MCPTool): command: str, *, load_tools: bool = True, - parse_tool_results: Literal[True] | Callable[[types.CallToolResult], Any] | None = True, + parse_tool_results: Callable[[types.CallToolResult], str] | None = None, load_prompts: bool = True, - parse_prompt_results: Literal[True] | Callable[[types.GetPromptResult], Any] | None = True, + parse_prompt_results: Callable[[types.GetPromptResult], str] | None = None, request_timeout: int | None = None, session: ClientSession | None = None, description: str | None = None, - approval_mode: (Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None) = None, + approval_mode: (Literal["always_require", "never_require"] | MCPSpecificApproval | None) = None, allowed_tools: Collection[str] | None = None, args: list[str] | None = None, env: dict[str, str] | None = None, encoding: str | None = None, - chat_client: ChatClientProtocol | None = None, + client: SupportsChatGetResponse | None = None, additional_properties: dict[str, Any] | None = None, **kwargs: Any, ) -> None: @@ -959,15 +1070,19 @@ class MCPStdioTool(MCPTool): Keyword Args: load_tools: Whether to load tools from the MCP server. - parse_tool_results: How to parse tool results from the MCP server. - Set to True, to use the default parser that converts to Agent Framework types. - Set to a callable to use a custom parser function. - Set to None to return the raw MCP tool result. + parse_tool_results: An optional callable with signature + ``Callable[[types.CallToolResult], str]`` that overrides the default result + parsing. When ``None`` (the default), the built-in parser converts MCP types + directly to a string. If you need per-function result parsing, access the + ``.functions`` list after connecting and set ``result_parser`` on individual + ``FunctionTool`` instances. load_prompts: Whether to load prompts from the MCP server. - parse_prompt_results: How to parse prompt results from the MCP server. - Set to True, to use the default parser that converts to Agent Framework types. - Set to a callable to use a custom parser function. - Set to None to return the raw MCP prompt result. + parse_prompt_results: An optional callable with signature + ``Callable[[types.GetPromptResult], str]`` that overrides the default prompt + result parsing. When ``None`` (the default), the built-in parser converts + MCP prompt results to a string. If you need per-function result parsing, + access the ``.functions`` list after connecting and set ``result_parser`` on + individual ``FunctionTool`` instances. request_timeout: The default timeout in seconds for all requests. session: The session to use for the MCP connection. description: The description of the tool. @@ -982,7 +1097,7 @@ class MCPStdioTool(MCPTool): args: The arguments to pass to the command. env: The environment variables to set for the command. encoding: The encoding to use for the command output. - chat_client: The chat client to use for sampling. + client: The chat client to use for sampling. kwargs: Any extra arguments to pass to the stdio client. """ super().__init__( @@ -992,7 +1107,7 @@ class MCPStdioTool(MCPTool): allowed_tools=allowed_tools, additional_properties=additional_properties, session=session, - chat_client=chat_client, + client=client, load_tools=load_tools, parse_tool_results=parse_tool_results, load_prompts=load_prompts, @@ -1031,7 +1146,7 @@ class MCPStreamableHTTPTool(MCPTool): Examples: .. code-block:: python - from agent_framework import MCPStreamableHTTPTool, ChatAgent + from agent_framework import MCPStreamableHTTPTool, Agent # Create an MCP HTTP tool mcp_tool = MCPStreamableHTTPTool( @@ -1042,7 +1157,7 @@ class MCPStreamableHTTPTool(MCPTool): # Use with a chat agent async with mcp_tool: - agent = ChatAgent(chat_client=client, name="assistant", tools=mcp_tool) + agent = Agent(client=client, name="assistant", tools=mcp_tool) response = await agent.run("Fetch data from the API") """ @@ -1052,16 +1167,16 @@ class MCPStreamableHTTPTool(MCPTool): url: str, *, load_tools: bool = True, - parse_tool_results: Literal[True] | Callable[[types.CallToolResult], Any] | None = True, + parse_tool_results: Callable[[types.CallToolResult], str] | None = None, load_prompts: bool = True, - parse_prompt_results: Literal[True] | Callable[[types.GetPromptResult], Any] | None = True, + parse_prompt_results: Callable[[types.GetPromptResult], str] | None = None, request_timeout: int | None = None, session: ClientSession | None = None, description: str | None = None, - approval_mode: (Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None) = None, + approval_mode: (Literal["always_require", "never_require"] | MCPSpecificApproval | None) = None, allowed_tools: Collection[str] | None = None, terminate_on_close: bool | None = None, - chat_client: ChatClientProtocol | None = None, + client: SupportsChatGetResponse | None = None, additional_properties: dict[str, Any] | None = None, http_client: httpx.AsyncClient | None = None, **kwargs: Any, @@ -1080,15 +1195,19 @@ class MCPStreamableHTTPTool(MCPTool): Keyword Args: load_tools: Whether to load tools from the MCP server. - parse_tool_results: How to parse tool results from the MCP server. - Set to True, to use the default parser that converts to Agent Framework types. - Set to a callable to use a custom parser function. - Set to None to return the raw MCP tool result. + parse_tool_results: An optional callable with signature + ``Callable[[types.CallToolResult], str]`` that overrides the default result + parsing. When ``None`` (the default), the built-in parser converts MCP types + directly to a string. If you need per-function result parsing, access the + ``.functions`` list after connecting and set ``result_parser`` on individual + ``FunctionTool`` instances. load_prompts: Whether to load prompts from the MCP server. - parse_prompt_results: How to parse prompt results from the MCP server. - Set to True, to use the default parser that converts to Agent Framework types. - Set to a callable to use a custom parser function. - Set to None to return the raw MCP prompt result. + parse_prompt_results: An optional callable with signature + ``Callable[[types.GetPromptResult], str]`` that overrides the default prompt + result parsing. When ``None`` (the default), the built-in parser converts + MCP prompt results to a string. If you need per-function result parsing, + access the ``.functions`` list after connecting and set ``result_parser`` on + individual ``FunctionTool`` instances. request_timeout: The default timeout in seconds for all requests. session: The session to use for the MCP connection. description: The description of the tool. @@ -1101,7 +1220,7 @@ class MCPStreamableHTTPTool(MCPTool): allowed_tools: A list of tools that are allowed to use this tool. additional_properties: Additional properties. terminate_on_close: Close the transport when the MCP client is terminated. - chat_client: The chat client to use for sampling. + client: The chat client to use for sampling. http_client: Optional httpx.AsyncClient to use. If not provided, the ``streamable_http_client`` API will create and manage a default client. To configure headers, timeouts, or other HTTP client settings, create @@ -1115,7 +1234,7 @@ class MCPStreamableHTTPTool(MCPTool): allowed_tools=allowed_tools, additional_properties=additional_properties, session=session, - chat_client=chat_client, + client=client, load_tools=load_tools, parse_tool_results=parse_tool_results, load_prompts=load_prompts, @@ -1148,7 +1267,7 @@ class MCPWebsocketTool(MCPTool): Examples: .. code-block:: python - from agent_framework import MCPWebsocketTool, ChatAgent + from agent_framework import MCPWebsocketTool, Agent # Create an MCP WebSocket tool mcp_tool = MCPWebsocketTool( @@ -1157,7 +1276,7 @@ class MCPWebsocketTool(MCPTool): # Use with a chat agent async with mcp_tool: - agent = ChatAgent(chat_client=client, name="assistant", tools=mcp_tool) + agent = Agent(client=client, name="assistant", tools=mcp_tool) response = await agent.run("Connect to the real-time service") """ @@ -1167,15 +1286,15 @@ class MCPWebsocketTool(MCPTool): url: str, *, load_tools: bool = True, - parse_tool_results: Literal[True] | Callable[[types.CallToolResult], Any] | None = True, + parse_tool_results: Callable[[types.CallToolResult], str] | None = None, load_prompts: bool = True, - parse_prompt_results: Literal[True] | Callable[[types.GetPromptResult], Any] | None = True, + parse_prompt_results: Callable[[types.GetPromptResult], str] | None = None, request_timeout: int | None = None, session: ClientSession | None = None, description: str | None = None, - approval_mode: (Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None) = None, + approval_mode: (Literal["always_require", "never_require"] | MCPSpecificApproval | None) = None, allowed_tools: Collection[str] | None = None, - chat_client: ChatClientProtocol | None = None, + client: SupportsChatGetResponse | None = None, additional_properties: dict[str, Any] | None = None, **kwargs: Any, ) -> None: @@ -1193,15 +1312,19 @@ class MCPWebsocketTool(MCPTool): Keyword Args: load_tools: Whether to load tools from the MCP server. - parse_tool_results: How to parse tool results from the MCP server. - Set to True, to use the default parser that converts to Agent Framework types. - Set to a callable to use a custom parser function. - Set to None to return the raw MCP tool result. + parse_tool_results: An optional callable with signature + ``Callable[[types.CallToolResult], str]`` that overrides the default result + parsing. When ``None`` (the default), the built-in parser converts MCP types + directly to a string. If you need per-function result parsing, access the + ``.functions`` list after connecting and set ``result_parser`` on individual + ``FunctionTool`` instances. load_prompts: Whether to load prompts from the MCP server. - parse_prompt_results: How to parse prompt results from the MCP server. - Set to True, to use the default parser that converts to Agent Framework types. - Set to a callable to use a custom parser function. - Set to None to return the raw MCP prompt result. + parse_prompt_results: An optional callable with signature + ``Callable[[types.GetPromptResult], str]`` that overrides the default prompt + result parsing. When ``None`` (the default), the built-in parser converts + MCP prompt results to a string. If you need per-function result parsing, + access the ``.functions`` list after connecting and set ``result_parser`` on + individual ``FunctionTool`` instances. request_timeout: The default timeout in seconds for all requests. session: The session to use for the MCP connection. description: The description of the tool. @@ -1213,7 +1336,7 @@ class MCPWebsocketTool(MCPTool): A tool should not be listed in both, if so, it will require approval. allowed_tools: A list of tools that are allowed to use this tool. additional_properties: Additional properties. - chat_client: The chat client to use for sampling. + client: The chat client to use for sampling. kwargs: Any extra arguments to pass to the WebSocket client. """ super().__init__( @@ -1223,7 +1346,7 @@ class MCPWebsocketTool(MCPTool): allowed_tools=allowed_tools, additional_properties=additional_properties, session=session, - chat_client=chat_client, + client=client, load_tools=load_tools, parse_tool_results=parse_tool_results, load_prompts=load_prompts, diff --git a/python/packages/core/agent_framework/_memory.py b/python/packages/core/agent_framework/_memory.py deleted file mode 100644 index 465bc1ffec..0000000000 --- a/python/packages/core/agent_framework/_memory.py +++ /dev/null @@ -1,181 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import sys -from abc import ABC, abstractmethod -from collections.abc import MutableSequence, Sequence -from types import TracebackType -from typing import TYPE_CHECKING, Any, Final - -from ._types import ChatMessage - -if TYPE_CHECKING: - from ._tools import ToolProtocol - -if sys.version_info >= (3, 11): - from typing import Self # pragma: no cover -else: - from typing_extensions import Self # pragma: no cover - -# region Context - -__all__ = ["Context", "ContextProvider"] - - -class Context: - """A class containing any context that should be provided to the AI model as supplied by a ContextProvider. - - Each ContextProvider has the ability to provide its own context for each invocation. - The Context class contains the additional context supplied by the ContextProvider. - This context will be combined with context supplied by other providers before being passed to the AI model. - This context is per invocation, and will not be stored as part of the chat history. - - Examples: - .. code-block:: python - - from agent_framework import Context, ChatMessage - - # Create context with instructions - context = Context( - instructions="Use a professional tone when responding.", - messages=[ChatMessage(content="Previous context", role="user")], - tools=[my_tool], - ) - - # Access context properties - print(context.instructions) - print(len(context.messages)) - """ - - def __init__( - self, - instructions: str | None = None, - messages: Sequence[ChatMessage] | None = None, - tools: Sequence[ToolProtocol] | None = None, - ): - """Create a new Context object. - - Args: - instructions: The instructions to provide to the AI model. - messages: The list of messages to include in the context. - tools: The list of tools to provide to this run. - """ - self.instructions = instructions - self.messages: Sequence[ChatMessage] = messages or [] - self.tools: Sequence[ToolProtocol] = tools or [] - - -# region ContextProvider - - -class ContextProvider(ABC): - """Base class for all context providers. - - A context provider is a component that can be used to enhance the AI's context management. - It can listen to changes in the conversation and provide additional context to the AI model - just before invocation. - - Note: - ContextProvider is an abstract base class. You must subclass it and implement - the ``invoking()`` method to create a custom context provider. Ideally, you should - also implement the ``invoked()`` and ``thread_created()`` methods to track conversation - state, but these are optional. - - Examples: - .. code-block:: python - - from agent_framework import ContextProvider, Context, ChatMessage - - - class CustomContextProvider(ContextProvider): - async def invoking(self, messages, **kwargs): - # Add custom instructions before each invocation - return Context(instructions="Always be concise and helpful.", messages=[], tools=[]) - - - # Use with a chat agent - async with CustomContextProvider() as provider: - agent = ChatAgent(chat_client=client, name="assistant", context_provider=provider) - """ - - # Default prompt to be used by all context providers when assembling memories/instructions - DEFAULT_CONTEXT_PROMPT: Final[str] = "## Memories\nConsider the following memories when answering user questions:" - - async def thread_created(self, thread_id: str | None) -> None: - """Called just after a new thread is created. - - Implementers can use this method to perform any operations required at the creation - of a new thread. For example, checking long-term storage for any data that is relevant - to the current session. - - Args: - thread_id: The ID of the new thread. - """ - pass - - async def invoked( - self, - request_messages: ChatMessage | Sequence[ChatMessage], - response_messages: ChatMessage | Sequence[ChatMessage] | None = None, - invoke_exception: Exception | None = None, - **kwargs: Any, - ) -> None: - """Called after the agent has received a response from the underlying inference service. - - You can inspect the request and response messages, and update the state of the context provider. - - Args: - request_messages: The messages that were sent to the model/agent. - response_messages: The messages that were returned by the model/agent. - invoke_exception: The exception that was thrown, if any. - - Keyword Args: - kwargs: Additional keyword arguments (not used at present). - """ - pass - - @abstractmethod - async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context: - """Called just before the model/agent is invoked. - - Implementers can load any additional context required at this time, - and they should return any context that should be passed to the agent. - - Args: - messages: The most recent messages that the agent is being invoked with. - - Keyword Args: - kwargs: Additional keyword arguments (not used at present). - - Returns: - A Context object containing instructions, messages, and tools to include. - """ - pass - - async def __aenter__(self) -> Self: - """Enter the async context manager. - - Override this method to perform any setup operations when the context provider is entered. - - Returns: - The ContextProvider instance for chaining. - """ - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - """Exit the async context manager. - - Override this method to perform any cleanup operations when the context provider is exited. - - Args: - exc_type: The exception type if an exception occurred, None otherwise. - exc_val: The exception value if an exception occurred, None otherwise. - exc_tb: The exception traceback if an exception occurred, None otherwise. - """ - pass diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index 48f762cdaa..969ef1efc9 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -10,13 +10,13 @@ from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequenc from enum import Enum from typing import TYPE_CHECKING, Any, Generic, Literal, TypeAlias, overload -from ._clients import ChatClientProtocol +from ._clients import SupportsChatGetResponse from ._types import ( AgentResponse, AgentResponseUpdate, - ChatMessage, ChatResponse, ChatResponseUpdate, + Message, ResponseStream, prepare_messages, ) @@ -35,12 +35,12 @@ if TYPE_CHECKING: from pydantic import BaseModel from ._agents import SupportsAgentRun - from ._clients import ChatClientProtocol - from ._threads import AgentThread + from ._clients import SupportsChatGetResponse + from ._sessions import AgentSession from ._tools import FunctionTool from ._types import ChatOptions, ChatResponse, ChatResponseUpdate - TResponseModelT = TypeVar("TResponseModelT", bound=BaseModel) + ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) __all__ = [ "AgentContext", @@ -65,21 +65,21 @@ __all__ = [ ] AgentT = TypeVar("AgentT", bound="SupportsAgentRun") -TContext = TypeVar("TContext") -TUpdate = TypeVar("TUpdate") +ContextT = TypeVar("ContextT") +UpdateT = TypeVar("UpdateT") -class _EmptyAsyncIterator(Generic[TUpdate]): +class _EmptyAsyncIterator(Generic[UpdateT]): """Empty async iterator that yields nothing. Used when middleware terminates without setting a result, and we need to provide an empty stream. """ - def __aiter__(self) -> _EmptyAsyncIterator[TUpdate]: + def __aiter__(self) -> _EmptyAsyncIterator[UpdateT]: return self - async def __anext__(self) -> TUpdate: + async def __anext__(self) -> UpdateT: raise StopAsyncIteration @@ -118,7 +118,7 @@ class AgentContext: Attributes: agent: The agent being invoked. messages: The messages being sent to the agent. - thread: The agent thread for this invocation, if any. + session: The agent session for this invocation, if any. options: The options for the agent invocation as a dict. stream: Whether this is a streaming invocation. metadata: Metadata dictionary for sharing data between agent middleware. @@ -138,14 +138,14 @@ class AgentContext: async def process(self, context: AgentContext, call_next): print(f"Agent: {context.agent.name}") print(f"Messages: {len(context.messages)}") - print(f"Thread: {context.thread}") + print(f"Session: {context.session}") print(f"Streaming: {context.stream}") # Store metadata context.metadata["start_time"] = time.time() # Continue execution - await call_next(context) + await call_next() # Access result after execution print(f"Result: {context.result}") @@ -155,8 +155,8 @@ class AgentContext: self, *, agent: SupportsAgentRun, - messages: list[ChatMessage], - thread: AgentThread | None = None, + messages: list[Message], + session: AgentSession | None = None, options: Mapping[str, Any] | None = None, stream: bool = False, metadata: Mapping[str, Any] | None = None, @@ -175,7 +175,7 @@ class AgentContext: Args: agent: The agent being invoked. messages: The messages being sent to the agent. - thread: The agent thread for this invocation, if any. + session: The agent session for this invocation, if any. options: The options for the agent invocation as a dict. stream: Whether this is a streaming invocation. metadata: Metadata dictionary for sharing data between agent middleware. @@ -187,7 +187,7 @@ class AgentContext: """ self.agent = agent self.messages = messages - self.thread = thread + self.session = session self.options = options self.stream = stream self.metadata = metadata if metadata is not None else {} @@ -229,12 +229,12 @@ class FunctionInvocationContext: raise MiddlewareTermination("Validation failed") # Continue execution - await call_next(context) + await call_next() """ def __init__( self, - function: FunctionTool[Any, Any], + function: FunctionTool[Any], arguments: BaseModel, metadata: Mapping[str, Any] | None = None, result: Any = None, @@ -263,7 +263,7 @@ class ChatContext: about the chat request. Attributes: - chat_client: The chat client being invoked. + client: The chat client being invoked. messages: The messages being sent to the chat client. options: The options for the chat request as a dict. stream: Whether this is a streaming invocation. @@ -293,7 +293,7 @@ class ChatContext: context.metadata["input_tokens"] = self.count_tokens(context.messages) # Continue execution - await call_next(context) + await call_next() # Access result and count output tokens if context.result: @@ -302,8 +302,8 @@ class ChatContext: def __init__( self, - chat_client: ChatClientProtocol, - messages: Sequence[ChatMessage], + client: SupportsChatGetResponse, + messages: Sequence[Message], options: Mapping[str, Any] | None, stream: bool = False, metadata: Mapping[str, Any] | None = None, @@ -319,7 +319,7 @@ class ChatContext: """Initialize the ChatContext. Args: - chat_client: The chat client being invoked. + client: The chat client being invoked. messages: The messages being sent to the chat client. options: The options for the chat request as a dict. stream: Whether this is a streaming invocation. @@ -330,7 +330,7 @@ class ChatContext: stream_result_hooks: Result hooks to apply to the finalized streaming response. stream_cleanup_hooks: Cleanup hooks to run after streaming completes. """ - self.chat_client = chat_client + self.client = client self.messages = messages self.options = options self.stream = stream @@ -356,7 +356,7 @@ class AgentMiddleware(ABC): Examples: .. code-block:: python - from agent_framework import AgentMiddleware, AgentContext, ChatAgent + from agent_framework import AgentMiddleware, AgentContext, Agent class RetryMiddleware(AgentMiddleware): @@ -365,21 +365,21 @@ class AgentMiddleware(ABC): async def process(self, context: AgentContext, call_next): for attempt in range(self.max_retries): - await call_next(context) + await call_next() if context.result and not context.result.is_error: break print(f"Retry {attempt + 1}/{self.max_retries}") # Use with an agent - agent = ChatAgent(chat_client=client, name="assistant", middleware=[RetryMiddleware()]) + agent = Agent(client=client, name="assistant", middleware=[RetryMiddleware()]) """ @abstractmethod async def process( self, context: AgentContext, - call_next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """Process an agent invocation. @@ -415,7 +415,7 @@ class FunctionMiddleware(ABC): Examples: .. code-block:: python - from agent_framework import FunctionMiddleware, FunctionInvocationContext, ChatAgent + from agent_framework import FunctionMiddleware, FunctionInvocationContext, Agent class CachingMiddleware(FunctionMiddleware): @@ -431,7 +431,7 @@ class FunctionMiddleware(ABC): raise MiddlewareTermination() # Execute function - await call_next(context) + await call_next() # Cache result if context.result: @@ -439,14 +439,14 @@ class FunctionMiddleware(ABC): # Use with an agent - agent = ChatAgent(chat_client=client, name="assistant", middleware=[CachingMiddleware()]) + agent = Agent(client=client, name="assistant", middleware=[CachingMiddleware()]) """ @abstractmethod async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """Process a function invocation. @@ -479,7 +479,7 @@ class ChatMiddleware(ABC): Examples: .. code-block:: python - from agent_framework import ChatMiddleware, ChatContext, ChatAgent + from agent_framework import ChatMiddleware, ChatContext, Agent class SystemPromptMiddleware(ChatMiddleware): @@ -488,17 +488,17 @@ class ChatMiddleware(ABC): async def process(self, context: ChatContext, call_next): # Add system prompt to messages - from agent_framework import ChatMessage + from agent_framework import Message - context.messages.insert(0, ChatMessage(role="system", text=self.system_prompt)) + context.messages.insert(0, Message(role="system", text=self.system_prompt)) # Continue execution - await call_next(context) + await call_next() # Use with an agent - agent = ChatAgent( - chat_client=client, + agent = Agent( + client=client, name="assistant", middleware=[SystemPromptMiddleware("You are a helpful assistant.")], ) @@ -508,7 +508,7 @@ class ChatMiddleware(ABC): async def process( self, context: ChatContext, - call_next: Callable[[ChatContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """Process a chat client request. @@ -531,15 +531,13 @@ class ChatMiddleware(ABC): # Pure function type definitions for convenience -AgentMiddlewareCallable = Callable[[AgentContext, Callable[[AgentContext], Awaitable[None]]], Awaitable[None]] +AgentMiddlewareCallable = Callable[[AgentContext, Callable[[], Awaitable[None]]], Awaitable[None]] AgentMiddlewareTypes: TypeAlias = AgentMiddleware | AgentMiddlewareCallable -FunctionMiddlewareCallable = Callable[ - [FunctionInvocationContext, Callable[[FunctionInvocationContext], Awaitable[None]]], Awaitable[None] -] +FunctionMiddlewareCallable = Callable[[FunctionInvocationContext, Callable[[], Awaitable[None]]], Awaitable[None]] FunctionMiddlewareTypes: TypeAlias = FunctionMiddleware | FunctionMiddlewareCallable -ChatMiddlewareCallable = Callable[[ChatContext, Callable[[ChatContext], Awaitable[None]]], Awaitable[None]] +ChatMiddlewareCallable = Callable[[ChatContext, Callable[[], Awaitable[None]]], Awaitable[None]] ChatMiddlewareTypes: TypeAlias = ChatMiddleware | ChatMiddlewareCallable ChatAndFunctionMiddlewareTypes: TypeAlias = ( @@ -572,18 +570,18 @@ def agent_middleware(func: AgentMiddlewareCallable) -> AgentMiddlewareCallable: Examples: .. code-block:: python - from agent_framework import agent_middleware, AgentContext, ChatAgent + from agent_framework import agent_middleware, AgentContext, Agent @agent_middleware async def logging_middleware(context: AgentContext, call_next): print(f"Before: {context.agent.name}") - await call_next(context) + await call_next() print(f"After: {context.result}") # Use with an agent - agent = ChatAgent(chat_client=client, name="assistant", middleware=[logging_middleware]) + agent = Agent(client=client, name="assistant", middleware=[logging_middleware]) """ # Add marker attribute to identify this as agent middleware func._middleware_type: MiddlewareType = MiddlewareType.AGENT # type: ignore @@ -605,18 +603,18 @@ def function_middleware(func: FunctionMiddlewareCallable) -> FunctionMiddlewareC Examples: .. code-block:: python - from agent_framework import function_middleware, FunctionInvocationContext, ChatAgent + from agent_framework import function_middleware, FunctionInvocationContext, Agent @function_middleware async def logging_middleware(context: FunctionInvocationContext, call_next): print(f"Calling: {context.function.name}") - await call_next(context) + await call_next() print(f"Result: {context.result}") # Use with an agent - agent = ChatAgent(chat_client=client, name="assistant", middleware=[logging_middleware]) + agent = Agent(client=client, name="assistant", middleware=[logging_middleware]) """ # Add marker attribute to identify this as function middleware func._middleware_type: MiddlewareType = MiddlewareType.FUNCTION # type: ignore @@ -638,38 +636,38 @@ def chat_middleware(func: ChatMiddlewareCallable) -> ChatMiddlewareCallable: Examples: .. code-block:: python - from agent_framework import chat_middleware, ChatContext, ChatAgent + from agent_framework import chat_middleware, ChatContext, Agent @chat_middleware async def logging_middleware(context: ChatContext, call_next): print(f"Messages: {len(context.messages)}") - await call_next(context) + await call_next() print(f"Response: {context.result}") # Use with an agent - agent = ChatAgent(chat_client=client, name="assistant", middleware=[logging_middleware]) + agent = Agent(client=client, name="assistant", middleware=[logging_middleware]) """ # Add marker attribute to identify this as chat middleware func._middleware_type: MiddlewareType = MiddlewareType.CHAT # type: ignore return func -class MiddlewareWrapper(Generic[TContext]): +class MiddlewareWrapper(Generic[ContextT]): """Generic wrapper to convert pure functions into middleware protocol objects. This wrapper allows function-based middleware to be used alongside class-based middleware by providing a unified interface. Type Parameters: - TContext: The type of context object this middleware operates on. + ContextT: The type of context object this middleware operates on. """ - def __init__(self, func: Callable[[TContext, Callable[[TContext], Awaitable[None]]], Awaitable[None]]) -> None: + def __init__(self, func: Callable[[ContextT, Callable[[], Awaitable[None]]], Awaitable[None]]) -> None: self.func = func - async def process(self, context: TContext, call_next: Callable[[TContext], Awaitable[None]]) -> None: + async def process(self, context: ContextT, call_next: Callable[[], Awaitable[None]]) -> None: await self.func(context, call_next) @@ -772,25 +770,25 @@ class AgentMiddlewarePipeline(BaseMiddlewarePipeline): context.result = await context.result return context.result - def create_next_handler(index: int) -> Callable[[AgentContext], Awaitable[None]]: + def create_next_handler(index: int) -> Callable[[], Awaitable[None]]: if index >= len(self._middleware): - async def final_wrapper(c: AgentContext) -> None: - c.result = final_handler(c) # type: ignore[assignment] - if inspect.isawaitable(c.result): - c.result = await c.result + async def final_wrapper() -> None: + context.result = final_handler(context) # type: ignore[assignment] + if inspect.isawaitable(context.result): + context.result = await context.result return final_wrapper - async def current_handler(c: AgentContext) -> None: + async def current_handler() -> None: # MiddlewareTermination bubbles up to execute() to skip post-processing - await self._middleware[index].process(c, create_next_handler(index + 1)) + await self._middleware[index].process(context, create_next_handler(index + 1)) return current_handler first_handler = create_next_handler(0) with contextlib.suppress(MiddlewareTermination): - await first_handler(context) + await first_handler() if context.result and isinstance(context.result, ResponseStream): for hook in context.stream_transform_hooks: @@ -847,25 +845,25 @@ class FunctionMiddlewarePipeline(BaseMiddlewarePipeline): if not self._middleware: return await final_handler(context) - def create_next_handler(index: int) -> Callable[[FunctionInvocationContext], Awaitable[None]]: + def create_next_handler(index: int) -> Callable[[], Awaitable[None]]: if index >= len(self._middleware): - async def final_wrapper(c: FunctionInvocationContext) -> None: - c.result = final_handler(c) - if inspect.isawaitable(c.result): - c.result = await c.result + async def final_wrapper() -> None: + context.result = final_handler(context) + if inspect.isawaitable(context.result): + context.result = await context.result return final_wrapper - async def current_handler(c: FunctionInvocationContext) -> None: + async def current_handler() -> None: # MiddlewareTermination bubbles up to execute() to skip post-processing - await self._middleware[index].process(c, create_next_handler(index + 1)) + await self._middleware[index].process(context, create_next_handler(index + 1)) return current_handler first_handler = create_next_handler(0) # Don't suppress MiddlewareTermination - let it propagate to signal loop termination - await first_handler(context) + await first_handler() return context.result @@ -922,25 +920,25 @@ class ChatMiddlewarePipeline(BaseMiddlewarePipeline): raise ValueError("Streaming agent middleware requires a ResponseStream result.") return context.result - def create_next_handler(index: int) -> Callable[[ChatContext], Awaitable[None]]: + def create_next_handler(index: int) -> Callable[[], Awaitable[None]]: if index >= len(self._middleware): - async def final_wrapper(c: ChatContext) -> None: - c.result = final_handler(c) # type: ignore[assignment] - if inspect.isawaitable(c.result): - c.result = await c.result + async def final_wrapper() -> None: + context.result = final_handler(context) # type: ignore[assignment] + if inspect.isawaitable(context.result): + context.result = await context.result return final_wrapper - async def current_handler(c: ChatContext) -> None: + async def current_handler() -> None: # MiddlewareTermination bubbles up to execute() to skip post-processing - await self._middleware[index].process(c, create_next_handler(index + 1)) + await self._middleware[index].process(context, create_next_handler(index + 1)) return current_handler first_handler = create_next_handler(0) with contextlib.suppress(MiddlewareTermination): - await first_handler(context) + await first_handler() if context.result and isinstance(context.result, ResponseStream): for hook in context.stream_transform_hooks: @@ -953,15 +951,15 @@ class ChatMiddlewarePipeline(BaseMiddlewarePipeline): # Covariant for chat client options -TOptions_co = TypeVar( - "TOptions_co", +OptionsCoT = TypeVar( + "OptionsCoT", bound=TypedDict, # type: ignore[valid-type] default="ChatOptions[None]", covariant=True, ) -class ChatMiddlewareLayer(Generic[TOptions_co]): +class ChatMiddlewareLayer(Generic[OptionsCoT]): """Layer for chat clients to apply chat middleware around response generation.""" def __init__( @@ -980,39 +978,39 @@ class ChatMiddlewareLayer(Generic[TOptions_co]): @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[False] = ..., - options: ChatOptions[TResponseModelT], + options: ChatOptions[ResponseModelBoundT], **kwargs: Any, - ) -> Awaitable[ChatResponse[TResponseModelT]]: ... + ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[False] = ..., - options: TOptions_co | ChatOptions[None] | None = None, + options: OptionsCoT | ChatOptions[None] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[True], - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: bool = False, - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Execute the chat pipeline if middleware is configured.""" @@ -1035,7 +1033,7 @@ class ChatMiddlewareLayer(Generic[TOptions_co]): ) context = ChatContext( - chat_client=self, # type: ignore[arg-type] + client=self, # type: ignore[arg-type] messages=prepare_messages(messages), options=options, stream=stream, @@ -1090,29 +1088,29 @@ class AgentMiddlewareLayer: self.agent_middleware = middleware_list["agent"] # Pass middleware to super so BaseAgent can store it for dynamic rebuild super().__init__(*args, middleware=middleware, **kwargs) # type: ignore[call-arg] - # Note: We intentionally don't extend chat_client's middleware lists here. + # Note: We intentionally don't extend client's middleware lists here. # Chat and function middleware is passed to the chat client at runtime via kwargs # in AgentMiddlewareLayer.run(), where it's properly combined with run-level middleware. @overload def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[False] = ..., - thread: AgentThread | None = None, + session: AgentSession | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - options: ChatOptions[TResponseModelT], + options: ChatOptions[ResponseModelBoundT], **kwargs: Any, - ) -> Awaitable[AgentResponse[TResponseModelT]]: ... + ) -> Awaitable[AgentResponse[ResponseModelBoundT]]: ... @overload def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[False] = ..., - thread: AgentThread | None = None, + session: AgentSession | None = None, middleware: Sequence[MiddlewareTypes] | None = None, options: ChatOptions[None] | None = None, **kwargs: Any, @@ -1121,10 +1119,10 @@ class AgentMiddlewareLayer: @overload def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[True], - thread: AgentThread | None = None, + session: AgentSession | None = None, middleware: Sequence[MiddlewareTypes] | None = None, options: ChatOptions[Any] | None = None, **kwargs: Any, @@ -1132,10 +1130,10 @@ class AgentMiddlewareLayer: def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, middleware: Sequence[MiddlewareTypes] | None = None, options: ChatOptions[Any] | None = None, **kwargs: Any, @@ -1159,12 +1157,12 @@ class AgentMiddlewareLayer: # Execute with middleware if available if not pipeline.has_middlewares: - return super().run(messages, stream=stream, thread=thread, options=options, **combined_kwargs) # type: ignore[misc, no-any-return] + return super().run(messages, stream=stream, session=session, options=options, **combined_kwargs) # type: ignore[misc, no-any-return] context = AgentContext( agent=self, # type: ignore[arg-type] messages=prepare_messages(messages), # type: ignore[arg-type] - thread=thread, + session=session, options=options, stream=stream, kwargs=combined_kwargs, @@ -1199,7 +1197,7 @@ class AgentMiddlewareLayer: return super().run( # type: ignore[misc, no-any-return] context.messages, stream=context.stream, - thread=context.thread, + session=context.session, options=context.options, **context.kwargs, ) diff --git a/python/packages/core/agent_framework/_pydantic.py b/python/packages/core/agent_framework/_pydantic.py deleted file mode 100644 index a54f7b81af..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"] - - -TSettings = TypeVar("TSettings", 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[TSettings], *args: Any, **kwargs: Any) -> TSettings: - """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/_serialization.py b/python/packages/core/agent_framework/_serialization.py index c70f73e1d2..1259ca2a1a 100644 --- a/python/packages/core/agent_framework/_serialization.py +++ b/python/packages/core/agent_framework/_serialization.py @@ -11,8 +11,8 @@ from ._logging import get_logger logger = get_logger() -TClass = TypeVar("TClass", bound="SerializationMixin") -TProtocol = TypeVar("TProtocol", bound="SerializationProtocol") +ClassT = TypeVar("ClassT", bound="SerializationMixin") +ProtocolT = TypeVar("ProtocolT", bound="SerializationProtocol") # Regex pattern for converting CamelCase to snake_case _CAMEL_TO_SNAKE_PATTERN = re.compile(r"(? TProtocol: + def from_dict(cls: type[ProtocolT], value: MutableMapping[str, Any], /, **kwargs: Any) -> ProtocolT: """Create an instance from a dictionary. Args: @@ -166,45 +166,22 @@ class SerializationMixin: during deserialization via the ``dependencies`` parameter. Examples: - **Nested object serialization with agent thread management:** + **Nested object serialization:** .. code-block:: python - from agent_framework import ChatMessage - from agent_framework._threads import AgentThreadState, ChatMessageStoreState + from agent_framework import Message + from agent_framework._sessions import AgentSession - # ChatMessageStoreState handles nested ChatMessage serialization - store_state = ChatMessageStoreState( - messages=[ - ChatMessage(role="user", text="Hello agent"), - ChatMessage(role="assistant", text="Hi! How can I help?"), - ] - ) + # AgentSession uses SerializationMixin for state serialization + session = AgentSession(session_id="test") - # Nested serialization: messages are automatically converted to dicts - store_dict = store_state.to_dict() - # Result: { - # "type": "chat_message_store_state", - # "messages": [ - # {"type": "chat_message", "role": {...}, "contents": [...]}, - # {"type": "chat_message", "role": {...}, "contents": [...]} - # ] - # } + # Serialization produces a clean dict representation + session_dict = session.to_dict() - # AgentThreadState contains nested ChatMessageStoreState - thread_state = AgentThreadState(chat_message_store_state=store_state) - - # Deep serialization: nested SerializationMixin objects are handled automatically - thread_dict = thread_state.to_dict() - # The chat_message_store_state and its nested messages are all serialized - - # Reconstruction from nested dictionaries with automatic type conversion - # The __init__ method handles MutableMapping -> object conversion: - reconstructed = AgentThreadState.from_dict({ - "chat_message_store_state": {"messages": [{"role": "user", "text": "Hello again"}]} - }) - # chat_message_store_state becomes ChatMessageStoreState instance automatically + # Reconstruction from dictionaries + restored = AgentSession.from_dict(session_dict) **Framework tools with exclusion patterns:** @@ -392,8 +369,8 @@ class SerializationMixin: @classmethod def from_dict( - cls: type[TClass], value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None - ) -> TClass: + cls: type[ClassT], value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None + ) -> ClassT: """Create an instance from a dictionary with optional dependency injection. This method reconstructs an object from its dictionary representation, automatically @@ -443,7 +420,7 @@ class SerializationMixin: dependencies = {"open_ai_chat_client": {"client": openai_client}} # The chat client is reconstructed with the OpenAI client injected - chat_client = OpenAIChatClient.from_dict(client_data, dependencies=dependencies) + client = OpenAIChatClient.from_dict(client_data, dependencies=dependencies) # Now ready to make API calls with the injected client **Function Injection for Tools** - FunctionTool runtime dependency: @@ -560,7 +537,7 @@ class SerializationMixin: return cls(**kwargs) @classmethod - def from_json(cls: type[TClass], value: str, /, *, dependencies: MutableMapping[str, Any] | None = None) -> TClass: + def from_json(cls: type[ClassT], value: str, /, *, dependencies: MutableMapping[str, Any] | None = None) -> ClassT: """Create an instance from a JSON string. This is a convenience method that parses the JSON string using ``json.loads()`` diff --git a/python/packages/core/agent_framework/_sessions.py b/python/packages/core/agent_framework/_sessions.py new file mode 100644 index 0000000000..6240c632c2 --- /dev/null +++ b/python/packages/core/agent_framework/_sessions.py @@ -0,0 +1,566 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unified context management types for the agent framework. + +This module provides the core types for the context provider pipeline: +- SessionContext: Per-invocation state passed through providers +- BaseContextProvider: Base class for context providers (renamed to ContextProvider in PR2) +- BaseHistoryProvider: Base class for history storage providers (renamed to HistoryProvider in PR2) +- AgentSession: Lightweight session state container +- InMemoryHistoryProvider: Built-in in-memory history provider +""" + +from __future__ import annotations + +import copy +import uuid +from abc import abstractmethod +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from ._types import AgentResponse, Message + +if TYPE_CHECKING: + from ._agents import SupportsAgentRun + + +__all__ = [ + "AgentSession", + "BaseContextProvider", + "BaseHistoryProvider", + "InMemoryHistoryProvider", + "SessionContext", + "register_state_type", +] + + +# Registry of known types for state deserialization +_STATE_TYPE_REGISTRY: dict[str, type] = {} + + +def register_state_type(cls: type) -> None: + """Register a type for automatic deserialization in session state. + + Call this for any custom type (including Pydantic models) that you store + in ``session.state`` and want to survive ``to_dict()`` / ``from_dict()`` + round-trips. Types with ``to_dict``/``from_dict`` methods or Pydantic + ``BaseModel`` subclasses are handled automatically. + + The type identifier defaults to ``cls.__name__.lower()`` but can be + overridden by defining a ``_get_type_identifier`` classmethod. + + Note: + Pydantic models are auto-registered on first serialization, but + pre-registering ensures deserialization works even if the model + hasn't been serialized in this process yet (e.g. cold-start restore). + + Args: + cls: The type to register. + """ + type_id: str = getattr(cls, "_get_type_identifier", lambda: cls.__name__.lower())() + _STATE_TYPE_REGISTRY[type_id] = cls + + +# Keep internal alias for framework use +_register_state_type = register_state_type + + +def _serialize_value(value: Any) -> Any: + """Serialize a single value, handling objects with to_dict() and Pydantic models.""" + if hasattr(value, "to_dict") and callable(value.to_dict): + return value.to_dict() # pyright: ignore[reportUnknownMemberType] + # Pydantic BaseModel support — import lazily to avoid hard dep at module level + try: + from pydantic import BaseModel + + if isinstance(value, BaseModel): + data = value.model_dump() + type_id: str = getattr(value.__class__, "_get_type_identifier", lambda: value.__class__.__name__.lower())() + data["type"] = type_id + # Auto-register for round-trip deserialization + _STATE_TYPE_REGISTRY.setdefault(type_id, value.__class__) + return data + except ImportError: + pass + if isinstance(value, list): + return [_serialize_value(item) for item in value] # pyright: ignore[reportUnknownVariableType] + if isinstance(value, dict): + return {str(k): _serialize_value(v) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] + return value + + +def _deserialize_value(value: Any) -> Any: + """Deserialize a single value, restoring registered types.""" + if isinstance(value, dict) and "type" in value: + type_id = str(value["type"]) # pyright: ignore[reportUnknownArgumentType] + cls = _STATE_TYPE_REGISTRY.get(type_id) + if cls is not None: + if hasattr(cls, "from_dict"): + return cls.from_dict(value) # type: ignore[union-attr] + # Pydantic BaseModel support + try: + from pydantic import BaseModel + + if issubclass(cls, BaseModel): + data = {k: v for k, v in value.items() if k != "type"} + return cls.model_validate(data) + except ImportError: + pass + if isinstance(value, list): + return [_deserialize_value(item) for item in value] # pyright: ignore[reportUnknownVariableType] + if isinstance(value, dict): + return {str(k): _deserialize_value(v) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] + return value + + +def _serialize_state(state: dict[str, Any]) -> dict[str, Any]: + """Deep-serialize a state dict, converting SerializationProtocol objects to dicts.""" + return {k: _serialize_value(v) for k, v in state.items()} + + +def _deserialize_state(state: dict[str, Any]) -> dict[str, Any]: + """Deep-deserialize a state dict, restoring SerializationProtocol objects.""" + return {k: _deserialize_value(v) for k, v in state.items()} + + +# Register known types +_register_state_type(Message) + + +class SessionContext: + """Per-invocation state passed through the context provider pipeline. + + Created fresh for each agent.run() call. Providers read from and write to + the mutable fields to add context before invocation and process responses after. + + Attributes: + session_id: The ID of the current session. + service_session_id: Service-managed session ID (if present, service handles storage). + input_messages: The new messages being sent to the agent (set by caller). + context_messages: Dict mapping source_id -> messages added by that provider. + Maintains insertion order (provider execution order). + instructions: Additional instructions added by providers. + tools: Additional tools added by providers. + response: After invocation, contains the full AgentResponse, should not be changed. + options: Options passed to agent.run() - read-only, for reflection only. + metadata: Shared metadata dictionary for cross-provider communication. + """ + + def __init__( + self, + *, + session_id: str | None = None, + service_session_id: str | None = None, + input_messages: list[Message], + context_messages: dict[str, list[Message]] | None = None, + instructions: list[str] | None = None, + tools: list[Any] | None = None, + options: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, + ): + """Initialize the session context. + + Args: + session_id: The ID of the current session. + service_session_id: Service-managed session ID. + input_messages: The new messages being sent to the agent. + context_messages: Pre-populated context messages by source. + instructions: Pre-populated instructions. + tools: Pre-populated tools. + options: Options from agent.run() - read-only for providers. + metadata: Shared metadata for cross-provider communication. + """ + self.session_id = session_id + self.service_session_id = service_session_id + self.input_messages = input_messages + self.context_messages: dict[str, list[Message]] = context_messages or {} + self.instructions: list[str] = instructions or [] + self.tools: list[Any] = tools or [] + self._response: AgentResponse | None = None + self.options: dict[str, Any] = options or {} + self.metadata: dict[str, Any] = metadata or {} + + @property + def response(self) -> AgentResponse | None: + """The agent's response. Set by the framework after invocation, read-only for providers.""" + return self._response + + def extend_messages(self, source: str | object, messages: Sequence[Message]) -> None: + """Add context messages from a specific source. + + Messages are copied before attribution is added, so the caller's + original message objects are never mutated. The copies are stored + keyed by source_id, maintaining insertion order based on provider + execution order. Each message gets an ``attribution`` marker in + ``additional_properties`` for downstream filtering. + + Args: + source: Either a plain ``source_id`` string, or an object with a + ``source_id`` attribute (e.g. a context provider). When an + object is passed, its class name is recorded as + ``source_type`` in the attribution. + messages: The messages to add. + """ + if isinstance(source, str): + source_id = source + attribution: dict[str, str] = {"source_id": source_id} + else: + source_id = source.source_id # type: ignore[attr-defined] + attribution = {"source_id": source_id, "source_type": type(source).__name__} + + copied: list[Message] = [] + for message in messages: + msg_copy = copy.copy(message) + msg_copy.additional_properties = dict(message.additional_properties) + msg_copy.additional_properties.setdefault("_attribution", attribution) + copied.append(msg_copy) + if source_id not in self.context_messages: + self.context_messages[source_id] = [] + self.context_messages[source_id].extend(copied) + + def extend_instructions(self, source_id: str, instructions: str | Sequence[str]) -> None: + """Add instructions to be prepended to the conversation. + + Args: + source_id: The provider source_id adding these instructions. + instructions: A single instruction string or sequence of strings. + """ + if isinstance(instructions, str): + instructions = [instructions] + self.instructions.extend(instructions) + + def extend_tools(self, source_id: str, tools: Sequence[Any]) -> None: + """Add tools to be available for this invocation. + + Tools are added with source attribution in their metadata. + + Args: + source_id: The provider source_id adding these tools. + tools: The tools to add. + """ + for tool in tools: + if hasattr(tool, "additional_properties") and isinstance(tool.additional_properties, dict): + tool.additional_properties["context_source"] = source_id + self.tools.extend(tools) + + def get_messages( + self, + *, + sources: set[str] | None = None, + exclude_sources: set[str] | None = None, + include_input: bool = False, + include_response: bool = False, + ) -> list[Message]: + """Get context messages, optionally filtered and including input/response. + + Returns messages in provider execution order (dict insertion order), + with input and response appended if requested. + + Args: + sources: If provided, only include context messages from these sources. + exclude_sources: If provided, exclude context messages from these sources. + include_input: If True, append input_messages after context. + include_response: If True, append response.messages at the end. + + Returns: + Flattened list of messages in conversation order. + """ + result: list[Message] = [] + for source_id, messages in self.context_messages.items(): + if sources is not None and source_id not in sources: + continue + if exclude_sources is not None and source_id in exclude_sources: + continue + result.extend(messages) + if include_input and self.input_messages: + result.extend(self.input_messages) + if include_response and self.response and self.response.messages: + result.extend(self.response.messages) + return result + + +class BaseContextProvider: + """Base class for context providers (hooks pattern). + + Context providers participate in the context engineering pipeline, + adding context before model invocation and processing responses after. + + Note: + This class uses a temporary name prefixed with ``_`` to avoid collision + with the existing ``ContextProvider`` in ``_memory.py``. It will be + renamed to ``ContextProvider`` in PR2 when the old class is removed. + + Attributes: + source_id: Unique identifier for this provider instance (required). + Used for message/tool attribution so other providers can filter. + """ + + def __init__(self, source_id: str): + """Initialize the provider. + + Args: + source_id: Unique identifier for this provider instance. + """ + self.source_id = source_id + + async def before_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Called before model invocation. + + Override to add context (messages, instructions, tools) to the + SessionContext before the model is invoked. + + Args: + agent: The agent running this invocation. + session: The current session. + context: The invocation context - add messages/instructions/tools here. + state: The session's mutable state dict. + """ + + async def after_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Called after model invocation. + + Override to process the response (store messages, extract info, etc.). + The context.response will be populated at this point. + + Args: + agent: The agent that ran this invocation. + session: The current session. + context: The invocation context with response populated. + state: The session's mutable state dict. + """ + + +class BaseHistoryProvider(BaseContextProvider): + """Base class for conversation history storage providers. + + A single class configurable for different use cases: + - Primary memory storage (loads + stores messages) + - Audit/logging storage (stores only, doesn't load) + - Evaluation storage (stores only for later analysis) + + Note: + This class uses a temporary name prefixed with ``_`` to avoid collision + with existing types. It will be renamed to ``HistoryProvider`` in PR2. + + Subclasses only need to implement ``get_messages()`` and ``save_messages()``. + The default ``before_run``/``after_run`` handle loading and storing based on + configuration flags. Override them for custom behavior. + + Attributes: + load_messages: Whether to load messages before invocation (default True). + When False, the agent skips calling ``before_run`` entirely. + store_inputs: Whether to store input messages (default True). + store_context_messages: Whether to store context from other providers (default False). + store_context_from: If set, only store context from these source_ids. + store_outputs: Whether to store response messages (default True). + """ + + def __init__( + self, + source_id: str, + *, + load_messages: bool = True, + store_inputs: bool = True, + store_context_messages: bool = False, + store_context_from: set[str] | None = None, + store_outputs: bool = True, + ): + """Initialize the history provider. + + Args: + source_id: Unique identifier for this provider instance. + load_messages: Whether to load messages before invocation. + store_inputs: Whether to store input messages. + store_context_messages: Whether to store context from other providers. + store_context_from: If set, only store context from these source_ids. + store_outputs: Whether to store response messages. + """ + super().__init__(source_id) + self.load_messages = load_messages + self.store_inputs = store_inputs + self.store_context_messages = store_context_messages + self.store_context_from = store_context_from + self.store_outputs = store_outputs + + @abstractmethod + async def get_messages(self, session_id: str | None, **kwargs: Any) -> list[Message]: + """Retrieve stored messages for this session. + + Args: + session_id: The session ID to retrieve messages for. + **kwargs: Additional arguments (e.g., ``state`` for in-memory providers). + + Returns: + List of stored messages. + """ + ... + + @abstractmethod + async def save_messages(self, session_id: str | None, messages: Sequence[Message], **kwargs: Any) -> None: + """Persist messages for this session. + + Args: + session_id: The session ID to store messages for. + messages: The messages to persist. + **kwargs: Additional arguments (e.g., ``state`` for in-memory providers). + """ + ... + + def _get_context_messages_to_store(self, context: SessionContext) -> list[Message]: + """Get context messages that should be stored based on configuration.""" + if not self.store_context_messages: + return [] + if self.store_context_from is not None: + return context.get_messages(sources=self.store_context_from) + return context.get_messages(exclude_sources={self.source_id}) + + async def before_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Load history into context. Skipped by the agent when load_messages=False.""" + history = await self.get_messages(context.session_id, state=state) + context.extend_messages(self, history) + + async def after_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Store messages based on configuration.""" + messages_to_store: list[Message] = [] + messages_to_store.extend(self._get_context_messages_to_store(context)) + if self.store_inputs: + messages_to_store.extend(context.input_messages) + if self.store_outputs and context.response and context.response.messages: + messages_to_store.extend(context.response.messages) + if messages_to_store: + await self.save_messages(context.session_id, messages_to_store, state=state) + + +class AgentSession: + """A conversation session with an agent. + + Lightweight state container. Provider instances are owned by the agent, + not the session. The session only holds session IDs and a mutable state dict. + + Attributes: + session_id: Unique identifier for this session. + service_session_id: Service-managed session ID (if using service-side storage). + state: Mutable state dict shared with all providers. + """ + + def __init__( + self, + *, + session_id: str | None = None, + service_session_id: str | None = None, + ): + """Initialize the session. + + Args: + session_id: Optional session ID (generated if not provided). + service_session_id: Optional service-managed session ID. + """ + self._session_id = session_id or str(uuid.uuid4()) + self.service_session_id = service_session_id + self.state: dict[str, Any] = {} + + @property + def session_id(self) -> str: + """The unique identifier for this session.""" + return self._session_id + + def to_dict(self) -> dict[str, Any]: + """Serialize session to a plain dict for storage/transfer. + + Values in ``state`` that implement ``SerializationProtocol`` (i.e. have + ``to_dict``/``from_dict``) are serialized automatically. Built-in types + (str, int, float, bool, None, list, dict) are kept as-is. + """ + return { + "type": "session", + "session_id": self._session_id, + "service_session_id": self.service_session_id, + "state": _serialize_state(self.state), + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> AgentSession: + """Restore session from a previously serialized dict. + + Values in ``state`` that were serialized via ``SerializationProtocol`` + (containing a ``type`` key) are restored to their original types. + + Args: + data: Dict from a previous ``to_dict()`` call. + + Returns: + Restored AgentSession instance. + """ + session = cls( + session_id=data["session_id"], + service_session_id=data.get("service_session_id"), + ) + session.state = _deserialize_state(data.get("state", {})) + return session + + +class InMemoryHistoryProvider(BaseHistoryProvider): + """Built-in history provider that stores messages in session.state. + + Messages are stored in ``state[source_id]["messages"]`` as a list of + ``Message`` objects. Serialization to/from dicts is handled by + ``AgentSession.to_dict()``/``from_dict()`` using ``SerializationProtocol``. + + This provider holds no instance state — all data lives in the session's + state dict, passed as a named ``state`` parameter to ``get_messages``/``save_messages``. + + This is the default provider auto-added by the agent when no providers + are configured and ``conversation_id`` or ``store=True`` is set. + """ + + async def get_messages( + self, session_id: str | None, *, state: dict[str, Any] | None = None, **kwargs: Any + ) -> list[Message]: + """Retrieve messages from session state.""" + if state is None: + return [] + my_state = state.get(self.source_id, {}) + return list(my_state.get("messages", [])) + + async def save_messages( + self, + session_id: str | None, + messages: Sequence[Message], + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Persist messages to session state.""" + if state is None: + return + my_state = state.setdefault(self.source_id, {}) + existing = my_state.get("messages", []) + my_state["messages"] = [*existing, *messages] diff --git a/python/packages/core/agent_framework/_settings.py b/python/packages/core/agent_framework/_settings.py new file mode 100644 index 0000000000..30fd8c8508 --- /dev/null +++ b/python/packages/core/agent_framework/_settings.py @@ -0,0 +1,280 @@ +# 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 + source_a: str | None + source_b: str | None + + + # Make model_id required; require exactly one of source_a / source_b: + settings = load_settings( + MySettings, + env_prefix="MY_APP_", + required_fields=["model_id", ("source_a", "source_b")], + model_id="gpt-4", + source_a="value", + ) + 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 | tuple[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. + + Entries in *required_fields* are validated after resolution: + + - A **string** entry means the field must resolve to a non-``None`` value. + - A **tuple** entry means exactly one field in the group must be non-``None`` + (mutually exclusive). + + 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 (``str``) that must resolve to a non-``None`` + value, or tuples of field names where exactly one must be set. + **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, or if a mutually exclusive constraint is violated. + 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) + + 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_fields: + for entry in required_fields: + if isinstance(entry, str): + # Single required field + if result.get(entry) is None: + env_var_name = f"{env_prefix}{entry.upper()}" + raise SettingNotFoundError( + f"Required setting '{entry}' was not provided. " + f"Set it via the '{entry}' parameter or the " + f"'{env_var_name}' environment variable." + ) + else: + # Mutually exclusive group — exactly one must be set + set_fields = [f for f in entry if result.get(f) is not None] + if len(set_fields) == 0: + names = ", ".join(f"'{f}'" for f in entry) + raise SettingNotFoundError(f"Exactly one of {names} must be provided, but none was set.") + if len(set_fields) > 1: + all_names = ", ".join(f"'{f}'" for f in entry) + set_names = ", ".join(f"'{f}'" for f in set_fields) + raise SettingNotFoundError( + f"Only one of {all_names} may be provided, but multiple were set: {set_names}." + ) + + return result # type: ignore[return-value] diff --git a/python/packages/core/agent_framework/_threads.py b/python/packages/core/agent_framework/_threads.py deleted file mode 100644 index 74462d3a90..0000000000 --- a/python/packages/core/agent_framework/_threads.py +++ /dev/null @@ -1,507 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -from collections.abc import MutableMapping, Sequence -from typing import Any, Protocol, TypeVar - -from ._memory import ContextProvider -from ._serialization import SerializationMixin -from ._types import ChatMessage -from .exceptions import AgentThreadException - -__all__ = ["AgentThread", "ChatMessageStore", "ChatMessageStoreProtocol"] - - -class ChatMessageStoreProtocol(Protocol): - """Defines methods for storing and retrieving chat messages associated with a specific thread. - - Implementations of this protocol are responsible for managing the storage of chat messages, - including handling large volumes of data by truncating or summarizing messages as necessary. - - Examples: - .. code-block:: python - - from agent_framework import ChatMessage - - - class MyMessageStore: - def __init__(self): - self._messages = [] - - async def list_messages(self) -> list[ChatMessage]: - return self._messages - - async def add_messages(self, messages: Sequence[ChatMessage]) -> None: - self._messages.extend(messages) - - @classmethod - async def deserialize(cls, serialized_store_state, **kwargs): - store = cls() - store._messages = serialized_store_state.get("messages", []) - return store - - async def update_from_state(self, serialized_store_state, **kwargs) -> None: - self._messages = serialized_store_state.get("messages", []) - - async def serialize(self, **kwargs): - return {"messages": self._messages} - - - # Use the custom store - store = MyMessageStore() - """ - - async def list_messages(self) -> list[ChatMessage]: - """Gets all the messages from the store that should be used for the next agent invocation. - - Messages are returned in ascending chronological order, with the oldest message first. - - If the messages stored in the store become very large, it is up to the store to - truncate, summarize or otherwise limit the number of messages returned. - - When using implementations of ``ChatMessageStoreProtocol``, a new one should be created for each thread - since they may contain state that is specific to a thread. - """ - ... - - async def add_messages(self, messages: Sequence[ChatMessage]) -> None: - """Adds messages to the store. - - Args: - messages: The sequence of ChatMessage objects to add to the store. - """ - ... - - @classmethod - async def deserialize( - cls, serialized_store_state: MutableMapping[str, Any], **kwargs: Any - ) -> ChatMessageStoreProtocol: - """Creates a new instance of the store from previously serialized state. - - This method, together with ``serialize()`` can be used to save and load messages from a persistent store - if this store only has messages in memory. - - Args: - serialized_store_state: The previously serialized state data containing messages. - - Keyword Args: - **kwargs: Additional arguments for deserialization. - - Returns: - A new instance of the store populated with messages from the serialized state. - """ - ... - - async def update_from_state(self, serialized_store_state: MutableMapping[str, Any], **kwargs: Any) -> None: - """Update the current ChatMessageStore instance from serialized state data. - - Args: - serialized_store_state: Previously serialized state data containing messages. - - Keyword Args: - kwargs: Additional arguments for deserialization. - """ - ... - - async def serialize(self, **kwargs: Any) -> dict[str, Any]: - """Serializes the current object's state. - - This method, together with ``deserialize()`` can be used to save and load messages from a persistent store - if this store only has messages in memory. - - Keyword Args: - kwargs: Additional arguments for serialization. - - Returns: - The serialized state data that can be used with ``deserialize()``. - """ - ... - - -class ChatMessageStoreState(SerializationMixin): - """State model for serializing and deserializing chat message store data. - - Attributes: - messages: List of chat messages stored in the message store. - """ - - def __init__( - self, - messages: Sequence[ChatMessage] | Sequence[MutableMapping[str, Any]] | None = None, - **kwargs: Any, - ) -> None: - """Create the store state. - - Args: - messages: a list of messages or a list of the dict representation of messages. - - Keyword Args: - **kwargs: not used for this, but might be used by subclasses. - - """ - if not messages: - self.messages: list[ChatMessage] = [] - return - if not isinstance(messages, list): - raise TypeError("Messages should be a list") - new_messages: list[ChatMessage] = [] - for msg in messages: - if isinstance(msg, ChatMessage): - new_messages.append(msg) - else: - new_messages.append(ChatMessage.from_dict(msg)) - self.messages = new_messages - - -class AgentThreadState(SerializationMixin): - """State model for serializing and deserializing thread information.""" - - def __init__( - self, - *, - service_thread_id: str | None = None, - chat_message_store_state: ChatMessageStoreState | MutableMapping[str, Any] | None = None, - ) -> None: - """Create a AgentThread state. - - Keyword Args: - service_thread_id: Optional ID of the thread managed by the agent service. - chat_message_store_state: Optional serialized state of the chat message store. - """ - if service_thread_id is not None and chat_message_store_state is not None: - raise AgentThreadException("A thread cannot have both a service_thread_id and a chat_message_store.") - self.service_thread_id = service_thread_id - self.chat_message_store_state: ChatMessageStoreState | None = None - if chat_message_store_state is not None: - if isinstance(chat_message_store_state, dict): - self.chat_message_store_state = ChatMessageStoreState.from_dict(chat_message_store_state) - elif isinstance(chat_message_store_state, ChatMessageStoreState): - self.chat_message_store_state = chat_message_store_state - else: - raise TypeError("Could not parse ChatMessageStoreState.") - - -TChatMessageStore = TypeVar("TChatMessageStore", bound="ChatMessageStore") - - -class ChatMessageStore: - """An in-memory implementation of ChatMessageStoreProtocol that stores messages in a list. - - This implementation provides a simple, list-based storage for chat messages - with support for serialization and deserialization. It implements all the - required methods of the ``ChatMessageStoreProtocol`` protocol. - - The store maintains messages in memory and provides methods to serialize - and deserialize the state for persistence purposes. - - Examples: - .. code-block:: python - - from agent_framework import ChatMessageStore, ChatMessage - - # Create an empty store - store = ChatMessageStore() - - # Add messages - message = ChatMessage(role="user", text="Hello") - await store.add_messages([message]) - - # Retrieve messages - messages = await store.list_messages() - - # Serialize for persistence - state = await store.serialize() - - # Deserialize from saved state - restored_store = await ChatMessageStore.deserialize(state) - """ - - def __init__(self, messages: Sequence[ChatMessage] | None = None): - """Create a ChatMessageStore for use in a thread. - - Args: - messages: The messages to store. - """ - self.messages = list(messages) if messages else [] - - async def add_messages(self, messages: Sequence[ChatMessage]) -> None: - """Add messages to the store. - - Args: - messages: Sequence of ChatMessage objects to add to the store. - """ - self.messages.extend(messages) - - async def list_messages(self) -> list[ChatMessage]: - """Get all messages from the store in chronological order. - - Returns: - List of ChatMessage objects, ordered from oldest to newest. - """ - return self.messages - - @classmethod - async def deserialize( - cls: type[TChatMessageStore], serialized_store_state: MutableMapping[str, Any], **kwargs: Any - ) -> TChatMessageStore: - """Create a new ChatMessageStore instance from serialized state data. - - Args: - serialized_store_state: Previously serialized state data containing messages. - - Keyword Args: - **kwargs: Additional arguments for deserialization. - - Returns: - A new ChatMessageStore instance populated with messages from the serialized state. - """ - state = ChatMessageStoreState.from_dict(serialized_store_state, **kwargs) - if state.messages: - return cls(messages=state.messages) - return cls() - - async def update_from_state(self, serialized_store_state: MutableMapping[str, Any], **kwargs: Any) -> None: - """Update the current ChatMessageStore instance from serialized state data. - - Args: - serialized_store_state: Previously serialized state data containing messages. - - Keyword Args: - **kwargs: Additional arguments for deserialization. - """ - if not serialized_store_state: - return - state = ChatMessageStoreState.from_dict(serialized_store_state, **kwargs) - if state.messages: - self.messages = state.messages - - async def serialize(self, **kwargs: Any) -> dict[str, Any]: - """Serialize the current store state for persistence. - - Keyword Args: - **kwargs: Additional arguments for serialization. - - Returns: - Serialized state data that can be used with deserialize_state. - """ - state = ChatMessageStoreState(messages=self.messages) - return state.to_dict() - - -TAgentThread = TypeVar("TAgentThread", bound="AgentThread") - - -class AgentThread: - """The Agent thread class, this can represent both a locally managed thread or a thread managed by the service. - - An ``AgentThread`` maintains the conversation state and message history for an agent interaction. - It can either use a service-managed thread (via ``service_thread_id``) or a local message store - (via ``message_store``), but not both. - - Examples: - .. code-block:: python - - from agent_framework import ChatAgent, ChatMessageStore - from agent_framework.openai import OpenAIChatClient - - client = OpenAIChatClient(model="gpt-4o") - - # Create agent with service-managed threads using a service_thread_id - service_agent = ChatAgent(name="assistant", client=client) - service_thread = await service_agent.get_new_thread(service_thread_id="thread_abc123") - - # Create agent with service-managed threads using conversation_id - conversation_agent = ChatAgent(name="assistant", client=client, conversation_id="thread_abc123") - conversation_thread = await conversation_agent.get_new_thread() - - # Create agent with custom message store factory - local_agent = ChatAgent(name="assistant", client=client, chat_message_store_factory=ChatMessageStore) - local_thread = await local_agent.get_new_thread() - - # Serialize and restore thread state - state = await local_thread.serialize() - restored_thread = await local_agent.deserialize_thread(state) - """ - - def __init__( - self, - *, - service_thread_id: str | None = None, - message_store: ChatMessageStoreProtocol | None = None, - context_provider: ContextProvider | None = None, - ) -> None: - """Initialize an AgentThread, do not use this method manually, always use: ``agent.get_new_thread()``. - - Args: - service_thread_id: The optional ID of the thread managed by the agent service. - message_store: The optional ChatMessageStore implementation for managing chat messages. - context_provider: The optional ContextProvider for the thread. - - Note: - Either ``service_thread_id`` or ``message_store`` may be set, but not both. - """ - if service_thread_id is not None and message_store is not None: - raise AgentThreadException("Only the service_thread_id or message_store may be set, but not both.") - - self._service_thread_id = service_thread_id - self._message_store = message_store - self.context_provider = context_provider - - @property - def is_initialized(self) -> bool: - """Indicates if the thread is initialized. - - This means either the ``service_thread_id`` or the ``message_store`` is set. - """ - return self._service_thread_id is not None or self._message_store is not None - - @property - def service_thread_id(self) -> str | None: - """Gets the ID of the current thread to support cases where the thread is owned by the agent service.""" - return self._service_thread_id - - @service_thread_id.setter - def service_thread_id(self, service_thread_id: str | None) -> None: - """Sets the ID of the current thread to support cases where the thread is owned by the agent service. - - Note: - Either ``service_thread_id`` or ``message_store`` may be set, but not both. - """ - if service_thread_id is None: - return - - if self._message_store is not None: - raise AgentThreadException( - "Only the service_thread_id or message_store may be set, " - "but not both and switching from one to another is not supported." - ) - self._service_thread_id = service_thread_id - - @property - def message_store(self) -> ChatMessageStoreProtocol | None: - """Gets the ``ChatMessageStoreProtocol`` used by this thread.""" - return self._message_store - - @message_store.setter - def message_store(self, message_store: ChatMessageStoreProtocol | None) -> None: - """Sets the ``ChatMessageStoreProtocol`` used by this thread. - - Note: - Either ``service_thread_id`` or ``message_store`` may be set, but not both. - """ - if message_store is None: - return - - if self._service_thread_id is not None: - raise AgentThreadException( - "Only the service_thread_id or message_store may be set, " - "but not both and switching from one to another is not supported." - ) - - self._message_store = message_store - - async def on_new_messages(self, new_messages: ChatMessage | Sequence[ChatMessage]) -> None: - """Invoked when a new message has been contributed to the chat by any participant. - - Args: - new_messages: The new ChatMessage or sequence of ChatMessage objects to add to the thread. - """ - if self._service_thread_id is not None: - # If the thread messages are stored in the service there is nothing to do here, - # since invoking the service should already update the thread. - return - if self._message_store is None: - # If there is no conversation id, and no store we can - # create a default in memory store. - self._message_store = ChatMessageStore() - # If a store has been provided, we need to add the messages to the store. - if isinstance(new_messages, ChatMessage): - new_messages = [new_messages] - await self._message_store.add_messages(new_messages) - - async def serialize(self, **kwargs: Any) -> dict[str, Any]: - """Serializes the current object's state. - - Keyword Args: - **kwargs: Arguments for serialization. - """ - chat_message_store_state = None - if self._message_store is not None: - chat_message_store_state = await self._message_store.serialize(**kwargs) - - state = AgentThreadState( - service_thread_id=self._service_thread_id, chat_message_store_state=chat_message_store_state - ) - return state.to_dict(exclude_none=False) - - @classmethod - async def deserialize( - cls: type[TAgentThread], - serialized_thread_state: MutableMapping[str, Any], - *, - message_store: ChatMessageStoreProtocol | None = None, - **kwargs: Any, - ) -> TAgentThread: - """Deserializes the state from a dictionary into a new AgentThread instance. - - Args: - serialized_thread_state: The serialized thread state as a dictionary. - - Keyword Args: - message_store: Optional ChatMessageStoreProtocol to use for managing messages. - If not provided, a new ChatMessageStore will be created if needed. - **kwargs: Additional arguments for deserialization. - - Returns: - A new AgentThread instance with properties set from the serialized state. - """ - state = AgentThreadState.from_dict(serialized_thread_state) - - if state.service_thread_id is not None: - return cls(service_thread_id=state.service_thread_id) - - # If we don't have any ChatMessageStoreProtocol state return here. - if state.chat_message_store_state is None: - return cls() - - if message_store is not None: - try: - await message_store.add_messages(state.chat_message_store_state.messages, **kwargs) - except Exception as ex: - raise AgentThreadException("Failed to deserialize the provided message store.") from ex - return cls(message_store=message_store) - try: - message_store = ChatMessageStore(messages=state.chat_message_store_state.messages, **kwargs) - except Exception as ex: - raise AgentThreadException("Failed to deserialize the message store.") from ex - return cls(message_store=message_store) - - async def update_from_thread_state( - self, - serialized_thread_state: MutableMapping[str, Any], - **kwargs: Any, - ) -> None: - """Deserializes the state from a dictionary into the thread properties. - - Args: - serialized_thread_state: The serialized thread state as a dictionary. - - Keyword Args: - **kwargs: Additional arguments for deserialization. - """ - state = AgentThreadState.from_dict(serialized_thread_state) - - if state.service_thread_id is not None: - self.service_thread_id = state.service_thread_id - # Since we have an ID, we should not have a chat message store and we can return here. - return - # If we don't have any ChatMessageStoreProtocol state return here. - if state.chat_message_store_state is None: - return - if self.message_store is not None: - await self.message_store.add_messages(state.chat_message_store_state.messages, **kwargs) - # If we don't have a chat message store yet, create an in-memory one. - return - # Create the message store from the default. - self.message_store = ChatMessageStore(messages=state.chat_message_store_state.messages, **kwargs) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 5dec72d02c..8374d18e60 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -10,7 +10,6 @@ from collections.abc import ( AsyncIterable, Awaitable, Callable, - Collection, Mapping, MutableMapping, Sequence, @@ -25,18 +24,16 @@ from typing import ( Final, Generic, Literal, - Protocol, TypedDict, Union, cast, get_args, get_origin, overload, - runtime_checkable, ) from opentelemetry.metrics import Histogram, NoOpHistogram -from pydantic import AnyUrl, BaseModel, Field, ValidationError, create_model +from pydantic import BaseModel, Field, ValidationError, create_model from ._logging import get_logger from ._serialization import SerializationMixin @@ -58,25 +55,21 @@ if sys.version_info >= (3, 12): from typing import override # type: ignore # pragma: no cover else: from typing_extensions import override # type: ignore[import] # pragma: no cover -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 if TYPE_CHECKING: - from ._clients import ChatClientProtocol + from ._clients import SupportsChatGetResponse from ._middleware import FunctionMiddlewarePipeline, FunctionMiddlewareTypes from ._types import ( - ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate, Content, + Message, ResponseStream, ) - TResponseModelT = TypeVar("TResponseModelT", bound=BaseModel) + ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) logger = get_logger() @@ -85,13 +78,6 @@ __all__ = [ "FunctionInvocationConfiguration", "FunctionInvocationLayer", "FunctionTool", - "HostedCodeInterpreterTool", - "HostedFileSearchTool", - "HostedImageGenerationTool", - "HostedMCPSpecificApproval", - "HostedMCPTool", - "HostedWebSearchTool", - "ToolProtocol", "normalize_function_invocation_configuration", "tool", ] @@ -100,11 +86,10 @@ __all__ = [ logger = get_logger() DEFAULT_MAX_ITERATIONS: Final[int] = 40 DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST: Final[int] = 3 -TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol[Any]") +ChatClientT = TypeVar("ChatClientT", bound="SupportsChatGetResponse[Any]") # region Helpers ArgsT = TypeVar("ArgsT", bound=BaseModel, default=BaseModel) -ReturnT = TypeVar("ReturnT", default=Any) def _parse_inputs( @@ -163,380 +148,6 @@ def _parse_inputs( # region Tools -@runtime_checkable -class ToolProtocol(Protocol): - """Represents a generic tool. - - This protocol defines the interface that all tools must implement to be compatible - with the agent framework. It is implemented by various tool classes such as HostedMCPTool, - HostedWebSearchTool, and FunctionTool's. A FunctionTool is usually created by the `tool` decorator. - - Since each connector needs to parse tools differently, users can pass a dict to - specify a service-specific tool when no abstraction is available. - - Attributes: - name: The name of the tool. - description: A description of the tool, suitable for use in describing the purpose to a model. - additional_properties: Additional properties associated with the tool. - """ - - name: str - """The name of the tool.""" - description: str - """A description of the tool, suitable for use in describing the purpose to a model.""" - additional_properties: dict[str, Any] | None - """Additional properties associated with the tool.""" - - def __str__(self) -> str: - """Return a string representation of the tool.""" - ... - - -class BaseTool(SerializationMixin): - """Base class for AI tools, providing common attributes and methods. - - Used as the base class for the various tools in the agent framework, such as HostedMCPTool, - HostedWebSearchTool, and FunctionTool. - - Since each connector needs to parse tools differently, this class is not exposed directly to end users. - In most cases, users can pass a dict to specify a service-specific tool when no abstraction is available. - """ - - DEFAULT_EXCLUDE: ClassVar[set[str]] = {"additional_properties"} - - def __init__( - self, - *, - name: str, - description: str = "", - additional_properties: dict[str, Any] | None = None, - **kwargs: Any, - ) -> None: - """Initialize the BaseTool. - - Keyword Args: - name: The name of the tool. - description: A description of the tool. - additional_properties: Additional properties associated with the tool. - **kwargs: Additional keyword arguments. - """ - self.name = name - self.description = description - self.additional_properties = additional_properties - for key, value in kwargs.items(): - setattr(self, key, value) - - def __str__(self) -> str: - """Return a string representation of the tool.""" - if self.description: - return f"{self.__class__.__name__}(name={self.name}, description={self.description})" - return f"{self.__class__.__name__}(name={self.name})" - - -class HostedCodeInterpreterTool(BaseTool): - """Represents a hosted tool that can be specified to an AI service to enable it to execute generated code. - - This tool does not implement code interpretation itself. It serves as a marker to inform a service - that it is allowed to execute generated code if the service is capable of doing so. - - Examples: - .. code-block:: python - - from agent_framework import HostedCodeInterpreterTool - - # Create a code interpreter tool - code_tool = HostedCodeInterpreterTool() - - # With file inputs - code_tool_with_files = HostedCodeInterpreterTool(inputs=[{"file_id": "file-123"}, {"file_id": "file-456"}]) - """ - - def __init__( - self, - *, - inputs: Content | dict[str, Any] | str | list[Content | dict[str, Any] | str] | None = None, - description: str | None = None, - additional_properties: dict[str, Any] | None = None, - **kwargs: Any, - ) -> None: - """Initialize the HostedCodeInterpreterTool. - - Keyword Args: - inputs: A list of contents that the tool can accept as input. Defaults to None. - This should mostly be HostedFileContent or HostedVectorStoreContent. - Can also be DataContent, depending on the service used. - When supplying a list, it can contain: - - Content instances - - dicts with properties for Content (e.g., {"uri": "http://example.com", "media_type": "text/html"}) - - strings (which will be converted to UriContent with media_type "text/plain"). - If None, defaults to an empty list. - description: A description of the tool. - additional_properties: Additional properties associated with the tool. - **kwargs: Additional keyword arguments to pass to the base class. - """ - if "name" in kwargs: - raise ValueError("The 'name' argument is reserved for the HostedCodeInterpreterTool and cannot be set.") - - self.inputs = _parse_inputs(inputs) if inputs else [] - - super().__init__( - name="code_interpreter", - description=description or "", - additional_properties=additional_properties, - **kwargs, - ) - - -class HostedWebSearchTool(BaseTool): - """Represents a web search tool that can be specified to an AI service to enable it to perform web searches. - - Examples: - .. code-block:: python - - from agent_framework import HostedWebSearchTool - - # Create a basic web search tool - search_tool = HostedWebSearchTool() - - # With location context - search_tool_with_location = HostedWebSearchTool( - description="Search the web for information", - additional_properties={"user_location": {"city": "Seattle", "country": "US"}}, - ) - """ - - def __init__( - self, - description: str | None = None, - additional_properties: dict[str, Any] | None = None, - **kwargs: Any, - ): - """Initialize a HostedWebSearchTool. - - Keyword Args: - description: A description of the tool. - additional_properties: Additional properties associated with the tool - (e.g., {"user_location": {"city": "Seattle", "country": "US"}}). - **kwargs: Additional keyword arguments to pass to the base class. - if additional_properties is not provided, any kwargs will be added to additional_properties. - """ - args: dict[str, Any] = { - "name": "web_search", - } - if additional_properties is not None: - args["additional_properties"] = additional_properties - elif kwargs: - args["additional_properties"] = kwargs - if description is not None: - args["description"] = description - super().__init__(**args) - - -class HostedImageGenerationToolOptions(TypedDict, total=False): - """Options for HostedImageGenerationTool.""" - - count: int - image_size: str - media_type: str - model_id: str - response_format: Literal["uri", "data", "hosted"] - streaming_count: int - - -class HostedImageGenerationTool(BaseTool): - """Represents a hosted tool that can be specified to an AI service to enable it to perform image generation.""" - - def __init__( - self, - *, - options: HostedImageGenerationToolOptions | None = None, - description: str | None = None, - additional_properties: dict[str, Any] | None = None, - **kwargs: Any, - ): - """Initialize a HostedImageGenerationTool.""" - if "name" in kwargs: - raise ValueError("The 'name' argument is reserved for the HostedImageGenerationTool and cannot be set.") - - self.options = options - super().__init__( - name="image_generation", - description=description or "", - additional_properties=additional_properties, - **kwargs, - ) - - -class HostedMCPSpecificApproval(TypedDict, total=False): - """Represents the specific mode for a hosted tool. - - When using this mode, the user must specify which tools always or never require approval. - This is represented as a dictionary with two optional keys: - - Attributes: - always_require_approval: A sequence of tool names that always require approval. - never_require_approval: A sequence of tool names that never require approval. - """ - - always_require_approval: Collection[str] | None - never_require_approval: Collection[str] | None - - -class HostedMCPTool(BaseTool): - """Represents a MCP tool that is managed and executed by the service. - - Examples: - .. code-block:: python - - from agent_framework import HostedMCPTool - - # Create a basic MCP tool - mcp_tool = HostedMCPTool( - name="my_mcp_tool", - url="https://example.com/mcp", - ) - - # With approval mode and allowed tools - mcp_tool_with_approval = HostedMCPTool( - name="my_mcp_tool", - description="My MCP tool", - url="https://example.com/mcp", - approval_mode="always_require", - allowed_tools=["tool1", "tool2"], - headers={"Authorization": "Bearer token"}, - ) - - # With specific approval mode - mcp_tool_specific = HostedMCPTool( - name="my_mcp_tool", - url="https://example.com/mcp", - approval_mode={ - "always_require_approval": ["dangerous_tool"], - "never_require_approval": ["safe_tool"], - }, - ) - """ - - def __init__( - self, - *, - name: str, - description: str | None = None, - url: AnyUrl | str, - approval_mode: Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None = None, - allowed_tools: Collection[str] | None = None, - headers: dict[str, str] | None = None, - additional_properties: dict[str, Any] | None = None, - **kwargs: Any, - ) -> None: - """Create a hosted MCP tool. - - Keyword Args: - name: The name of the tool. - description: A description of the tool. - url: The URL of the tool. - approval_mode: The approval mode for the tool. This can be: - - "always_require": The tool always requires approval before use. - - "never_require": The tool never requires approval before use. - - A dict with keys `always_require_approval` or `never_require_approval`, - followed by a sequence of strings with the names of the relevant tools. - allowed_tools: A list of tools that are allowed to use this tool. - headers: Headers to include in requests to the tool. - additional_properties: Additional properties to include in the tool definition. - **kwargs: Additional keyword arguments to pass to the base class. - """ - try: - # Validate approval_mode - if approval_mode is not None: - if isinstance(approval_mode, str): - if approval_mode not in ("always_require", "never_require"): - raise ValueError( - f"Invalid approval_mode: {approval_mode}. " - "Must be 'always_require', 'never_require', or a dict with 'always_require_approval' " - "or 'never_require_approval' keys." - ) - elif isinstance(approval_mode, dict): - # Validate that the dict has sets - for key, value in approval_mode.items(): - if not isinstance(value, set): - approval_mode[key] = set(value) # type: ignore - - # Validate allowed_tools - if allowed_tools is not None and isinstance(allowed_tools, dict): - raise TypeError( - f"allowed_tools must be a sequence of strings, not a dict. Got: {type(allowed_tools).__name__}" - ) - - super().__init__( - name=name, - description=description or "", - additional_properties=additional_properties, - **kwargs, - ) - self.url = url if isinstance(url, AnyUrl) else AnyUrl(url) - self.approval_mode = approval_mode - self.allowed_tools = set(allowed_tools) if allowed_tools else None - self.headers = headers - except (ValidationError, ValueError, TypeError) as err: - raise ToolException(f"Error initializing HostedMCPTool: {err}", inner_exception=err) from err - - -class HostedFileSearchTool(BaseTool): - """Represents a file search tool that can be specified to an AI service to enable it to perform file searches. - - Examples: - .. code-block:: python - - from agent_framework import HostedFileSearchTool - - # Create a basic file search tool - file_search = HostedFileSearchTool() - - # With vector store inputs and max results - file_search_with_inputs = HostedFileSearchTool( - inputs=[{"vector_store_id": "vs_123"}], - max_results=10, - description="Search files in vector store", - ) - """ - - def __init__( - self, - *, - inputs: Content | dict[str, Any] | str | list[Content | dict[str, Any] | str] | None = None, - max_results: int | None = None, - description: str | None = None, - additional_properties: dict[str, Any] | None = None, - **kwargs: Any, - ): - """Initialize a FileSearchTool. - - Keyword Args: - inputs: A list of contents that the tool can accept as input. Defaults to None. - This should be one or more HostedVectorStoreContents. - When supplying a list, it can contain: - - Content instances - - dicts with properties for Content (e.g., {"uri": "http://example.com", "media_type": "text/html"}) - - strings (which will be converted to UriContent with media_type "text/plain"). - If None, defaults to an empty list. - max_results: The maximum number of results to return from the file search. - If None, max limit is applied. - description: A description of the tool. - additional_properties: Additional properties associated with the tool. - **kwargs: Additional keyword arguments to pass to the base class. - """ - if "name" in kwargs: - raise ValueError("The 'name' argument is reserved for the HostedFileSearchTool and cannot be set.") - - self.inputs = _parse_inputs(inputs) if inputs else None - self.max_results = max_results - - super().__init__( - name="file_search", - description=description or "", - additional_properties=additional_properties, - **kwargs, - ) def _default_histogram() -> Histogram: @@ -569,19 +180,24 @@ def _default_histogram() -> Histogram: ) -TClass = TypeVar("TClass", bound="SerializationMixin") +ClassT = TypeVar("ClassT", bound="SerializationMixin") class EmptyInputModel(BaseModel): """An empty input model for functions with no parameters.""" -class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]): +class FunctionTool(SerializationMixin, Generic[ArgsT]): """A tool that wraps a Python function to make it callable by AI models. This class wraps a Python function to make it callable by AI models with automatic parameter validation and JSON schema generation. + Attributes: + name: The name of the tool. + description: A description of the tool, suitable for use in describing the purpose to a model. + additional_properties: Additional properties associated with the tool. + Examples: .. code-block:: python @@ -619,7 +235,12 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]): """ INJECTABLE: ClassVar[set[str]] = {"func"} - DEFAULT_EXCLUDE: ClassVar[set[str]] = {"input_model", "_invocation_duration_histogram", "_cached_parameters"} + DEFAULT_EXCLUDE: ClassVar[set[str]] = { + "additional_properties", + "input_model", + "_invocation_duration_histogram", + "_cached_parameters", + } def __init__( self, @@ -630,8 +251,9 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]): max_invocations: int | None = None, max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, - func: Callable[..., Awaitable[ReturnT] | ReturnT] | None = None, + func: Callable[..., Any] | None = None, input_model: type[ArgsT] | Mapping[str, Any] | None = None, + result_parser: Callable[[Any], str] | None = None, **kwargs: Any, ) -> None: """Initialize the FunctionTool. @@ -640,24 +262,41 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]): name: The name of the function. description: A description of the function. approval_mode: Whether or not approval is required to run this tool. - Default is that approval is required. + Default is that approval is NOT required (``"never_require"``). max_invocations: The maximum number of times this function can be invoked. If None, there is no limit. Should be at least 1. max_invocation_exceptions: The maximum number of exceptions allowed during invocations. If None, there is no limit. Should be at least 1. additional_properties: Additional properties to set on the function. - func: The function to wrap. + func: The function to wrap. When ``None``, creates a declaration-only tool + that has no implementation. Declaration-only tools are useful when you want + the agent to reason about tool usage without executing them, or when the + actual implementation exists elsewhere (e.g., client-side rendering). input_model: The Pydantic model that defines the input parameters for the function. This can also be a JSON schema dictionary. - If not provided, it will be inferred from the function signature. + If not provided and ``func`` is not ``None``, it will be inferred from + the function signature. When ``func`` is ``None`` and ``input_model`` is + not provided, the tool will use an empty input model (no parameters) in + its JSON schema. For declaration-only tools that should declare + parameters, explicitly provide ``input_model`` (either a Pydantic + ``BaseModel`` or a JSON schema dictionary) so the model can reason about + the expected arguments. + result_parser: An optional callable with signature ``Callable[[Any], str]`` that + overrides the default result parsing behavior. When provided, this callable + is used to convert the raw function return value to a string instead of the + built-in :meth:`parse_result` logic. Depending on your function, it may be + easiest to just do the serialization directly in the function body rather + than providing a custom ``result_parser``. **kwargs: Additional keyword arguments. """ - super().__init__( - name=name, - description=description, - additional_properties=additional_properties, - **kwargs, - ) + # Core attributes (formerly from BaseTool) + self.name = name + self.description = description + self.additional_properties = additional_properties + for key, value in kwargs.items(): + setattr(self, key, value) + + # FunctionTool-specific attributes self.func = func self._instance = None # Store the instance for bound methods self.input_model = self._resolve_input_model(input_model) @@ -673,6 +312,7 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]): self.invocation_exception_count = 0 self._invocation_duration_histogram = _default_histogram() self.type: Literal["function_tool"] = "function_tool" + self.result_parser = result_parser self._forward_runtime_kwargs: bool = False if self.func: sig = inspect.signature(self.func) @@ -681,6 +321,12 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]): self._forward_runtime_kwargs = True break + def __str__(self) -> str: + """Return a string representation of the tool.""" + if self.description: + return f"{self.__class__.__name__}(name={self.name}, description={self.description})" + return f"{self.__class__.__name__}(name={self.name})" + @property def declaration_only(self) -> bool: """Indicate whether the function is declaration only (i.e., has no implementation).""" @@ -689,7 +335,7 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]): return True return self.func is None - def __get__(self, obj: Any, objtype: type | None = None) -> FunctionTool[ArgsT, ReturnT]: + def __get__(self, obj: Any, objtype: type | None = None) -> FunctionTool[ArgsT]: """Implement the descriptor protocol to support bound methods. When a FunctionTool is accessed as an attribute of a class instance, @@ -732,7 +378,7 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]): return cast(type[ArgsT], _create_model_from_json_schema(self.name, input_model)) raise TypeError("input_model must be a Pydantic BaseModel subclass or a JSON schema dict.") - def __call__(self, *args: Any, **kwargs: Any) -> ReturnT | Awaitable[ReturnT]: + def __call__(self, *args: Any, **kwargs: Any) -> Any: """Call the wrapped function with the provided arguments.""" if self.declaration_only: raise ToolException(f"Function '{self.name}' is declaration only and cannot be invoked.") @@ -763,15 +409,19 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]): *, arguments: ArgsT | None = None, **kwargs: Any, - ) -> ReturnT: + ) -> str: """Run the AI function with the provided arguments as a Pydantic model. + The raw return value of the wrapped function is automatically parsed into a ``str`` + (either plain text or serialized JSON) using :meth:`parse_result` or the custom + ``result_parser`` if one was provided. + Keyword Args: arguments: A Pydantic model instance containing the arguments for the function. kwargs: Keyword arguments to pass to the function, will not be used if ``arguments`` is provided. Returns: - The result of the function execution. + The parsed result as a string — either plain text or serialized JSON. Raises: TypeError: If arguments is not an instance of the expected input model. @@ -781,6 +431,8 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]): global OBSERVABILITY_SETTINGS from .observability import OBSERVABILITY_SETTINGS + parser = self.result_parser or FunctionTool.parse_result + original_kwargs = dict(kwargs) tool_call_id = original_kwargs.pop("tool_call_id", None) if arguments is not None: @@ -796,9 +448,14 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]): logger.debug(f"Function arguments: {kwargs}") res = self.__call__(**kwargs) result = await res if inspect.isawaitable(res) else res + try: + parsed = parser(result) + except Exception: + logger.warning(f"Function {self.name}: result parser failed, falling back to str().") + parsed = str(result) logger.info(f"Function {self.name} succeeded.") - logger.debug(f"Function result: {result or 'None'}") - return result # type: ignore[reportReturnType] + logger.debug(f"Function result: {parsed or 'None'}") + return parsed attributes = get_function_span_attributes(self, tool_call_id=tool_call_id) if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined] @@ -811,16 +468,16 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]): "chat_options", "tools", "tool_choice", - "thread", + "session", "conversation_id", "options", "response_format", } } attributes.update({ - OtelAttr.TOOL_ARGUMENTS: arguments.model_dump_json() + OtelAttr.TOOL_ARGUMENTS: arguments.model_dump_json(ensure_ascii=False) if arguments - else json.dumps(serializable_kwargs, default=str) + else json.dumps(serializable_kwargs, default=str, ensure_ascii=False) if serializable_kwargs else "None" }) @@ -842,19 +499,16 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]): logger.error(f"Function failed. Error: {exception}") raise else: + try: + parsed = parser(result) + except Exception: + logger.warning(f"Function {self.name}: result parser failed, falling back to str().") + parsed = str(result) logger.info(f"Function {self.name} succeeded.") if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined] - from ._types import prepare_function_call_results - - try: - json_result = prepare_function_call_results(result) - except (TypeError, OverflowError): - span.set_attribute(OtelAttr.TOOL_RESULT, "") - logger.debug("Function result: ") - else: - span.set_attribute(OtelAttr.TOOL_RESULT, json_result) - logger.debug(f"Function result: {json_result}") - return result # type: ignore[reportReturnType] + span.set_attribute(OtelAttr.TOOL_RESULT, parsed) + logger.debug(f"Function result: {parsed}") + return parsed finally: duration = (end_time_stamp or perf_counter()) - start_time_stamp span.set_attribute(OtelAttr.MEASUREMENT_FUNCTION_INVOCATION_DURATION, duration) @@ -872,6 +526,49 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]): self._cached_parameters = self.input_model.model_json_schema() return self._cached_parameters + @staticmethod + def _make_dumpable(value: Any) -> Any: + """Recursively convert a value to a JSON-dumpable form.""" + from ._types import Content + + if isinstance(value, list): + return [FunctionTool._make_dumpable(item) for item in value] + if isinstance(value, dict): + return {k: FunctionTool._make_dumpable(v) for k, v in value.items()} + if isinstance(value, Content): + return value.to_dict(exclude={"raw_representation", "additional_properties"}) + if isinstance(value, BaseModel): + return value.model_dump() + if hasattr(value, "to_dict"): + return value.to_dict() + if hasattr(value, "text") and isinstance(value.text, str): + return value.text + return value + + @staticmethod + def parse_result(result: Any) -> str: + """Convert a raw function return value to a string representation. + + The return value is always a ``str`` — either plain text or serialized JSON. + This is called automatically by :meth:`invoke` before returning the result, + ensuring that the result stored in ``Content.from_function_result`` is + already in a form that can be passed directly to LLM APIs. + + Args: + result: The raw return value from the wrapped function. + + Returns: + A string representation of the result, either plain text or serialized JSON. + """ + if result is None: + return "" + if isinstance(result, str): + return result + dumpable = FunctionTool._make_dumpable(result) + if isinstance(dumpable, str): + return dumpable + return json.dumps(dumpable, default=str) + def to_json_schema_spec(self) -> dict[str, Any]: """Convert a FunctionTool to the JSON Schema function specification format. @@ -898,10 +595,10 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]): def _tools_to_dict( tools: ( - ToolProtocol + FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None ), ) -> list[str | dict[str, Any]] | None: @@ -1235,7 +932,7 @@ def _create_model_from_json_schema(tool_name: str, schema_json: Mapping[str, Any @overload def tool( - func: Callable[..., ReturnT | Awaitable[ReturnT]], + func: Callable[..., Any], *, name: str | None = None, description: str | None = None, @@ -1244,7 +941,8 @@ def tool( max_invocations: int | None = None, max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, -) -> FunctionTool[Any, ReturnT]: ... + result_parser: Callable[[Any], str] | None = None, +) -> FunctionTool[Any]: ... @overload @@ -1258,11 +956,12 @@ def tool( max_invocations: int | None = None, max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, -) -> Callable[[Callable[..., ReturnT | Awaitable[ReturnT]]], FunctionTool[Any, ReturnT]]: ... + result_parser: Callable[[Any], str] | None = None, +) -> Callable[[Callable[..., Any]], FunctionTool[Any]]: ... def tool( - func: Callable[..., ReturnT | Awaitable[ReturnT]] | None = None, + func: Callable[..., Any] | None = None, *, name: str | None = None, description: str | None = None, @@ -1271,7 +970,8 @@ def tool( max_invocations: int | None = None, max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, -) -> FunctionTool[Any, ReturnT] | Callable[[Callable[..., ReturnT | Awaitable[ReturnT]]], FunctionTool[Any, ReturnT]]: + result_parser: Callable[[Any], str] | None = None, +) -> FunctionTool[Any] | Callable[[Callable[..., Any]], FunctionTool[Any]]: """Decorate a function to turn it into a FunctionTool that can be passed to models and executed automatically. This decorator creates a Pydantic model from the function's signature, @@ -1286,7 +986,11 @@ def tool( to bypass automatic inference from the function signature. Args: - func: The function to decorate. + func: The function to decorate. This parameter enables the decorator to be used + both with and without parentheses: ``@tool`` directly decorates the function, + while ``@tool()`` or ``@tool(name="custom")`` returns a decorator. For + declaration-only tools (no implementation), use :class:`FunctionTool` directly + with ``func=None``—see the example below. Keyword Args: name: The name of the function. If not provided, the function's ``__name__`` @@ -1301,12 +1005,18 @@ def tool( When provided, the schema is used instead of inferring one from the function's signature. Defaults to ``None`` (infer from signature). approval_mode: Whether or not approval is required to run this tool. - Default is that approval is required. + Default is that approval is NOT required (``"never_require"``). max_invocations: The maximum number of times this function can be invoked. If None, there is no limit, should be at least 1. max_invocation_exceptions: The maximum number of exceptions allowed during invocations. If None, there is no limit, should be at least 1. additional_properties: Additional properties to set on the function. + result_parser: An optional callable with signature ``Callable[[Any], str]`` that + overrides the default result parsing. When provided, this callable converts the + raw function return value to a string instead of using the built-in + :meth:`FunctionTool.parse_result`. Depending on your function, it may be + easiest to just do the serialization directly in the function body rather + than providing a custom ``result_parser``. Note: When approval_mode is set to "always_require", the function will not be executed @@ -1369,14 +1079,28 @@ def tool( '''Get weather for a location.''' return f"Weather in {location}: 22 {unit}" + + # Declaration-only tool (no implementation) + # Use FunctionTool directly when you need a tool declaration without + # an executable function. The agent can request this tool, but it won't + # be executed automatically. Useful for testing agent reasoning or when + # the implementation is handled externally (e.g., client-side rendering). + from agent_framework import FunctionTool + + declaration_only_tool = FunctionTool( + name="get_current_time", + description="Get the current time in ISO 8601 format.", + func=None, # Explicitly no implementation - makes declaration_only=True + ) + """ - def decorator(func: Callable[..., ReturnT | Awaitable[ReturnT]]) -> FunctionTool[Any, ReturnT]: + def decorator(func: Callable[..., Any]) -> FunctionTool[Any]: @wraps(func) - def wrapper(f: Callable[..., ReturnT | Awaitable[ReturnT]]) -> FunctionTool[Any, ReturnT]: + def wrapper(f: Callable[..., Any]) -> FunctionTool[Any]: tool_name: str = name or getattr(f, "__name__", "unknown_function") # type: ignore[assignment] tool_desc: str = description or (f.__doc__ or "") - return FunctionTool[Any, ReturnT]( + return FunctionTool[Any]( name=tool_name, description=tool_desc, approval_mode=approval_mode, @@ -1385,6 +1109,7 @@ def tool( additional_properties=additional_properties or {}, func=f, input_model=schema, + result_parser=result_parser, ) return wrapper(func) @@ -1437,7 +1162,7 @@ class FunctionInvocationConfiguration(TypedDict, total=False): max_iterations: int max_consecutive_errors_per_request: int terminate_on_unknown_calls: bool - additional_tools: Sequence[ToolProtocol] + additional_tools: Sequence[FunctionTool] include_detailed_errors: bool @@ -1468,7 +1193,7 @@ async def _auto_invoke_function( custom_args: dict[str, Any] | None = None, *, config: FunctionInvocationConfiguration, - tool_map: dict[str, FunctionTool[BaseModel, Any]], + tool_map: dict[str, FunctionTool[BaseModel]], sequence_index: int | None = None, request_index: int | None = None, middleware_pipeline: FunctionMiddlewarePipeline | None = None, # Optional MiddlewarePipeline @@ -1500,7 +1225,7 @@ async def _auto_invoke_function( # this function is called. This function only handles the actual execution of approved, # non-declaration-only functions. - tool: FunctionTool[BaseModel, Any] | None = None + tool: FunctionTool[BaseModel] | None = None if function_call_content.type == "function_call": tool = tool_map.get(function_call_content.name) # type: ignore[arg-type] # Tool should exist because _try_execute_function_calls validates this @@ -1611,12 +1336,12 @@ async def _auto_invoke_function( def _get_tool_map( - tools: ToolProtocol + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]], -) -> dict[str, FunctionTool[Any, Any]]: - tool_list: dict[str, FunctionTool[Any, Any]] = {} + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]], +) -> dict[str, FunctionTool[Any]]: + tool_list: dict[str, FunctionTool[Any]] = {} for tool_item in tools if isinstance(tools, list) else [tools]: if isinstance(tool_item, FunctionTool): tool_list[tool_item.name] = tool_item @@ -1632,10 +1357,10 @@ async def _try_execute_function_calls( custom_args: dict[str, Any], attempt_idx: int, function_calls: Sequence[Content], - tools: ToolProtocol + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]], + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]], config: FunctionInvocationConfiguration, middleware_pipeline: Any = None, # Optional MiddlewarePipeline to avoid circular imports ) -> tuple[Sequence[Content], bool]: @@ -1821,8 +1546,8 @@ def _extract_tools(options: dict[str, Any] | None) -> Any: options: The options dict containing chat options. Returns: - ToolProtocol | Callable[..., Any] | MutableMapping[str, Any] | - Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None + FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | + Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None """ if options and isinstance(options, dict): return options.get("tools") @@ -1830,14 +1555,14 @@ def _extract_tools(options: dict[str, Any] | None) -> Any: def _collect_approval_responses( - messages: list[ChatMessage], + messages: list[Message], ) -> dict[str, Content]: """Collect approval responses (both approved and rejected) from messages.""" - from ._types import ChatMessage + from ._types import Message fcc_todo: dict[str, Content] = {} for msg in messages: - for content in msg.contents if isinstance(msg, ChatMessage) else []: + for content in msg.contents if isinstance(msg, Message) else []: # Collect BOTH approved and rejected responses if content.type == "function_approval_response": fcc_todo[content.id] = content # type: ignore[attr-defined, index] @@ -1845,7 +1570,7 @@ def _collect_approval_responses( def _replace_approval_contents_with_results( - messages: list[ChatMessage], + messages: list[Message], fcc_todo: dict[str, Content], approved_function_results: list[Content], ) -> None: @@ -1914,7 +1639,7 @@ def _extract_function_calls(response: ChatResponse) -> list[Content]: ] -def _prepend_fcc_messages(response: ChatResponse, fcc_messages: list[ChatMessage]) -> None: +def _prepend_fcc_messages(response: ChatResponse, fcc_messages: list[Message]) -> None: if not fcc_messages: return for msg in reversed(fcc_messages): @@ -1934,7 +1659,7 @@ class FunctionRequestResult(TypedDict, total=False): action: Literal["return", "continue", "stop"] errors_in_a_row: int - result_message: ChatMessage | None + result_message: Message | None update_role: Literal["assistant", "tool"] | None function_call_results: list[Content] | None @@ -1943,12 +1668,12 @@ def _handle_function_call_results( *, response: ChatResponse, function_call_results: list[Content], - fcc_messages: list[ChatMessage], + fcc_messages: list[Message], errors_in_a_row: int, had_errors: bool, max_errors: int, ) -> FunctionRequestResult: - from ._types import ChatMessage + from ._types import Message if any(fccr.type in {"function_approval_request", "function_call"} for fccr in function_call_results): # Only add items that aren't already in the message (e.g. function_approval_request wrappers). @@ -1958,7 +1683,7 @@ def _handle_function_call_results( if response.messages and response.messages[0].role == "assistant": response.messages[0].contents.extend(new_items) else: - response.messages.append(ChatMessage(role="assistant", contents=new_items)) + response.messages.append(Message(role="assistant", contents=new_items)) return { "action": "return", "errors_in_a_row": errors_in_a_row, @@ -1985,7 +1710,7 @@ def _handle_function_call_results( else: errors_in_a_row = 0 - result_message = ChatMessage(role="tool", contents=function_call_results) + result_message = Message(role="tool", contents=function_call_results) response.messages.append(result_message) fcc_messages.extend(response.messages) return { @@ -2000,10 +1725,10 @@ def _handle_function_call_results( async def _process_function_requests( *, response: ChatResponse | None, - prepped_messages: list[ChatMessage] | None, + prepped_messages: list[Message] | None, tool_options: dict[str, Any] | None, attempt_idx: int, - fcc_messages: list[ChatMessage] | None, + fcc_messages: list[Message] | None, errors_in_a_row: int, max_errors: int, execute_function_calls: Callable[..., Awaitable[tuple[list[Content], bool, bool]]], @@ -2083,15 +1808,15 @@ async def _process_function_requests( return result -TOptions_co = TypeVar( - "TOptions_co", +OptionsCoT = TypeVar( + "OptionsCoT", bound=TypedDict, # type: ignore[valid-type] default="ChatOptions[None]", covariant=True, ) -class FunctionInvocationLayer(Generic[TOptions_co]): +class FunctionInvocationLayer(Generic[OptionsCoT]): """Layer for chat clients to apply function invocation around get_response.""" def __init__( @@ -2112,39 +1837,39 @@ class FunctionInvocationLayer(Generic[TOptions_co]): @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[False] = ..., - options: ChatOptions[TResponseModelT], + options: ChatOptions[ResponseModelBoundT], **kwargs: Any, - ) -> Awaitable[ChatResponse[TResponseModelT]]: ... + ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[False] = ..., - options: TOptions_co | ChatOptions[None] | None = None, + options: OptionsCoT | ChatOptions[None] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[True], - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: bool = False, - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: @@ -2172,7 +1897,7 @@ class FunctionInvocationLayer(Generic[TOptions_co]): config=self.function_invocation_configuration, middleware_pipeline=function_middleware_pipeline, ) - filtered_kwargs = {k: v for k, v in kwargs.items() if k != "thread"} + filtered_kwargs = {k: v for k, v in kwargs.items() if k != "session"} # Make options mutable so we can update conversation_id during function invocation loop mutable_options: dict[str, Any] = dict(options) if options else {} # Remove additional_function_arguments from options passed to underlying chat client @@ -2186,7 +1911,7 @@ class FunctionInvocationLayer(Generic[TOptions_co]): nonlocal filtered_kwargs errors_in_a_row: int = 0 prepped_messages = prepare_messages(messages) - fcc_messages: list[ChatMessage] = [] + fcc_messages: list[Message] = [] response: ChatResponse | None = None for attempt_idx in range( @@ -2280,7 +2005,7 @@ class FunctionInvocationLayer(Generic[TOptions_co]): nonlocal stream_result_hooks errors_in_a_row: int = 0 prepped_messages = prepare_messages(messages) - fcc_messages: list[ChatMessage] = [] + fcc_messages: list[Message] = [] response: ChatResponse | None = None for attempt_idx in range( diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index b5fc029894..71a635f1cc 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -15,7 +15,7 @@ from pydantic import BaseModel from ._logging import get_logger from ._serialization import SerializationMixin -from ._tools import ToolProtocol, tool +from ._tools import FunctionTool, tool from .exceptions import AdditionItemMismatch, ContentError if sys.version_info >= (3, 13): @@ -31,22 +31,23 @@ __all__ = [ "AgentResponse", "AgentResponseUpdate", "Annotation", - "ChatMessage", "ChatOptions", "ChatResponse", "ChatResponseUpdate", "Content", + "ContinuationToken", + "FinalT", "FinishReason", "FinishReasonLiteral", + "Message", + "OuterFinalT", + "OuterUpdateT", "ResponseStream", "Role", "RoleLiteral", - "TFinal", - "TOuterFinal", - "TOuterUpdate", - "TUpdate", "TextSpanRegion", "ToolMode", + "UpdateT", "UsageDetails", "add_usage_details", "detect_media_type_from_base64", @@ -54,7 +55,6 @@ __all__ = [ "merge_chat_options", "normalize_messages", "normalize_tools", - "prepare_function_call_results", "prepend_instructions_to_messages", "validate_chat_options", "validate_tool_mode", @@ -305,12 +305,12 @@ def _serialize_value(value: Any, exclude_none: bool) -> Any: # region Constants and types _T = TypeVar("_T") -TEmbedding = TypeVar("TEmbedding") -TChatResponse = TypeVar("TChatResponse", bound="ChatResponse") -TToolMode = TypeVar("TToolMode", bound="ToolMode") -TAgentRunResponse = TypeVar("TAgentRunResponse", bound="AgentResponse") -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None, covariant=True) -TResponseModelT = TypeVar("TResponseModelT", bound=BaseModel) +EmbeddingT = TypeVar("EmbeddingT") +ChatResponseT = TypeVar("ChatResponseT", bound="ChatResponse") +ToolModeT = TypeVar("ToolModeT", bound="ToolMode") +AgentResponseT = TypeVar("AgentResponseT", bound="AgentResponse") +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None, covariant=True) +ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) CreatedAtT = str # Use a datetimeoffset type? Or a more specific type like datetime.datetime? @@ -389,7 +389,7 @@ class Annotation(TypedDict, total=False): raw_representation: Any -TContent = TypeVar("TContent", bound="Content") +ContentT = TypeVar("ContentT", bound="Content") # endregion @@ -544,13 +544,13 @@ class Content: @classmethod def from_text( - cls: type[TContent], + cls: type[ContentT], text: str, *, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create text content.""" return cls( "text", @@ -562,14 +562,14 @@ class Content: @classmethod def from_text_reasoning( - cls: type[TContent], + cls: type[ContentT], *, text: str | None = None, protected_data: str | None = None, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create text reasoning content.""" return cls( "text_reasoning", @@ -582,14 +582,14 @@ class Content: @classmethod def from_data( - cls: type[TContent], + cls: type[ContentT], data: bytes, media_type: str, *, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: r"""Create data content from raw binary data. Use this to create content from binary data (images, audio, documents, etc.). @@ -658,14 +658,14 @@ class Content: @classmethod def from_uri( - cls: type[TContent], + cls: type[ContentT], uri: str, *, media_type: str | None = None, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create content from a URI, can be both data URI or external URI. Use this when you already have a properly formed data URI @@ -720,7 +720,7 @@ class Content: @classmethod def from_error( - cls: type[TContent], + cls: type[ContentT], *, message: str | None = None, error_code: str | None = None, @@ -728,7 +728,7 @@ class Content: annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create error content.""" return cls( "error", @@ -742,7 +742,7 @@ class Content: @classmethod def from_function_call( - cls: type[TContent], + cls: type[ContentT], call_id: str, name: str, *, @@ -751,7 +751,7 @@ class Content: annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create function call content.""" return cls( "function_call", @@ -766,7 +766,7 @@ class Content: @classmethod def from_function_result( - cls: type[TContent], + cls: type[ContentT], call_id: str, *, result: Any = None, @@ -774,7 +774,7 @@ class Content: annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create function result content.""" return cls( "function_result", @@ -788,13 +788,13 @@ class Content: @classmethod def from_usage( - cls: type[TContent], + cls: type[ContentT], usage_details: UsageDetails, *, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create usage content.""" return cls( "usage", @@ -806,7 +806,7 @@ class Content: @classmethod def from_hosted_file( - cls: type[TContent], + cls: type[ContentT], file_id: str, *, media_type: str | None = None, @@ -814,7 +814,7 @@ class Content: annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create hosted file content.""" return cls( "hosted_file", @@ -828,13 +828,13 @@ class Content: @classmethod def from_hosted_vector_store( - cls: type[TContent], + cls: type[ContentT], vector_store_id: str, *, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create hosted vector store content.""" return cls( "hosted_vector_store", @@ -846,14 +846,14 @@ class Content: @classmethod def from_code_interpreter_tool_call( - cls: type[TContent], + cls: type[ContentT], *, call_id: str | None = None, inputs: Sequence[Content] | None = None, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create code interpreter tool call content.""" return cls( "code_interpreter_tool_call", @@ -866,14 +866,14 @@ class Content: @classmethod def from_code_interpreter_tool_result( - cls: type[TContent], + cls: type[ContentT], *, call_id: str | None = None, outputs: Sequence[Content] | None = None, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create code interpreter tool result content.""" return cls( "code_interpreter_tool_result", @@ -886,13 +886,13 @@ class Content: @classmethod def from_image_generation_tool_call( - cls: type[TContent], + cls: type[ContentT], *, image_id: str | None = None, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create image generation tool call content.""" return cls( "image_generation_tool_call", @@ -904,14 +904,14 @@ class Content: @classmethod def from_image_generation_tool_result( - cls: type[TContent], + cls: type[ContentT], *, image_id: str | None = None, outputs: Any = None, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create image generation tool result content.""" return cls( "image_generation_tool_result", @@ -924,7 +924,7 @@ class Content: @classmethod def from_mcp_server_tool_call( - cls: type[TContent], + cls: type[ContentT], call_id: str, tool_name: str, *, @@ -933,7 +933,7 @@ class Content: annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create MCP server tool call content.""" return cls( "mcp_server_tool_call", @@ -948,14 +948,14 @@ class Content: @classmethod def from_mcp_server_tool_result( - cls: type[TContent], + cls: type[ContentT], call_id: str, *, output: Any = None, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create MCP server tool result content.""" return cls( "mcp_server_tool_result", @@ -968,14 +968,14 @@ class Content: @classmethod def from_function_approval_request( - cls: type[TContent], + cls: type[ContentT], id: str, function_call: Content, *, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create function approval request content.""" return cls( "function_approval_request", @@ -989,7 +989,7 @@ class Content: @classmethod def from_function_approval_response( - cls: type[TContent], + cls: type[ContentT], approved: bool, id: str, function_call: Content, @@ -997,7 +997,7 @@ class Content: annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create function approval response content.""" return cls( "function_approval_response", @@ -1091,7 +1091,7 @@ class Content: return f"Content(type={self.type})" @classmethod - def from_dict(cls: type[TContent], data: Mapping[str, Any]) -> TContent: + def from_dict(cls: type[ContentT], data: Mapping[str, Any]) -> ContentT: """Create a Content instance from a mapping.""" if not (content_type := data.get("type")): raise ValueError("Content mapping requires 'type'") @@ -1376,36 +1376,6 @@ class Content: # endregion -def _prepare_function_call_results_as_dumpable(content: Content | Any | list[Content | Any]) -> Any: - if isinstance(content, list): - # Particularly deal with lists of Content - return [_prepare_function_call_results_as_dumpable(item) for item in content] - if isinstance(content, dict): - return {k: _prepare_function_call_results_as_dumpable(v) for k, v in content.items()} - if isinstance(content, BaseModel): - return content.model_dump() - if hasattr(content, "to_dict"): - return content.to_dict(exclude={"raw_representation", "additional_properties"}) - # Handle objects with text attribute (e.g., MCP TextContent) - if hasattr(content, "text") and isinstance(content.text, str): - return content.text - return content - - -def prepare_function_call_results(content: Content | Any | list[Content | Any]) -> str: - """Prepare the values of the function call results.""" - if isinstance(content, Content): - # For BaseContent objects, use to_dict and serialize to JSON - # Use default=str to handle datetime and other non-JSON-serializable objects - return json.dumps(content.to_dict(exclude={"raw_representation", "additional_properties"}), default=str) - - dumpable = _prepare_function_call_results_as_dumpable(content) - if isinstance(dumpable, str): - return dumpable - # fallback - use default=str to handle datetime and other non-JSON-serializable objects - return json.dumps(dumpable, default=str) - - # region Chat Response constants RoleLiteral = Literal["system", "user", "assistant", "tool"] @@ -1419,14 +1389,14 @@ Known values: "system", "user", "assistant", "tool" Examples: .. code-block:: python - from agent_framework import ChatMessage + from agent_framework import Message # Use string values directly - user_msg = ChatMessage("user", ["Hello"]) - assistant_msg = ChatMessage("assistant", ["Hi there!"]) + user_msg = Message("user", ["Hello"]) + assistant_msg = Message("assistant", ["Hi there!"]) # Custom roles are also supported - custom_msg = ChatMessage("custom", ["Custom role message"]) + custom_msg = Message("custom", ["Custom role message"]) # Compare roles directly as strings if user_msg.role == "user": @@ -1460,10 +1430,10 @@ Examples: """ -# region ChatMessage +# region Message -class ChatMessage(SerializationMixin): +class Message(SerializationMixin): """Represents a chat message. Attributes: @@ -1478,17 +1448,17 @@ class ChatMessage(SerializationMixin): Examples: .. code-block:: python - from agent_framework import ChatMessage, Content + from agent_framework import Message, Content # Create a message with text content - user_msg = ChatMessage("user", ["What's the weather?"]) + user_msg = Message("user", ["What's the weather?"]) print(user_msg.text) # "What's the weather?" # Create a system message - system_msg = ChatMessage("system", ["You are a helpful assistant."]) + system_msg = Message("system", ["You are a helpful assistant."]) # Create a message with mixed content types - assistant_msg = ChatMessage( + assistant_msg = Message( "assistant", ["The weather is sunny!", Content.from_image_uri("https://...")], ) @@ -1498,13 +1468,13 @@ class ChatMessage(SerializationMixin): msg_dict = user_msg.to_dict() # {'type': 'chat_message', 'role': 'user', # 'contents': [{'type': 'text', 'text': "What's the weather?"}], 'additional_properties': {}} - restored_msg = ChatMessage.from_dict(msg_dict) + restored_msg = Message.from_dict(msg_dict) print(restored_msg.text) # "What's the weather?" # Serialization - to_json and from_json msg_json = user_msg.to_json() # '{"type": "chat_message", "role": "user", "contents": [...], ...}' - restored_from_json = ChatMessage.from_json(msg_json) + restored_from_json = Message.from_json(msg_json) print(restored_from_json.role) # "user" """ @@ -1522,7 +1492,7 @@ class ChatMessage(SerializationMixin): additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any | None = None, ) -> None: - """Initialize ChatMessage. + """Initialize Message. Args: role: The role of the author of the message (e.g., "user", "assistant", "system", "tool"). @@ -1567,86 +1537,86 @@ class ChatMessage(SerializationMixin): def prepare_messages( - messages: str | Content | ChatMessage | Sequence[str | Content | ChatMessage], + messages: str | Content | Message | Sequence[str | Content | Message], system_instructions: str | Sequence[str] | None = None, -) -> list[ChatMessage]: - """Convert various message input formats into a list of ChatMessage objects. +) -> list[Message]: + """Convert various message input formats into a list of Message objects. Args: messages: The input messages in various supported formats. Can be: - A string (converted to a user message) - - A Content object (wrapped in a user ChatMessage) - - A ChatMessage object + - A Content object (wrapped in a user Message) + - A Message object - A sequence containing any mix of the above system_instructions: The system instructions. They will be inserted to the start of the messages list. Returns: - A list of ChatMessage objects. + A list of Message objects. """ if system_instructions is not None: if isinstance(system_instructions, str): system_instructions = [system_instructions] - system_instruction_messages = [ChatMessage("system", [instr]) for instr in system_instructions] + system_instruction_messages = [Message("system", [instr]) for instr in system_instructions] else: system_instruction_messages = [] if isinstance(messages, str): - return [*system_instruction_messages, ChatMessage("user", [messages])] + return [*system_instruction_messages, Message("user", [messages])] if isinstance(messages, Content): - return [*system_instruction_messages, ChatMessage("user", [messages])] - if isinstance(messages, ChatMessage): + return [*system_instruction_messages, Message("user", [messages])] + if isinstance(messages, Message): return [*system_instruction_messages, messages] - return_messages: list[ChatMessage] = system_instruction_messages + return_messages: list[Message] = system_instruction_messages for msg in messages: if isinstance(msg, (str, Content)): - msg = ChatMessage("user", [msg]) + msg = Message("user", [msg]) return_messages.append(msg) return return_messages def normalize_messages( - messages: str | Content | ChatMessage | Sequence[str | Content | ChatMessage] | None = None, -) -> list[ChatMessage]: - """Normalize message inputs to a list of ChatMessage objects. + messages: str | Content | Message | Sequence[str | Content | Message] | None = None, +) -> list[Message]: + """Normalize message inputs to a list of Message objects. Args: messages: The input messages in various supported formats. Can be: - None (returns empty list) - A string (converted to a user message) - - A Content object (wrapped in a user ChatMessage) - - A ChatMessage object + - A Content object (wrapped in a user Message) + - A Message object - A sequence containing any mix of the above Returns: - A list of ChatMessage objects. + A list of Message objects. """ if messages is None: return [] if isinstance(messages, str): - return [ChatMessage("user", [messages])] + return [Message("user", [messages])] if isinstance(messages, Content): - return [ChatMessage("user", [messages])] + return [Message("user", [messages])] - if isinstance(messages, ChatMessage): + if isinstance(messages, Message): return [messages] - result: list[ChatMessage] = [] + result: list[Message] = [] for msg in messages: if isinstance(msg, (str, Content)): - result.append(ChatMessage("user", [msg])) + result.append(Message("user", [msg])) else: result.append(msg) return result def prepend_instructions_to_messages( - messages: list[ChatMessage], + messages: list[Message], instructions: str | Sequence[str] | None, role: RoleLiteral | str = "system", -) -> list[ChatMessage]: +) -> list[Message]: """Prepend instructions to a list of messages with a specified role. This is a helper method for chat clients that need to add instructions @@ -1654,7 +1624,7 @@ def prepend_instructions_to_messages( instructions (e.g., OpenAI uses "system", some providers might use "user"). Args: - messages: The existing list of ChatMessage objects. + messages: The existing list of Message objects. instructions: The instructions to prepend. Can be a single string or a sequence of strings. role: The role to use for the instruction messages. Defaults to "system". @@ -1664,9 +1634,9 @@ def prepend_instructions_to_messages( Examples: .. code-block:: python - from agent_framework import prepend_instructions_to_messages, ChatMessage + from agent_framework import prepend_instructions_to_messages, Message - messages = [ChatMessage("user", ["Hello"])] + messages = [Message("user", ["Hello"])] instructions = "You are a helpful assistant" # Prepend as system message (default) @@ -1681,7 +1651,7 @@ def prepend_instructions_to_messages( if isinstance(instructions, str): instructions = [instructions] - instruction_messages = [ChatMessage(role, [instr]) for instr in instructions] + instruction_messages = [Message(role, [instr]) for instr in instructions] return [*instruction_messages, *messages] @@ -1703,7 +1673,7 @@ def _process_update(response: ChatResponse | AgentResponse, update: ChatResponse is_new_message = True if is_new_message: - message = ChatMessage("assistant", []) + message = Message("assistant", []) response.messages.append(message) else: message = response.messages[-1] @@ -1760,6 +1730,7 @@ def _process_update(response: ChatResponse | AgentResponse, update: ChatResponse response.finish_reason = update.finish_reason if update.model_id is not None: response.model_id = update.model_id + response.continuation_token = update.continuation_token def _coalesce_text_content(contents: list[Content], type_str: Literal["text", "text_reasoning"]) -> None: @@ -1796,7 +1767,40 @@ def _finalize_response(response: ChatResponse | AgentResponse) -> None: _coalesce_text_content(msg.contents, "text_reasoning") -class ChatResponse(SerializationMixin, Generic[TResponseModel]): +# region ContinuationToken + + +class ContinuationToken(TypedDict): + """Opaque token for resuming long-running agent operations. + + A JSON-serializable dict used to poll for completion or resume a + streaming response. Presence on a response indicates the operation + is still in progress; ``None`` means the operation is complete. + + Each provider subclasses this with its own fields; consumers should + treat the token as opaque and simply pass it back to the same agent. + + Examples: + .. code-block:: python + + import json + + # Persist token across restarts + token_json = json.dumps(response.continuation_token) + + # Restore and resume + token = json.loads(token_json) + response = await agent.run( + session=session, + options={"continuation_token": token}, + ) + """ + + +# endregion + + +class ChatResponse(SerializationMixin, Generic[ResponseModelT]): """Represents the response to a chat request. Attributes: @@ -1812,17 +1816,17 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): raw_representation: The raw representation of the chat response from an underlying implementation. Note: - The `author_name` attribute is available on the `ChatMessage` objects inside `messages`, + The `author_name` attribute is available on the `Message` objects inside `messages`, not on the `ChatResponse` itself. Use `response.messages[0].author_name` to access the author name of individual messages. Examples: .. code-block:: python - from agent_framework import ChatResponse, ChatMessage + from agent_framework import ChatResponse, Message # Create a response with messages - msg = ChatMessage("assistant", ["The weather is sunny."]) + msg = Message("assistant", ["The weather is sunny."]) response = ChatResponse( messages=[msg], finish_reason="stop", @@ -1852,22 +1856,23 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): def __init__( self, *, - messages: ChatMessage | Sequence[ChatMessage] | None = None, + messages: Message | Sequence[Message] | None = None, response_id: str | None = None, conversation_id: str | None = None, model_id: str | None = None, created_at: CreatedAtT | None = None, finish_reason: FinishReasonLiteral | FinishReason | None = None, usage_details: UsageDetails | None = None, - value: TResponseModel | None = None, + value: ResponseModelT | None = None, response_format: type[BaseModel] | None = None, + continuation_token: ContinuationToken | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, ) -> None: """Initializes a ChatResponse with the provided parameters. Keyword Args: - messages: A single ChatMessage or sequence of ChatMessage objects to include in the response. + messages: A single Message or sequence of Message objects to include in the response. response_id: Optional ID of the chat response. conversation_id: Optional identifier for the state of the conversation. model_id: Optional model ID used in the creation of the chat response. @@ -1876,21 +1881,23 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): usage_details: Optional usage details for the chat response. value: Optional value of the structured output. response_format: Optional response format for the chat response. + continuation_token: Optional token for resuming a long-running background operation. + When present, indicates the operation is still in progress. additional_properties: Optional additional properties associated with the chat response. raw_representation: Optional raw representation of the chat response from an underlying implementation. """ if messages is None: - self.messages: list[ChatMessage] = [] - elif isinstance(messages, ChatMessage): + self.messages: list[Message] = [] + elif isinstance(messages, Message): self.messages = [messages] else: - # Handle both ChatMessage objects and dicts (for from_dict support) - processed_messages: list[ChatMessage] = [] + # Handle both Message objects and dicts (for from_dict support) + processed_messages: list[Message] = [] for msg in messages: - if isinstance(msg, ChatMessage): + if isinstance(msg, Message): processed_messages.append(msg) elif isinstance(msg, dict): - processed_messages.append(ChatMessage.from_dict(msg)) + processed_messages.append(Message.from_dict(msg)) else: processed_messages.append(msg) self.messages = processed_messages @@ -1903,10 +1910,11 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): finish_reason = finish_reason["value"] self.finish_reason = finish_reason self.usage_details = usage_details - self._value: TResponseModel | None = value + self._value: ResponseModelT | None = value self._response_format: type[BaseModel] | None = response_format self._value_parsed: bool = value is not None self.additional_properties = additional_properties or {} + self.continuation_token = continuation_token self.raw_representation: Any | list[Any] | None = raw_representation @overload @@ -1915,8 +1923,8 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): cls: type[ChatResponse[Any]], updates: Sequence[ChatResponseUpdate], *, - output_format_type: type[TResponseModelT], - ) -> ChatResponse[TResponseModelT]: ... + output_format_type: type[ResponseModelBoundT], + ) -> ChatResponse[ResponseModelBoundT]: ... @overload @classmethod @@ -1929,11 +1937,11 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): @classmethod def from_updates( - cls: type[TChatResponse], + cls: type[ChatResponseT], updates: Sequence[ChatResponseUpdate], *, output_format_type: type[BaseModel] | None = None, - ) -> TChatResponse: + ) -> ChatResponseT: """Joins multiple updates into a single ChatResponse. Example: @@ -1970,8 +1978,8 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): cls: type[ChatResponse[Any]], updates: AsyncIterable[ChatResponseUpdate], *, - output_format_type: type[TResponseModelT], - ) -> ChatResponse[TResponseModelT]: ... + output_format_type: type[ResponseModelBoundT], + ) -> ChatResponse[ResponseModelBoundT]: ... @overload @classmethod @@ -1984,11 +1992,11 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): @classmethod async def from_update_generator( - cls: type[TChatResponse], + cls: type[ChatResponseT], updates: AsyncIterable[ChatResponseUpdate], *, output_format_type: type[BaseModel] | None = None, - ) -> TChatResponse: + ) -> ChatResponseT: """Joins multiple updates into a single ChatResponse. Example: @@ -2018,10 +2026,10 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): @property def text(self) -> str: """Returns the concatenated text of all messages in the response.""" - return ("\n".join(message.text for message in self.messages if isinstance(message, ChatMessage))).strip() + return ("\n".join(message.text for message in self.messages if isinstance(message, Message))).strip() @property - def value(self) -> TResponseModel | None: + def value(self) -> ResponseModelT | None: """Get the parsed structured output value. If a response_format was provided and parsing hasn't been attempted yet, @@ -2037,7 +2045,7 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): and isinstance(self._response_format, type) and issubclass(self._response_format, BaseModel) ): - self._value = cast(TResponseModel, self._response_format.model_validate_json(self.text)) + self._value = cast(ResponseModelT, self._response_format.model_validate_json(self.text)) self._value_parsed = True return self._value @@ -2057,7 +2065,7 @@ class ChatResponseUpdate(SerializationMixin): author_name: The name of the author of the response update. This is primarily used in multi-agent scenarios to identify which agent or participant generated the response. When updates are combined into a `ChatResponse`, the `author_name` is propagated - to the resulting `ChatMessage` objects. + to the resulting `Message` objects. response_id: The ID of the response of which this update is a part. message_id: The ID of the message of which this update is a part. conversation_id: An identifier for the state of the conversation of which this update is a part. @@ -2109,6 +2117,7 @@ class ChatResponseUpdate(SerializationMixin): model_id: str | None = None, created_at: CreatedAtT | None = None, finish_reason: FinishReasonLiteral | FinishReason | None = None, + continuation_token: ContinuationToken | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, ) -> None: @@ -2124,6 +2133,8 @@ class ChatResponseUpdate(SerializationMixin): model_id: Optional model ID associated with this response update. created_at: Optional timestamp for the chat response update. finish_reason: Optional finish reason for the operation. + continuation_token: Optional token for resuming a long-running background operation. + When present, indicates the operation is still in progress. additional_properties: Optional additional properties associated with the chat response update. raw_representation: Optional raw representation of the chat response update from an underlying implementation. @@ -2151,6 +2162,7 @@ class ChatResponseUpdate(SerializationMixin): self.model_id = model_id self.created_at = created_at self.finish_reason = finish_reason + self.continuation_token = continuation_token self.additional_properties = additional_properties self.raw_representation = raw_representation @@ -2166,7 +2178,7 @@ class ChatResponseUpdate(SerializationMixin): # region AgentResponse -class AgentResponse(SerializationMixin, Generic[TResponseModel]): +class AgentResponse(SerializationMixin, Generic[ResponseModelT]): """Represents the response to an Agent run request. Provides one or more response messages and metadata about the response. @@ -2174,17 +2186,17 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): messages in scenarios involving function calls, RAG retrievals, or complex logic. Note: - The `author_name` attribute is available on the `ChatMessage` objects inside `messages`, + The `author_name` attribute is available on the `Message` objects inside `messages`, not on the `AgentResponse` itself. Use `response.messages[0].author_name` to access the author name of individual messages. Examples: .. code-block:: python - from agent_framework import AgentResponse, ChatMessage + from agent_framework import AgentResponse, Message # Create agent response - msg = ChatMessage("assistant", ["Task completed successfully."]) + msg = Message("assistant", ["Task completed successfully."]) response = AgentResponse(messages=[msg], response_id="run_123") print(response.text) # "Task completed successfully." @@ -2215,20 +2227,21 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): def __init__( self, *, - messages: ChatMessage | Sequence[ChatMessage] | None = None, + messages: Message | Sequence[Message] | None = None, response_id: str | None = None, agent_id: str | None = None, created_at: CreatedAtT | None = None, usage_details: UsageDetails | None = None, - value: TResponseModel | None = None, + value: ResponseModelT | None = None, response_format: type[BaseModel] | None = None, + continuation_token: ContinuationToken | None = None, raw_representation: Any | None = None, additional_properties: dict[str, Any] | None = None, ) -> None: """Initialize an AgentResponse. Keyword Args: - messages: A single ChatMessage or sequence of ChatMessage objects to include in the response. + messages: A single Message or sequence of Message objects to include in the response. response_id: The ID of the chat response. agent_id: The identifier of the agent that produced this response. Useful in multi-agent scenarios to track which agent generated the response. @@ -2236,21 +2249,23 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): usage_details: The usage details for the chat response. value: The structured output of the agent run response, if applicable. response_format: Optional response format for the agent response. + continuation_token: Optional token for resuming a long-running background operation. + When present, indicates the operation is still in progress. additional_properties: Any additional properties associated with the chat response. raw_representation: The raw representation of the chat response from an underlying implementation. """ if messages is None: - self.messages: list[ChatMessage] = [] - elif isinstance(messages, ChatMessage): + self.messages: list[Message] = [] + elif isinstance(messages, Message): self.messages = [messages] else: - # Handle both ChatMessage objects and dicts (for from_dict support) - processed_messages: list[ChatMessage] = [] + # Handle both Message objects and dicts (for from_dict support) + processed_messages: list[Message] = [] for msg in messages: - if isinstance(msg, ChatMessage): + if isinstance(msg, Message): processed_messages.append(msg) elif isinstance(msg, dict): - processed_messages.append(ChatMessage.from_dict(msg)) + processed_messages.append(Message.from_dict(msg)) else: processed_messages.append(msg) self.messages = processed_messages @@ -2258,10 +2273,11 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): self.agent_id = agent_id self.created_at = created_at self.usage_details = usage_details - self._value: TResponseModel | None = value + self._value: ResponseModelT | None = value self._response_format: type[BaseModel] | None = response_format self._value_parsed: bool = value is not None self.additional_properties = additional_properties or {} + self.continuation_token = continuation_token self.raw_representation = raw_representation @property @@ -2270,7 +2286,7 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): return "".join(msg.text for msg in self.messages) if self.messages else "" @property - def value(self) -> TResponseModel | None: + def value(self) -> ResponseModelT | None: """Get the parsed structured output value. If a response_format was provided and parsing hasn't been attempted yet, @@ -2286,7 +2302,7 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): and isinstance(self._response_format, type) and issubclass(self._response_format, BaseModel) ): - self._value = cast(TResponseModel, self._response_format.model_validate_json(self.text)) + self._value = cast(ResponseModelT, self._response_format.model_validate_json(self.text)) self._value_parsed = True return self._value @@ -2306,8 +2322,8 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): cls: type[AgentResponse[Any]], updates: Sequence[AgentResponseUpdate], *, - output_format_type: type[TResponseModelT], - ) -> AgentResponse[TResponseModelT]: ... + output_format_type: type[ResponseModelBoundT], + ) -> AgentResponse[ResponseModelBoundT]: ... @overload @classmethod @@ -2320,11 +2336,11 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): @classmethod def from_updates( - cls: type[TAgentRunResponse], + cls: type[AgentResponseT], updates: Sequence[AgentResponseUpdate], *, output_format_type: type[BaseModel] | None = None, - ) -> TAgentRunResponse: + ) -> AgentResponseT: """Joins multiple updates into a single AgentResponse. Args: @@ -2345,8 +2361,8 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): cls: type[AgentResponse[Any]], updates: AsyncIterable[AgentResponseUpdate], *, - output_format_type: type[TResponseModelT], - ) -> AgentResponse[TResponseModelT]: ... + output_format_type: type[ResponseModelBoundT], + ) -> AgentResponse[ResponseModelBoundT]: ... @overload @classmethod @@ -2359,11 +2375,11 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): @classmethod async def from_update_generator( - cls: type[TAgentRunResponse], + cls: type[AgentResponseT], updates: AsyncIterable[AgentResponseUpdate], *, output_format_type: type[BaseModel] | None = None, - ) -> TAgentRunResponse: + ) -> AgentResponseT: """Joins multiple updates into a single AgentResponse. Args: @@ -2393,7 +2409,7 @@ class AgentResponseUpdate(SerializationMixin): role: The role of the author of the response update. author_name: The name of the author of the response update. In multi-agent scenarios, this identifies which agent generated this update. When updates are combined into - an `AgentResponse`, the `author_name` is propagated to the resulting `ChatMessage` objects. + an `AgentResponse`, the `author_name` is propagated to the resulting `Message` objects. agent_id: The identifier of the agent that produced this update. Useful in multi-agent scenarios to track which agent generated specific parts of the response. response_id: The ID of the response of which this update is a part. @@ -2444,6 +2460,7 @@ class AgentResponseUpdate(SerializationMixin): response_id: str | None = None, message_id: str | None = None, created_at: CreatedAtT | None = None, + continuation_token: ContinuationToken | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, ) -> None: @@ -2458,6 +2475,8 @@ class AgentResponseUpdate(SerializationMixin): response_id: Optional ID of the response of which this update is a part. message_id: Optional ID of the message of which this update is a part. created_at: Optional timestamp for the chat response update. + continuation_token: Optional token for resuming a long-running background operation. + When present, indicates the operation is still in progress. additional_properties: Optional additional properties associated with the chat response update. raw_representation: Optional raw representation of the chat response update. @@ -2486,6 +2505,7 @@ class AgentResponseUpdate(SerializationMixin): self.response_id = response_id self.message_id = message_id self.created_at = created_at + self.continuation_token = continuation_token self.additional_properties = additional_properties self.raw_representation: Any | list[Any] | None = raw_representation @@ -2514,29 +2534,30 @@ def map_chat_to_agent_update(update: ChatResponseUpdate, agent_name: str | None) response_id=update.response_id, message_id=update.message_id, created_at=update.created_at, + continuation_token=update.continuation_token, additional_properties=update.additional_properties, raw_representation=update, ) # Type variables for ResponseStream -TUpdate = TypeVar("TUpdate") -TFinal = TypeVar("TFinal") -TOuterUpdate = TypeVar("TOuterUpdate") -TOuterFinal = TypeVar("TOuterFinal") +UpdateT = TypeVar("UpdateT") +FinalT = TypeVar("FinalT") +OuterUpdateT = TypeVar("OuterUpdateT") +OuterFinalT = TypeVar("OuterFinalT") -class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): +class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]): """Async stream wrapper that supports iteration and deferred finalization.""" def __init__( self, - stream: AsyncIterable[TUpdate] | Awaitable[AsyncIterable[TUpdate]], + stream: AsyncIterable[UpdateT] | Awaitable[AsyncIterable[UpdateT]], *, - finalizer: Callable[[Sequence[TUpdate]], TFinal | Awaitable[TFinal]] | None = None, - transform_hooks: list[Callable[[TUpdate], TUpdate | Awaitable[TUpdate] | None]] | None = None, + finalizer: Callable[[Sequence[UpdateT]], FinalT | Awaitable[FinalT]] | None = None, + transform_hooks: list[Callable[[UpdateT], UpdateT | Awaitable[UpdateT] | None]] | None = None, cleanup_hooks: list[Callable[[], Awaitable[None] | None]] | None = None, - result_hooks: list[Callable[[TFinal], TFinal | Awaitable[TFinal | None] | None]] | None = None, + result_hooks: list[Callable[[FinalT], FinalT | Awaitable[FinalT | None] | None]] | None = None, ) -> None: """A Async Iterable stream of updates. @@ -2552,16 +2573,16 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): """ self._stream_source = stream self._finalizer = finalizer - self._stream: AsyncIterable[TUpdate] | None = None - self._iterator: AsyncIterator[TUpdate] | None = None - self._updates: list[TUpdate] = [] + self._stream: AsyncIterable[UpdateT] | None = None + self._iterator: AsyncIterator[UpdateT] | None = None + self._updates: list[UpdateT] = [] self._consumed: bool = False self._finalized: bool = False - self._final_result: TFinal | None = None - self._transform_hooks: list[Callable[[TUpdate], TUpdate | Awaitable[TUpdate] | None]] = ( + self._final_result: FinalT | None = None + self._transform_hooks: list[Callable[[UpdateT], UpdateT | Awaitable[UpdateT] | None]] = ( transform_hooks if transform_hooks is not None else [] ) - self._result_hooks: list[Callable[[TFinal], TFinal | Awaitable[TFinal | None] | None]] = ( + self._result_hooks: list[Callable[[FinalT], FinalT | Awaitable[FinalT | None] | None]] = ( result_hooks if result_hooks is not None else [] ) self._cleanup_hooks: list[Callable[[], Awaitable[None] | None]] = ( @@ -2575,9 +2596,9 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): def map( self, - transform: Callable[[TUpdate], TOuterUpdate | Awaitable[TOuterUpdate]], - finalizer: Callable[[Sequence[TOuterUpdate]], TOuterFinal | Awaitable[TOuterFinal]], - ) -> ResponseStream[TOuterUpdate, TOuterFinal]: + transform: Callable[[UpdateT], OuterUpdateT | Awaitable[OuterUpdateT]], + finalizer: Callable[[Sequence[OuterUpdateT]], OuterFinalT | Awaitable[OuterFinalT]], + ) -> ResponseStream[OuterUpdateT, OuterFinalT]: """Create a new stream that transforms each update. The returned stream delegates iteration to this stream, ensuring single consumption. @@ -2619,8 +2640,8 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): def with_finalizer( self, - finalizer: Callable[[Sequence[TUpdate]], TOuterFinal | Awaitable[TOuterFinal]], - ) -> ResponseStream[TUpdate, TOuterFinal]: + finalizer: Callable[[Sequence[UpdateT]], OuterFinalT | Awaitable[OuterFinalT]], + ) -> ResponseStream[UpdateT, OuterFinalT]: """Create a new stream with a different finalizer. The returned stream delegates iteration to this stream, ensuring single consumption. @@ -2647,8 +2668,8 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): @classmethod def from_awaitable( cls, - awaitable: Awaitable[ResponseStream[TUpdate, TFinal]], - ) -> ResponseStream[TUpdate, TFinal]: + awaitable: Awaitable[ResponseStream[UpdateT, FinalT]], + ) -> ResponseStream[UpdateT, FinalT]: """Create a ResponseStream from an awaitable that resolves to a ResponseStream. This is useful when you have an async function that returns a ResponseStream @@ -2672,7 +2693,7 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): stream._wrap_inner = True return stream # type: ignore[return-value] - async def _get_stream(self) -> AsyncIterable[TUpdate]: + async def _get_stream(self) -> AsyncIterable[UpdateT]: if self._stream is None: if hasattr(self._stream_source, "__aiter__"): self._stream = self._stream_source # type: ignore[assignment] @@ -2686,10 +2707,10 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): return self._stream return self._stream # type: ignore[return-value] - def __aiter__(self) -> ResponseStream[TUpdate, TFinal]: + def __aiter__(self) -> ResponseStream[UpdateT, FinalT]: return self - async def __anext__(self) -> TUpdate: + async def __anext__(self) -> UpdateT: if self._iterator is None: stream = await self._get_stream() self._iterator = stream.__aiter__() @@ -2718,19 +2739,19 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): return update def __await__(self) -> Any: - async def _wrap() -> ResponseStream[TUpdate, TFinal]: + async def _wrap() -> ResponseStream[UpdateT, FinalT]: await self._get_stream() return self return _wrap().__await__() - async def get_final_response(self) -> TFinal: + async def get_final_response(self) -> FinalT: """Get the final response by applying the finalizer to all collected updates. If a finalizer is configured, it receives the list of updates and returns the final type. Result hooks are then applied in order to transform the result. - If no finalizer is configured, returns the collected updates as Sequence[TUpdate]. + If no finalizer is configured, returns the collected updates as Sequence[UpdateT]. For wrapped streams (created via .map() or .from_awaitable()): - The inner stream's finalizer is called first to produce the inner final result. @@ -2815,16 +2836,16 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): def with_transform_hook( self, - hook: Callable[[TUpdate], TUpdate | Awaitable[TUpdate] | None], - ) -> ResponseStream[TUpdate, TFinal]: + hook: Callable[[UpdateT], UpdateT | Awaitable[UpdateT] | None], + ) -> ResponseStream[UpdateT, FinalT]: """Register a transform hook executed for each update during iteration.""" self._transform_hooks.append(hook) return self def with_result_hook( self, - hook: Callable[[TFinal], TFinal | Awaitable[TFinal | None] | None], - ) -> ResponseStream[TUpdate, TFinal]: + hook: Callable[[FinalT], FinalT | Awaitable[FinalT | None] | None], + ) -> ResponseStream[UpdateT, FinalT]: """Register a result hook executed after finalization.""" self._result_hooks.append(hook) self._finalized = False @@ -2834,7 +2855,7 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): def with_cleanup_hook( self, hook: Callable[[], Awaitable[None] | None], - ) -> ResponseStream[TUpdate, TFinal]: + ) -> ResponseStream[UpdateT, FinalT]: """Register a cleanup hook executed after stream consumption (before finalizer).""" self._cleanup_hooks.append(hook) return self @@ -2849,7 +2870,7 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): await result @property - def updates(self) -> Sequence[TUpdate]: + def updates(self) -> Sequence[UpdateT]: return self._updates @@ -2920,10 +2941,10 @@ class _ChatOptionsBase(TypedDict, total=False): # Tool configuration (forward reference to avoid circular import) tools: ( - ToolProtocol + FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None ) tool_choice: ToolMode | Literal["auto", "required", "none"] @@ -2944,8 +2965,8 @@ class _ChatOptionsBase(TypedDict, total=False): if TYPE_CHECKING: - class ChatOptions(_ChatOptionsBase, Generic[TResponseModel], total=False): - response_format: type[TResponseModel] | Mapping[str, Any] | None # type: ignore[misc] + class ChatOptions(_ChatOptionsBase, Generic[ResponseModelT], total=False): + response_format: type[ResponseModelT] | Mapping[str, Any] | None # type: ignore[misc] else: ChatOptions = _ChatOptionsBase @@ -3013,17 +3034,17 @@ async def validate_chat_options(options: dict[str, Any]) -> dict[str, Any]: def normalize_tools( tools: ( - ToolProtocol + FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None ), -) -> list[ToolProtocol | MutableMapping[str, Any]]: +) -> list[FunctionTool | MutableMapping[str, Any]]: """Normalize tools into a list. Converts callables to FunctionTool objects and ensures all tools are either - ToolProtocol instances or MutableMappings. + FunctionTool instances or MutableMappings. Args: tools: Tools to normalize - can be a single tool, callable, or sequence. @@ -3048,16 +3069,16 @@ def normalize_tools( # List of tools tools = normalize_tools([my_tool, another_tool]) """ - final_tools: list[ToolProtocol | MutableMapping[str, Any]] = [] + final_tools: list[FunctionTool | MutableMapping[str, Any]] = [] if not tools: return final_tools if not isinstance(tools, Sequence) or isinstance(tools, (str, MutableMapping)): # Single tool (not a sequence, or is a mapping which shouldn't be treated as sequence) - if not isinstance(tools, (ToolProtocol, MutableMapping)): + if not isinstance(tools, (FunctionTool, MutableMapping)): return [tool(tools)] return [tools] for tool_item in tools: - if isinstance(tool_item, (ToolProtocol, MutableMapping)): + if isinstance(tool_item, (FunctionTool, MutableMapping)): final_tools.append(tool_item) else: # Convert callable to FunctionTool @@ -3067,17 +3088,17 @@ def normalize_tools( async def validate_tools( tools: ( - ToolProtocol + FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None ), -) -> list[ToolProtocol | MutableMapping[str, Any]]: +) -> list[FunctionTool | MutableMapping[str, Any]]: """Validate and normalize tools into a list. Converts callables to FunctionTool objects, expands MCP tools to their constituent - functions (connecting them if needed), and ensures all tools are either ToolProtocol + functions (connecting them if needed), and ensures all tools are either FunctionTool instances or MutableMappings. Args: @@ -3107,7 +3128,7 @@ async def validate_tools( normalized = normalize_tools(tools) # Handle MCP tool expansion (async-only) - final_tools: list[ToolProtocol | MutableMapping[str, Any]] = [] + final_tools: list[FunctionTool | MutableMapping[str, Any]] = [] for tool_ in normalized: # Import MCPTool here to avoid circular imports from ._mcp import MCPTool diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index c5666f7b26..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, ) @@ -52,8 +51,8 @@ from ._request_info_mixin import response_handler from ._runner import Runner from ._runner_context import ( InProcRunnerContext, - Message, RunnerContext, + WorkflowMessage, ) from ._validation import ( EdgeDuplicationError, @@ -92,7 +91,6 @@ __all__ = [ "GraphConnectivityError", "InMemoryCheckpointStorage", "InProcRunnerContext", - "Message", "Runner", "RunnerContext", "SingleEdgeGroup", @@ -108,7 +106,6 @@ __all__ = [ "WorkflowBuilder", "WorkflowCheckpoint", "WorkflowCheckpointException", - "WorkflowCheckpointSummary", "WorkflowContext", "WorkflowConvergenceException", "WorkflowErrorDetails", @@ -117,6 +114,7 @@ __all__ = [ "WorkflowEventType", "WorkflowException", "WorkflowExecutor", + "WorkflowMessage", "WorkflowRunResult", "WorkflowRunState", "WorkflowRunnerException", @@ -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 46161e61e4..ef2b127d45 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -6,22 +6,22 @@ import json import logging import sys import uuid -from collections.abc import AsyncIterable, Awaitable +from collections.abc import AsyncIterable, Awaitable, Sequence from dataclasses import dataclass from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast, overload -from agent_framework import ( +from .._agents import BaseAgent +from .._sessions import AgentSession, BaseContextProvider, BaseHistoryProvider, SessionContext +from .._types import ( AgentResponse, AgentResponseUpdate, - AgentThread, - BaseAgent, - ChatMessage, Content, + Message, + ResponseStream, UsageDetails, + add_usage_details, ) - -from .._types import add_usage_details from ..exceptions import AgentExecutionException from ._checkpoint import CheckpointStorage from ._events import ( @@ -79,6 +79,7 @@ class WorkflowAgent(BaseAgent): id: str | None = None, name: str | None = None, description: str | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, **kwargs: Any, ) -> None: """Initialize the WorkflowAgent. @@ -90,6 +91,7 @@ class WorkflowAgent(BaseAgent): id: Unique identifier for the agent. If None, will be generated. name: Optional name for the agent. description: Optional description of the agent. + context_providers: Optional sequence of context providers for the agent. **kwargs: Additional keyword arguments passed to BaseAgent. Note: @@ -107,10 +109,10 @@ class WorkflowAgent(BaseAgent): except KeyError as exc: # Defensive: workflow lacks a configured entry point raise ValueError("Workflow's start executor is not defined.") from exc - if not any(is_type_compatible(list[ChatMessage], input_type) for input_type in start_executor.input_types): - raise ValueError("Workflow's start executor cannot handle list[ChatMessage]") + if not any(is_type_compatible(list[Message], input_type) for input_type in start_executor.input_types): + raise ValueError("Workflow's start executor cannot handle list[Message]") - super().__init__(id=id, name=name, description=description, **kwargs) + super().__init__(id=id, name=name, description=description, context_providers=context_providers, **kwargs) self._workflow: Workflow = workflow self._pending_requests: dict[str, WorkflowEvent[Any]] = {} @@ -127,22 +129,22 @@ class WorkflowAgent(BaseAgent): @overload def run( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[True], - thread: AgentThread | None = None, + session: AgentSession | None = None, checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, **kwargs: Any, - ) -> AsyncIterable[AgentResponseUpdate]: ... + ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ... @overload async def run( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[False] = ..., - thread: AgentThread | None = None, + session: AgentSession | None = None, checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, **kwargs: Any, @@ -150,14 +152,14 @@ class WorkflowAgent(BaseAgent): def run( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, **kwargs: Any, - ) -> AsyncIterable[AgentResponseUpdate] | Awaitable[AgentResponse]: + ) -> ResponseStream[AgentResponseUpdate, AgentResponse] | Awaitable[AgentResponse]: """Get a response from the workflow agent. Args: @@ -167,7 +169,7 @@ class WorkflowAgent(BaseAgent): Keyword Args: stream: If True, returns an async iterable of updates. If False (default), returns an awaitable AgentResponse. - thread: The conversation thread. If None, a new thread will be created. + session: The agent session for conversation context. checkpoint_id: ID of checkpoint to restore from. If provided, the workflow resumes from this checkpoint instead of starting fresh. checkpoint_storage: Runtime checkpoint storage. When provided with checkpoint_id, @@ -184,82 +186,21 @@ class WorkflowAgent(BaseAgent): or AgentResponseUpdate objects. Request info events (type='request_info') will be converted to function call and approval request contents. """ + if messages is None: + messages = [] + response_id = str(uuid.uuid4()) if stream: - return self._run_streaming( - messages=messages, - thread=thread, - checkpoint_id=checkpoint_id, - checkpoint_storage=checkpoint_storage, - **kwargs, + return ResponseStream( + self._run_stream_impl(messages, response_id, session, checkpoint_id, checkpoint_storage, **kwargs), + finalizer=AgentResponse.from_updates, ) - return self._run_non_streaming( - messages=messages, - thread=thread, - checkpoint_id=checkpoint_id, - checkpoint_storage=checkpoint_storage, - **kwargs, - ) - - async def _run_non_streaming( - self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, - *, - thread: AgentThread | None = None, - checkpoint_id: str | None = None, - checkpoint_storage: CheckpointStorage | None = None, - **kwargs: Any, - ) -> AgentResponse: - """Internal non-streaming implementation.""" - input_messages = normalize_messages_input(messages) - thread = thread or self.get_new_thread() - response_id = str(uuid.uuid4()) - - response = await self._run_impl( - input_messages, response_id, thread, checkpoint_id, checkpoint_storage, **kwargs - ) - - # Notify thread of new messages (both input and response messages) - await self._notify_thread_of_new_messages(thread, input_messages, response.messages) - - return response - - async def _run_streaming( - self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, - *, - thread: AgentThread | None = None, - checkpoint_id: str | None = None, - checkpoint_storage: CheckpointStorage | None = None, - **kwargs: Any, - ) -> AsyncIterable[AgentResponseUpdate]: - """Internal streaming implementation. - - Yields AgentResponseUpdate objects. Output events (type='output') from the workflow - are converted to updates. Request info events (type='request_info') are converted - to function call and approval request contents. - """ - input_messages = normalize_messages_input(messages) - thread = thread or self.get_new_thread() - response_updates: list[AgentResponseUpdate] = [] - response_id = str(uuid.uuid4()) - - async for update in self._run_stream_impl( - input_messages, response_id, thread, checkpoint_id, checkpoint_storage, **kwargs - ): - response_updates.append(update) - yield update - - # Convert updates to final response. - response = self.merge_updates(response_updates, response_id) - - # Notify thread of new messages (both input and response messages) - await self._notify_thread_of_new_messages(thread, input_messages, response.messages) + return self._run_impl(messages, response_id, session, checkpoint_id, checkpoint_storage, **kwargs) async def _run_impl( self, - input_messages: list[ChatMessage], + messages: str | Message | Sequence[str | Message], response_id: str, - thread: AgentThread, + session: AgentSession | None, checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, **kwargs: Any, @@ -267,9 +208,9 @@ class WorkflowAgent(BaseAgent): """Internal implementation of non-streaming execution. Args: - input_messages: Normalized input messages to process. + messages: Normalized input messages to process. response_id: The unique response ID for this workflow execution. - thread: The conversation thread containing message history. + session: The agent session for conversation context. checkpoint_id: ID of checkpoint to restore from. checkpoint_storage: Runtime checkpoint storage. **kwargs: Additional keyword arguments passed through to the underlying @@ -278,20 +219,44 @@ class WorkflowAgent(BaseAgent): Returns: An AgentResponse representing the workflow execution results. """ + input_messages = normalize_messages_input(messages) + + # run the context providers with the session + session_context = SessionContext( + session_id=session.session_id if session else None, + service_session_id=session.service_session_id if session else None, + input_messages=input_messages or [], + options={}, + ) + state = session.state if session else {} + for provider in self.context_providers: + if isinstance(provider, BaseHistoryProvider) and not provider.load_messages: + continue + await provider.before_run( + agent=self, # type: ignore[arg-type] + session=session, # type: ignore[arg-type] + context=session_context, + state=state, + ) + # combine the messages + session_messages: list[Message] = session_context.get_messages(include_input=True) + output_events: list[WorkflowEvent[Any]] = [] async for event in self._run_core( - input_messages, thread, checkpoint_id, checkpoint_storage, streaming=False, **kwargs + session_messages, checkpoint_id, checkpoint_storage, streaming=False, **kwargs ): if event.type == "output" or event.type == "request_info": output_events.append(event) - return self._convert_workflow_events_to_agent_response(response_id, output_events) + result = self._convert_workflow_events_to_agent_response(response_id, output_events) + await self._run_after_providers(session=session, context=session_context) + return result async def _run_stream_impl( self, - input_messages: list[ChatMessage], + messages: str | Message | Sequence[str | Message], response_id: str, - thread: AgentThread, + session: AgentSession | None, checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, **kwargs: Any, @@ -299,9 +264,9 @@ class WorkflowAgent(BaseAgent): """Internal implementation of streaming execution. Args: - input_messages: Normalized input messages to process. + messages: Input messages to process. response_id: The unique response ID for this workflow execution. - thread: The conversation thread containing message history. + session: The agent session for conversation context. checkpoint_id: ID of checkpoint to restore from. checkpoint_storage: Runtime checkpoint storage. **kwargs: Additional keyword arguments passed through to the underlying @@ -310,17 +275,39 @@ class WorkflowAgent(BaseAgent): Yields: AgentResponseUpdate objects representing the workflow execution progress. """ + input_messages = normalize_messages_input(messages) + + # run the context providers with the session + session_context = SessionContext( + session_id=session.session_id if session else None, + service_session_id=session.service_session_id if session else None, + input_messages=input_messages or [], + options={}, + ) + state = session.state if session else {} + for provider in self.context_providers: + if isinstance(provider, BaseHistoryProvider) and not provider.load_messages: + continue + await provider.before_run( + agent=self, # type: ignore[arg-type] + session=session, # type: ignore[arg-type] + context=session_context, + state=state, + ) + # combine the messages + + session_messages: list[Message] = session_context.get_messages(include_input=True) async for event in self._run_core( - input_messages, thread, checkpoint_id, checkpoint_storage, streaming=True, **kwargs + session_messages, checkpoint_id, checkpoint_storage, streaming=True, **kwargs ): updates = self._convert_workflow_event_to_agent_response_updates(response_id, event) for update in updates: yield update + await self._run_after_providers(session=session, context=session_context) async def _run_core( self, - input_messages: list[ChatMessage], - thread: AgentThread, + input_messages: Sequence[Message], checkpoint_id: str | None, checkpoint_storage: CheckpointStorage | None, streaming: bool, @@ -330,7 +317,6 @@ class WorkflowAgent(BaseAgent): Args: input_messages: Normalized input messages to process. - thread: The conversation thread containing message history. checkpoint_id: ID of checkpoint to restore from. checkpoint_storage: Runtime checkpoint storage. streaming: Whether to use streaming workflow methods. @@ -371,10 +357,9 @@ class WorkflowAgent(BaseAgent): yield event else: - conversation_messages = await self._build_conversation_messages(thread, input_messages) if streaming: async for event in self.workflow.run( - message=conversation_messages, + message=input_messages, stream=True, checkpoint_storage=checkpoint_storage, **kwargs, @@ -382,7 +367,7 @@ class WorkflowAgent(BaseAgent): yield event else: for event in await self.workflow.run( - message=conversation_messages, + message=input_messages, checkpoint_storage=checkpoint_storage, **kwargs, ): @@ -390,29 +375,7 @@ class WorkflowAgent(BaseAgent): # endregion Run Methods - async def _build_conversation_messages( - self, - thread: AgentThread, - input_messages: list[ChatMessage], - ) -> list[ChatMessage]: - """Build the complete conversation by prepending thread history to input messages. - - Args: - thread: The conversation thread containing message history. - input_messages: The new input messages to append. - - Returns: - A list of ChatMessage objects representing the full conversation. - """ - conversation_messages: list[ChatMessage] = [] - if thread.message_store: - history = await thread.message_store.list_messages() - if history: - conversation_messages.extend(history) - conversation_messages.extend(input_messages) - return conversation_messages - - def _process_pending_requests(self, input_messages: list[ChatMessage]) -> dict[str, Any]: + def _process_pending_requests(self, input_messages: Sequence[Message]) -> dict[str, Any]: """Process pending requests by extracting function responses and updating state. Args: @@ -444,7 +407,7 @@ class WorkflowAgent(BaseAgent): output_events: list[WorkflowEvent[Any]], ) -> AgentResponse: """Convert a list of workflow output events to an AgentResponse.""" - messages: list[ChatMessage] = [] + messages: list[Message] = [] raw_representations: list[object] = [] merged_usage: UsageDetails | None = None latest_created_at: str | None = None @@ -453,7 +416,7 @@ class WorkflowAgent(BaseAgent): if output_event.type == "request_info": function_call, approval_request = self._process_request_info_event(output_event) messages.append( - ChatMessage( + Message( contents=[function_call, approval_request], role="assistant", author_name=output_event.source_executor_id, @@ -484,11 +447,11 @@ class WorkflowAgent(BaseAgent): if data.created_at else latest_created_at ) - elif isinstance(data, ChatMessage): + elif isinstance(data, Message): messages.append(data) raw_representations.append(data.raw_representation) - elif is_instance_of(data, list[ChatMessage]): - chat_messages = cast(list[ChatMessage], data) + elif is_instance_of(data, list[Message]): + chat_messages = cast(list[Message], data) messages.extend(chat_messages) raw_representations.append(data) else: @@ -497,7 +460,7 @@ class WorkflowAgent(BaseAgent): continue messages.append( - ChatMessage( + Message( contents=contents, role="assistant", author_name=output_event.executor_id, @@ -591,7 +554,7 @@ class WorkflowAgent(BaseAgent): ) ) return updates - if isinstance(data, ChatMessage): + if isinstance(data, Message): return [ AgentResponseUpdate( contents=list(data.contents), @@ -603,9 +566,9 @@ class WorkflowAgent(BaseAgent): raw_representation=data, ) ] - if is_instance_of(data, list[ChatMessage]): - # Convert each ChatMessage to an AgentResponseUpdate - chat_messages = cast(list[ChatMessage], data) + if is_instance_of(data, list[Message]): + # Convert each Message to an AgentResponseUpdate + chat_messages = cast(list[Message], data) updates = [] for msg in chat_messages: updates.append( @@ -669,7 +632,7 @@ class WorkflowAgent(BaseAgent): # Ignore workflow-internal events return [] - def _extract_function_responses(self, input_messages: list[ChatMessage]) -> dict[str, Any]: + def _extract_function_responses(self, input_messages: Sequence[Message]) -> dict[str, Any]: """Extract function responses from input messages.""" function_responses: dict[str, Any] = {} for message in input_messages: @@ -718,7 +681,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): @@ -820,7 +783,7 @@ class WorkflowAgent(BaseAgent): ) # PHASE 2: CONVERT GROUPED UPDATES TO RESPONSES AND MERGE - final_messages: list[ChatMessage] = [] + final_messages: list[Message] = [] merged_usage: UsageDetails | None = None latest_created_at: str | None = None merged_additional_properties: dict[str, Any] | None = None diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 4158380086..3b10579055 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -2,6 +2,7 @@ import logging import sys +from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass from typing import Any, cast @@ -10,12 +11,10 @@ from typing_extensions import Never from agent_framework import Content from .._agents import SupportsAgentRun -from .._threads import AgentThread -from .._types import AgentResponse, AgentResponseUpdate, ChatMessage +from .._sessions import AgentSession +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 @@ -40,7 +39,7 @@ class AgentExecutorRequest: If False, the messages will be saved to the executor's cache but not sent to the agent. """ - messages: list[ChatMessage] + messages: list[Message] should_respond: bool = True @@ -58,7 +57,7 @@ class AgentExecutorResponse: executor_id: str agent_response: AgentResponse - full_conversation: list[ChatMessage] | None = None + full_conversation: list[Message] | None = None class AgentExecutor(Executor): @@ -82,14 +81,14 @@ class AgentExecutor(Executor): self, agent: SupportsAgentRun, *, - agent_thread: AgentThread | None = None, + session: AgentSession | None = None, id: str | None = None, ): """Initialize the executor with a unique identifier. Args: agent: The agent to be wrapped by this executor. - agent_thread: The thread to use for running the agent. If None, a new thread will be created. + session: The session to use for running the agent. If None, a new session will be created. id: A unique identifier for the executor. If None, the agent's name will be used if available. """ # Prefer provided id; else use agent.name if present; else generate deterministic prefix @@ -98,15 +97,15 @@ class AgentExecutor(Executor): raise ValueError("Agent must have a non-empty name or id or an explicit id must be provided.") super().__init__(exec_id) self._agent = agent - self._agent_thread = agent_thread or self._agent.get_new_thread() + self._session = session or self._agent.create_session() self._pending_agent_requests: dict[str, Content] = {} self._pending_responses_to_agent: list[Content] = [] # AgentExecutor maintains an internal cache of messages in between runs - self._cache: list[ChatMessage] = [] + self._cache: list[Message] = [] # This tracks the full conversation after each run - self._full_conversation: list[ChatMessage] = [] + self._full_conversation: list[Message] = [] @property def description(self) -> str | None: @@ -157,20 +156,20 @@ class AgentExecutor(Executor): @handler async def from_message( self, - message: ChatMessage, + message: Message, ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate], ) -> None: - """Accept a single ChatMessage as input.""" + """Accept a single Message as input.""" self._cache = normalize_messages_input(message) await self._run_agent_and_emit(ctx) @handler async def from_messages( self, - messages: list[str | ChatMessage], + messages: list[str | Message], ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate], ) -> None: - """Accept a list of chat inputs (strings or ChatMessage) as conversation context.""" + """Accept a list of chat inputs (strings or Message) as conversation context.""" self._cache = normalize_messages_input(messages) await self._run_agent_and_emit(ctx) @@ -198,7 +197,7 @@ class AgentExecutor(Executor): # Use role="tool" for function_result responses (from declaration-only tools) # so the LLM receives proper tool results instead of orphaned tool_calls. role = "tool" if all(r.type == "function_result" for r in self._pending_responses_to_agent) else "user" - self._cache = normalize_messages_input(ChatMessage(role=role, contents=self._pending_responses_to_agent)) + self._cache = normalize_messages_input(Message(role=role, contents=self._pending_responses_to_agent)) self._pending_responses_to_agent.clear() await self._run_agent_and_emit(ctx) @@ -206,37 +205,35 @@ class AgentExecutor(Executor): async def on_checkpoint_save(self) -> dict[str, Any]: """Capture current executor state for checkpointing. - NOTE: if the thread storage is on the server side, the full thread state - may not be serialized locally. Therefore, we are relying on the server-side - to ensure the thread state is preserved and immutable across checkpoints. - This is not the case for AzureAI Agents, but works for the Responses API. + NOTE: if the session uses service-side storage, the full session state + may not be serialized locally. Returns: - Dict containing serialized cache and thread state + Dict containing serialized cache and session state """ - # Check if using AzureAIAgentClient with server-side thread and warn about checkpointing limitations - if is_chat_agent(self._agent) and self._agent_thread.service_thread_id is not None: - client_class_name = self._agent.chat_client.__class__.__name__ - client_module = self._agent.chat_client.__class__.__module__ + # Check if using AzureAIAgentClient with server-side session and warn about checkpointing limitations + if is_chat_agent(self._agent) and self._session.service_session_id is not None: + client_class_name = self._agent.client.__class__.__name__ + client_module = self._agent.client.__class__.__module__ if client_class_name == "AzureAIAgentClient" and "azure_ai" in client_module: logger.warning( - "Checkpointing an AgentExecutor with AzureAIAgentClient that uses server-side threads. " - "Currently, checkpointing does not capture messages from server-side threads " - "(service_thread_id: %s). The thread state in checkpoints is not immutable and can be " + "Checkpointing an AgentExecutor with AzureAIAgentClient that uses server-side sessions. " + "Currently, checkpointing does not capture messages from server-side sessions " + "(service_session_id: %s). The session state in checkpoints is not immutable and can be " "modified by subsequent runs. If you need reliable checkpointing with Azure AI agents, " - "consider implementing a custom executor and managing the thread state yourself.", - self._agent_thread.service_thread_id, + "consider implementing a custom executor and managing the session state yourself.", + self._session.service_session_id, ) - serialized_thread = await self._agent_thread.serialize() + serialized_session = self._session.to_dict() return { - "cache": encode_chat_messages(self._cache), - "full_conversation": encode_chat_messages(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), + "cache": self._cache, + "full_conversation": self._full_conversation, + "agent_session": serialized_session, + "pending_agent_requests": self._pending_agent_requests, + "pending_responses_to_agent": self._pending_responses_to_agent, } @override @@ -246,12 +243,10 @@ 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) + self._cache = cache_payload except Exception as exc: logger.warning("Failed to restore cache: %s", exc) self._cache = [] @@ -261,32 +256,30 @@ class AgentExecutor(Executor): full_conversation_payload = state.get("full_conversation") if full_conversation_payload: try: - self._full_conversation = decode_chat_messages(full_conversation_payload) + self._full_conversation = full_conversation_payload except Exception as exc: logger.warning("Failed to restore full conversation: %s", exc) self._full_conversation = [] else: self._full_conversation = [] - thread_payload = state.get("agent_thread") - if thread_payload: + session_payload = state.get("agent_session") + if session_payload: try: - # Deserialize the thread state directly - self._agent_thread = await AgentThread.deserialize(thread_payload) - + self._session = AgentSession.from_dict(session_payload) except Exception as exc: - logger.warning("Failed to restore agent thread: %s", exc) - self._agent_thread = self._agent.get_new_thread() + logger.warning("Failed to restore agent session: %s", exc) + self._session = self._agent.create_session() else: - self._agent_thread = self._agent.get_new_thread() + self._session = self._agent.create_session() 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.""" @@ -310,10 +303,10 @@ class AgentExecutor(Executor): # Non-streaming mode: use run() and emit single event response = await self._run_agent(cast(WorkflowContext[Never, AgentResponse], ctx)) - # Always extend full conversation with cached messages plus agent outputs - # (agent_response.messages) after each run. This is to avoid losing context - # when agent did not complete and the cache is cleared when responses come back. - self._full_conversation.extend(list(self._cache) + (list(response.messages) if response else [])) + # Snapshot current conversation as cache + latest agent outputs. + # Do not append to prior snapshots: callers may provide full-history messages + # in request.messages, and extending would duplicate prior turns. + self._full_conversation = list(self._cache) + (list(response.messages) if response else []) if response is None: # Agent did not complete (e.g., waiting for user input); do not emit response @@ -333,17 +326,12 @@ class AgentExecutor(Executor): Returns: The complete AgentResponse, or None if waiting for user input. """ - run_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {}) - - # Build options dict with additional_function_arguments for tool kwargs propagation - options: dict[str, Any] | None = None - if run_kwargs: - options = {"additional_function_arguments": run_kwargs} + run_kwargs, options = self._prepare_agent_run_args(ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})) response = await self._agent.run( self._cache, stream=False, - thread=self._agent_thread, + session=self._session, options=options, **run_kwargs, ) @@ -367,30 +355,34 @@ class AgentExecutor(Executor): Returns: The complete AgentResponse, or None if waiting for user input. """ - run_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) or {} - - # Build options dict with additional_function_arguments for tool kwargs propagation - options: dict[str, Any] | None = None - if run_kwargs: - options = {"additional_function_arguments": run_kwargs} + run_kwargs, options = self._prepare_agent_run_args(ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) or {}) updates: list[AgentResponseUpdate] = [] - user_input_requests: list[Content] = [] - async for update in self._agent.run( + streamed_user_input_requests: list[Content] = [] + stream = self._agent.run( self._cache, stream=True, - thread=self._agent_thread, + session=self._session, options=options, **run_kwargs, - ): + ) + async for update in stream: updates.append(update) await ctx.yield_output(update) - if update.user_input_requests: - user_input_requests.extend(update.user_input_requests) + streamed_user_input_requests.extend(update.user_input_requests) - # Build the final AgentResponse from the collected updates - if is_chat_agent(self._agent): + # Prefer stream finalization when available so result hooks run + # (e.g., thread conversation updates). Fall back to reconstructing from updates + # for legacy/custom agents that return a plain async iterable. + # TODO(evmattso): Integrate workflow agent run handling around ResponseStream so + # AgentExecutor does not need this conditional stream-finalization branch. + maybe_get_final_response = getattr(stream, "get_final_response", None) + get_final_response = maybe_get_final_response if callable(maybe_get_final_response) else None + response: AgentResponse[Any] + if get_final_response is not None: + response = await cast(Callable[[], Awaitable[AgentResponse[Any]]], get_final_response)() + elif is_chat_agent(self._agent): response_format = self._agent.default_options.get("response_format") response = AgentResponse.from_updates( updates, @@ -400,6 +392,16 @@ class AgentExecutor(Executor): response = AgentResponse.from_updates(updates) # Handle any user input requests after the streaming completes + user_input_requests: list[Content] = [] + seen_request_ids: set[str] = set() + for user_input_request in [*streamed_user_input_requests, *response.user_input_requests]: + request_id = getattr(user_input_request, "id", None) + if isinstance(request_id, str) and request_id: + if request_id in seen_request_ids: + continue + seen_request_ids.add(request_id) + user_input_requests.append(user_input_request) + if user_input_requests: for user_input_request in user_input_requests: self._pending_agent_requests[user_input_request.id] = user_input_request # type: ignore[index] @@ -407,3 +409,55 @@ class AgentExecutor(Executor): return None return response + + @staticmethod + def _prepare_agent_run_args(raw_run_kwargs: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any] | None]: + """Prepare kwargs and options for agent.run(), avoiding duplicate option passing. + + Workflow-level kwargs are propagated to tool calls through + `options.additional_function_arguments`. If workflow kwargs include an + `options` key, merge it into the final options object and remove it from + kwargs before spreading `**run_kwargs`. + """ + run_kwargs = dict(raw_run_kwargs) + options_from_workflow = run_kwargs.pop("options", None) + workflow_additional_args = run_kwargs.pop("additional_function_arguments", None) + + options: dict[str, Any] = {} + if options_from_workflow is not None: + if isinstance(options_from_workflow, Mapping): + for key, value in options_from_workflow.items(): + if isinstance(key, str): + options[key] = value + else: + logger.warning( + "Ignoring non-mapping workflow 'options' kwarg of type %s for AgentExecutor %s.", + type(options_from_workflow).__name__, + AgentExecutor.__name__, + ) + + existing_additional_args = options.get("additional_function_arguments") + if isinstance(existing_additional_args, Mapping): + additional_args = {key: value for key, value in existing_additional_args.items() if isinstance(key, str)} + else: + additional_args = {} + + if workflow_additional_args is not None: + if isinstance(workflow_additional_args, Mapping): + additional_args.update({ + key: value for key, value in workflow_additional_args.items() if isinstance(key, str) + }) + else: + logger.warning( + "Ignoring non-mapping workflow 'additional_function_arguments' kwarg of type %s for AgentExecutor %s.", # noqa: E501 + type(workflow_additional_args).__name__, + AgentExecutor.__name__, + ) + + if run_kwargs: + additional_args.update(run_kwargs) + + if additional_args: + options["additional_function_arguments"] = additional_args + + return run_kwargs, options or None 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_history.py b/python/packages/core/agent_framework/_workflows/_conversation_history.py index 52d7d99c74..3df5282ee4 100644 --- a/python/packages/core/agent_framework/_workflows/_conversation_history.py +++ b/python/packages/core/agent_framework/_workflows/_conversation_history.py @@ -2,16 +2,16 @@ """Helpers for managing chat conversation history. -These utilities operate on standard `list[ChatMessage]` collections and simple +These utilities operate on standard `list[Message]` collections and simple dictionary snapshots so orchestrators can share logic without new mixins. """ from collections.abc import Sequence -from .._types import ChatMessage +from .._types import Message -def latest_user_message(conversation: Sequence[ChatMessage]) -> ChatMessage: +def latest_user_message(conversation: Sequence[Message]) -> Message: """Return the most recent user-authored message from `conversation`.""" for message in reversed(conversation): role_value = getattr(message.role, "value", message.role) @@ -20,7 +20,7 @@ def latest_user_message(conversation: Sequence[ChatMessage]) -> ChatMessage: raise ValueError("No user message in conversation") -def ensure_author(message: ChatMessage, fallback: str) -> ChatMessage: +def ensure_author(message: Message, fallback: str) -> Message: """Attach `fallback` author if message is missing `author_name`.""" message.author_name = message.author_name or fallback return message 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 22433e6775..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 ChatMessage - -from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value - -"""Utilities for serializing and deserializing chat conversations for persistence. - -These helpers convert rich `ChatMessage` 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[ChatMessage]) -> 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[ChatMessage]: - """Restore chat messages from checkpoint-safe payloads.""" - restored: list[ChatMessage] = [] - 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( - ChatMessage( # 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/_edge_runner.py b/python/packages/core/agent_framework/_workflows/_edge_runner.py index c87994b4b4..06188e9fb2 100644 --- a/python/packages/core/agent_framework/_workflows/_edge_runner.py +++ b/python/packages/core/agent_framework/_workflows/_edge_runner.py @@ -18,7 +18,7 @@ from ._edge import ( SwitchCaseEdgeGroup, ) from ._executor import Executor -from ._runner_context import Message, RunnerContext +from ._runner_context import RunnerContext, WorkflowMessage from ._state import State logger = logging.getLogger(__name__) @@ -38,7 +38,7 @@ class EdgeRunner(ABC): self._executors = executors @abstractmethod - async def send_message(self, message: Message, state: State, ctx: RunnerContext) -> bool: + async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool: """Send a message through the edge group. Args: @@ -52,7 +52,7 @@ class EdgeRunner(ABC): """ raise NotImplementedError - def _can_handle(self, executor_id: str, message: Message) -> bool: + def _can_handle(self, executor_id: str, message: WorkflowMessage) -> bool: """Check if an executor can handle the given message data.""" if executor_id not in self._executors: return False @@ -62,7 +62,7 @@ class EdgeRunner(ABC): self, target_id: str, source_ids: list[str], - message: Message, + message: WorkflowMessage, state: State, ctx: RunnerContext, ) -> None: @@ -90,7 +90,7 @@ class SingleEdgeRunner(EdgeRunner): super().__init__(edge_group, executors) self._edge = edge_group.edges[0] - async def send_message(self, message: Message, state: State, ctx: RunnerContext) -> bool: + async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool: """Send a message through the single edge.""" should_execute = False target_id: str | None = None @@ -162,7 +162,7 @@ class FanOutEdgeRunner(EdgeRunner): Callable[[Any, list[str]], list[str]] | None, getattr(edge_group, "selection_func", None) ) - async def send_message(self, message: Message, state: State, ctx: RunnerContext) -> bool: + async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool: """Send a message through all edges in the fan-out edge group.""" deliverable_edges: list[Edge] = [] single_target_edge: Edge | None = None @@ -283,9 +283,9 @@ class FanInEdgeRunner(EdgeRunner): self._edges = edge_group.edges # Buffer to hold messages before sending them to the target executor # Key is the source executor ID, value is a list of messages - self._buffer: dict[str, list[Message]] = defaultdict(list) + self._buffer: dict[str, list[WorkflowMessage]] = defaultdict(list) - async def send_message(self, message: Message, state: State, ctx: RunnerContext) -> bool: + async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool: """Send a message through all edges in the fan-in edge group.""" execution_data: dict[str, Any] | None = None with create_edge_group_processing_span( @@ -306,7 +306,7 @@ class FanInEdgeRunner(EdgeRunner): # Check if target can handle list of message data (fan-in aggregates multiple messages) if self._can_handle( - self._edges[0].target_id, Message(data=[message.data], source_id=message.source_id) + self._edges[0].target_id, WorkflowMessage(data=[message.data], source_id=message.source_id) ): # If the edge can handle the data, buffer the message self._buffer[message.source_id].append(message) @@ -334,7 +334,7 @@ class FanInEdgeRunner(EdgeRunner): source_span_ids = [msg.source_span_id for msg in messages_to_send if msg.source_span_id] # Create a new Message object for the aggregated data - aggregated_message = Message( + aggregated_message = WorkflowMessage( data=aggregated_data, source_id=self._edge_group.__class__.__name__, # This won't be used in self._execute_on_target. trace_contexts=trace_contexts, 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/_executor.py b/python/packages/core/agent_framework/_workflows/_executor.py index ffab65e3a3..f219c0c28f 100644 --- a/python/packages/core/agent_framework/_workflows/_executor.py +++ b/python/packages/core/agent_framework/_workflows/_executor.py @@ -17,7 +17,7 @@ from ._events import ( ) from ._model_utils import DictConvertible from ._request_info_mixin import RequestInfoMixin -from ._runner_context import Message, MessageType, RunnerContext +from ._runner_context import MessageType, RunnerContext, WorkflowMessage from ._state import State from ._typing_utils import is_instance_of, normalize_type_to_list, resolve_type_annotation from ._workflow_context import WorkflowContext, validate_workflow_context_annotation @@ -244,7 +244,7 @@ class Executor(RequestInfoMixin, DictConvertible): with create_processing_span( self.id, self.__class__.__name__, - str(MessageType.STANDARD if not isinstance(message, Message) else message.type), + str(MessageType.STANDARD if not isinstance(message, WorkflowMessage) else message.type), type(message).__name__, source_trace_contexts=trace_contexts, source_span_ids=source_span_ids, @@ -253,7 +253,7 @@ class Executor(RequestInfoMixin, DictConvertible): handler = self._find_handler(message) original_message = message - if isinstance(message, Message): + if isinstance(message, WorkflowMessage): # Unwrap raw data for handler call message = message.data @@ -265,7 +265,7 @@ class Executor(RequestInfoMixin, DictConvertible): trace_contexts=trace_contexts, source_span_ids=source_span_ids, request_id=original_message.original_request_info_event.request_id - if isinstance(original_message, Message) and original_message.original_request_info_event + if isinstance(original_message, WorkflowMessage) and original_message.original_request_info_event else None, ) @@ -351,7 +351,7 @@ class Executor(RequestInfoMixin, DictConvertible): # Add to unified handler specs list self._handler_specs.append({**handler_spec}) - def can_handle(self, message: Message) -> bool: + def can_handle(self, message: WorkflowMessage) -> bool: """Check if the executor can handle a given message type. Args: @@ -460,7 +460,7 @@ class Executor(RequestInfoMixin, DictConvertible): Returns: The handler function if found, None otherwise """ - if isinstance(message, Message): + if isinstance(message, WorkflowMessage): # Case where Message wrapper is passed instead of raw data # Handler can be a standard handler or a response handler if message.type == MessageType.STANDARD: diff --git a/python/packages/core/agent_framework/_workflows/_message_utils.py b/python/packages/core/agent_framework/_workflows/_message_utils.py index 920672cead..6d27a905ee 100644 --- a/python/packages/core/agent_framework/_workflows/_message_utils.py +++ b/python/packages/core/agent_framework/_workflows/_message_utils.py @@ -4,38 +4,38 @@ from collections.abc import Sequence -from agent_framework import ChatMessage +from agent_framework import Message def normalize_messages_input( - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, -) -> list[ChatMessage]: - """Normalize heterogeneous message inputs to a list of ChatMessage objects. + messages: str | Message | Sequence[str | Message] | None = None, +) -> list[Message]: + """Normalize heterogeneous message inputs to a list of Message objects. Args: - messages: String, ChatMessage, or sequence of either. None yields empty list. + messages: String, Message, or sequence of either. None yields empty list. Returns: - List of ChatMessage instances suitable for workflow consumption. + List of Message instances suitable for workflow consumption. """ if messages is None: return [] if isinstance(messages, str): - return [ChatMessage(role="user", text=messages)] + return [Message(role="user", text=messages)] - if isinstance(messages, ChatMessage): + if isinstance(messages, Message): return [messages] - normalized: list[ChatMessage] = [] + normalized: list[Message] = [] for item in messages: if isinstance(item, str): - normalized.append(ChatMessage(role="user", text=item)) - elif isinstance(item, ChatMessage): + normalized.append(Message(role="user", text=item)) + elif isinstance(item, Message): normalized.append(item) else: raise TypeError( - f"Messages sequence must contain only str or ChatMessage instances; found {type(item).__name__}." + f"Messages sequence must contain only str or Message instances; found {type(item).__name__}." ) return normalized diff --git a/python/packages/core/agent_framework/_workflows/_model_utils.py b/python/packages/core/agent_framework/_workflows/_model_utils.py index 72380901c6..0627d716a8 100644 --- a/python/packages/core/agent_framework/_workflows/_model_utils.py +++ b/python/packages/core/agent_framework/_workflows/_model_utils.py @@ -9,7 +9,7 @@ if sys.version_info >= (3, 11): else: from typing_extensions import Self # pragma: no cover -TModel = TypeVar("TModel", bound="DictConvertible") +ModelT = TypeVar("ModelT", bound="DictConvertible") class DictConvertible: @@ -19,7 +19,7 @@ class DictConvertible: raise NotImplementedError @classmethod - def from_dict(cls: type[TModel], data: dict[str, Any]) -> TModel: + def from_dict(cls: type[ModelT], data: dict[str, Any]) -> ModelT: return cls(**data) # type: ignore[arg-type] def clone(self, *, deep: bool = True) -> Self: @@ -31,7 +31,7 @@ class DictConvertible: return json.dumps(self.to_dict()) @classmethod - def from_json(cls: type[TModel], raw: str) -> TModel: + def from_json(cls: type[ModelT], raw: str) -> ModelT: import json data = json.loads(raw) diff --git a/python/packages/core/agent_framework/_workflows/_runner.py b/python/packages/core/agent_framework/_workflows/_runner.py index f3a475e034..bad37148b7 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 @@ -24,8 +19,8 @@ from ._exceptions import ( ) from ._executor import Executor from ._runner_context import ( - Message, RunnerContext, + WorkflowMessage, ) from ._state import State @@ -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) @@ -162,26 +158,13 @@ class Runner: self._running = False async def _run_iteration(self) -> None: - async def _deliver_messages(source_executor_id: str, messages: list[Message]) -> None: + async def _deliver_messages(source_executor_id: str, messages: list[WorkflowMessage]) -> None: """Outer loop to concurrently deliver messages from all sources to their targets.""" - async def _deliver_message_inner(edge_runner: EdgeRunner, message: Message) -> bool: + async def _deliver_message_inner(edge_runner: EdgeRunner, message: WorkflowMessage) -> bool: """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: Message) -> 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,37 @@ 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 + # `on_checkpoint_save()` writes via State.set(), which stages values in the + # pending buffer. Checkpoints serialize committed state only, so commit here + # to ensure executor snapshots are captured in this checkpoint. + self._state.commit() + 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 +233,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 +244,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 +267,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 +300,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 ed81026245..d52e135e91 100644 --- a/python/packages/core/agent_framework/_workflows/_runner_context.py +++ b/python/packages/core/agent_framework/_workflows/_runner_context.py @@ -4,50 +4,42 @@ 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") class MessageType(Enum): - """Enumeration of message types in the workflow.""" + """Enumeration of WorkflowMessage types in the workflow.""" STANDARD = "standard" - """A standard message between executors.""" + """A standard WorkflowMessage between executors.""" RESPONSE = "response" - """A response message to a pending request.""" + """A response WorkflowMessage to a pending request.""" @dataclass -class Message: - """A class representing a message in the workflow.""" +class WorkflowMessage: + """A class representing a WorkflowMessage in the workflow.""" data: Any source_id: str target_id: str | None = None type: MessageType = MessageType.STANDARD - # OpenTelemetry trace context fields for message propagation + # OpenTelemetry trace context fields for WorkflowMessage propagation # These are plural to support fan-in scenarios where multiple messages are aggregated trace_contexts: list[dict[str, str]] | None = None # W3C Trace Context headers from multiple sources source_span_ids: list[str] | None = None # Publishing span IDs for linking from multiple sources @@ -67,50 +59,38 @@ class Message: return self.source_span_ids[0] if self.source_span_ids else None def to_dict(self) -> dict[str, Any]: - """Convert the Message to a dictionary for serialization.""" + """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 - def from_dict(data: dict[str, Any]) -> Message: - """Create a Message from a dictionary.""" + def from_dict(data: dict[str, Any]) -> WorkflowMessage: + """Create a WorkflowMessage from a dictionary.""" # Validation if "data" not in data: - raise KeyError("Missing 'data' field in Message dictionary.") + raise KeyError("Missing 'data' field in WorkflowMessage dictionary.") if "source_id" not in data: - raise KeyError("Missing 'source_id' field in Message dictionary.") + raise KeyError("Missing 'source_id' field in WorkflowMessage dictionary.") - return Message( - data=decode_checkpoint_value(data["data"]), + return WorkflowMessage( + 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. @@ -119,15 +99,15 @@ class RunnerContext(Protocol): If checkpoint storage is not configured, checkpoint methods may raise. """ - async def send_message(self, message: Message) -> None: - """Send a message from the executor to the context. + async def send_message(self, WorkflowMessage: WorkflowMessage) -> None: + """Send a WorkflowMessage from the executor to the context. Args: - message: The message to be sent. + WorkflowMessage: The WorkflowMessage to be sent. """ ... - async def drain_messages(self) -> dict[str, list[Message]]: + async def drain_messages(self) -> dict[str, list[WorkflowMessage]]: """Drain all messages from the context. Returns: @@ -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: @@ -291,7 +273,7 @@ class InProcRunnerContext: Args: checkpoint_storage: Optional storage to enable checkpointing. """ - self._messages: dict[str, list[Message]] = {} + self._messages: dict[str, list[WorkflowMessage]] = {} # Event queue for immediate streaming of events self._event_queue: asyncio.Queue[WorkflowEvent] = asyncio.Queue() @@ -301,17 +283,16 @@ 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 # region Messaging and Events - async def send_message(self, message: Message) -> None: - self._messages.setdefault(message.source_id, []) - self._messages[message.source_id].append(message) + async def send_message(self, WorkflowMessage: WorkflowMessage) -> None: + self._messages.setdefault(WorkflowMessage.source_id, []) + self._messages[WorkflowMessage.source_id].append(WorkflowMessage) - async def drain_messages(self) -> dict[str, list[Message]]: + async def drain_messages(self) -> dict[str, list[WorkflowMessage]]: messages = copy(self._messages) self._messages.clear() return messages @@ -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] = [Message.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) @@ -504,7 +463,7 @@ class InProcRunnerContext: source_executor_id = event.source_executor_id # Create ResponseMessage instance - response_msg = Message( + response_msg = WorkflowMessage( data=response, source_id=INTERNAL_SOURCE_ID(source_executor_id), target_id=source_executor_id, diff --git a/python/packages/core/agent_framework/_workflows/_typing_utils.py b/python/packages/core/agent_framework/_workflows/_typing_utils.py index 5bff0900b6..41ed071f0a 100644 --- a/python/packages/core/agent_framework/_workflows/_typing_utils.py +++ b/python/packages/core/agent_framework/_workflows/_typing_utils.py @@ -3,19 +3,19 @@ from types import UnionType from typing import Any, TypeGuard, Union, cast, get_args, get_origin -from .._agents import ChatAgent +from .._agents import Agent -def is_chat_agent(agent: Any) -> TypeGuard[ChatAgent]: - """Check if the given agent is a ChatAgent. +def is_chat_agent(agent: Any) -> TypeGuard[Agent]: + """Check if the given agent is a Agent. Args: agent (Any): The agent to check. Returns: - TypeGuard[ChatAgent]: True if the agent is a ChatAgent, False otherwise. + TypeGuard[Agent]: True if the agent is a Agent, False otherwise. """ - return isinstance(agent, ChatAgent) + return isinstance(agent, Agent) def resolve_type_annotation( @@ -255,7 +255,7 @@ def is_type_compatible(source_type: type | UnionType | Any, target_type: type | A type is compatible if values of source_type can be assigned to variables of target_type. For example: - - list[ChatMessage] is compatible with list[str | ChatMessage] + - list[Message] is compatible with list[str | Message] - str is compatible with str | int - int is compatible with Any diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index 5f93644035..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: @@ -744,10 +756,19 @@ class Workflow(DictConvertible): ignoring data/state changes. Used to verify that a workflow's structure hasn't changed when resuming from checkpoints. """ - executors_signature = { - executor_id: f"{executor.__class__.__module__}.{executor.__class__.__name__}" - for executor_id, executor in self.executors.items() - } + from ._workflow_executor import WorkflowExecutor + + executors_signature = {} + for executor_id, executor in self.executors.items(): + executor_sig: Any = f"{executor.__class__.__module__}.{executor.__class__.__name__}" + + if isinstance(executor, WorkflowExecutor): + executor_sig = { + "type": executor_sig, + "sub_workflow": executor.workflow.graph_signature, + } + + executors_signature[executor_id] = executor_sig edge_groups_signature: list[dict[str, Any]] = [] for group in self.edge_groups: @@ -787,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 @@ -795,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. @@ -832,14 +848,14 @@ class Workflow(DictConvertible): def as_agent(self, name: str | None = None) -> WorkflowAgent: """Create a WorkflowAgent that wraps this workflow. - The returned agent converts standard agent inputs (strings, ChatMessage, or lists of these) - into a list[ChatMessage] that is passed to the workflow's start executor. This conversion + The returned agent converts standard agent inputs (strings, Message, or lists of these) + into a list[Message] that is passed to the workflow's start executor. This conversion happens in WorkflowAgent._normalize_messages() which transforms: - - str -> [ChatMessage(USER, [str])] - - ChatMessage -> [ChatMessage] - - list[str | ChatMessage] -> list[ChatMessage] (with string elements converted) + - str -> [Message(USER, [str])] + - Message -> [Message] + - list[str | Message] -> list[Message] (with string elements converted) - The workflow's start executor must accept list[ChatMessage] as an input type, otherwise + The workflow's start executor must accept list[Message] as an input type, otherwise initialization will fail with a ValueError. Args: @@ -849,7 +865,7 @@ class Workflow(DictConvertible): A WorkflowAgent instance that wraps this workflow. Raises: - ValueError: If the workflow's start executor cannot handle list[ChatMessage] input. + ValueError: If the workflow's start executor cannot handle list[Message] input. """ # Import here to avoid circular imports from ._agent import WorkflowAgent diff --git a/python/packages/core/agent_framework/_workflows/_workflow_builder.py b/python/packages/core/agent_framework/_workflows/_workflow_builder.py index 14fd512e17..1a71b3a49b 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_builder.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_builder.py @@ -2,12 +2,11 @@ import logging import sys +import uuid from collections.abc import Callable, Sequence -from dataclasses import dataclass from typing import Any from .._agents import SupportsAgentRun -from .._threads import AgentThread from ..observability import OtelAttr, capture_exception, create_workflow_span from ._agent_executor import AgentExecutor from ._agent_utils import resolve_agent_id @@ -40,76 +39,6 @@ else: logger = logging.getLogger(__name__) -@dataclass -class _EdgeRegistration: - """A data class representing an edge registration in the workflow builder. - - Args: - source: The registered source name. - target: The registered target name. - condition: An optional condition function `(data) -> bool | Awaitable[bool]`. - """ - - source: str - target: str - condition: EdgeCondition | None = None - - -@dataclass -class _FanOutEdgeRegistration: - """A data class representing a fan-out edge registration in the workflow builder. - - Args: - source: The registered source name. - targets: A list of registered target names. - """ - - source: str - targets: list[str] - - -@dataclass -class _FanInEdgeRegistration: - """A data class representing a fan-in edge registration in the workflow builder. - - Args: - sources: A list of registered source names. - target: The registered target name. - """ - - sources: list[str] - target: str - - -@dataclass -class _SwitchCaseEdgeGroupRegistration: - """A data class representing a switch-case edge group registration in the workflow builder. - - Args: - source: The registered source name. - cases: A list of case objects that determine the target executor for each message. - """ - - source: str - cases: list[Case | Default] - - -@dataclass -class _MultiSelectionEdgeGroupRegistration: - """A data class representing a multi-selection edge group registration in the workflow builder. - - Args: - source: The registered source name. - targets: A list of registered target names. - selection_func: A function that selects target executors for messages. - Takes (message, list[registered target names]) and returns list[registered target names]. - """ - - source: str - targets: list[str] - selection_func: Callable[[Any, list[str]], list[str]] - - class WorkflowBuilder: """A builder class for constructing workflows. @@ -136,14 +65,10 @@ class WorkflowBuilder: await ctx.yield_output(text[::-1]) - # Build a workflow - workflow = ( - WorkflowBuilder(start_executor="UpperCase") - .register_executor(lambda: UpperCaseExecutor(id="upper"), name="UpperCase") - .register_executor(lambda: ReverseExecutor(id="reverse"), name="Reverse") - .add_edge("UpperCase", "Reverse") - .build() - ) + upper = UpperCaseExecutor(id="upper") + reverse = ReverseExecutor(id="reverse") + + workflow = WorkflowBuilder(start_executor=upper).add_edge(upper, reverse).build() # Run the workflow events = await workflow.run("hello") @@ -156,46 +81,40 @@ class WorkflowBuilder: name: str | None = None, description: str | None = None, *, - start_executor: Executor | SupportsAgentRun | str, + start_executor: Executor | SupportsAgentRun, checkpoint_storage: CheckpointStorage | None = None, - output_executors: list[Executor | SupportsAgentRun | str] | None = None, + output_executors: list[Executor | SupportsAgentRun] | None = None, ): """Initialize the 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, - SupportsAgentRun instance, or the name of a registered executor factory. + start_executor: The starting executor for the workflow. Can be an Executor instance + or SupportsAgentRun instance. checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence. output_executors: Optional list of executors whose outputs should be collected. If not provided, outputs from all executors are collected. """ self._edge_groups: list[EdgeGroup] = [] self._executors: dict[str, Executor] = {} - self._start_executor: Executor | str | None = None + 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 # being created for the same agent. self._agent_wrappers: dict[str, Executor] = {} - # Registrations for lazy initialization of executors - self._edge_registry: list[ - _EdgeRegistration - | _FanOutEdgeRegistration - | _SwitchCaseEdgeGroupRegistration - | _MultiSelectionEdgeGroupRegistration - | _FanInEdgeRegistration - ] = [] - self._executor_registry: dict[str, Callable[[], Executor]] = {} - # Output executors filter; if set, only outputs from these executors are yielded - self._output_executors: list[Executor | SupportsAgentRun | str] = output_executors if output_executors else [] + self._output_executors: list[Executor | SupportsAgentRun] = output_executors if output_executors else [] # Set the start executor self._set_start_executor(start_executor) @@ -258,133 +177,10 @@ class WorkflowBuilder: f"WorkflowBuilder expected an Executor or SupportsAgentRun instance; got {type(candidate).__name__}." ) - def register_executor(self, factory_func: Callable[[], Executor], name: str | list[str]) -> Self: - """Register an executor factory function for lazy initialization. - - This method allows you to register a factory function that creates an executor. - The executor will be instantiated only when the workflow is built, enabling - deferred initialization and potentially reducing startup time. - - Args: - factory_func: A callable that returns an Executor instance when called. - name: The name(s) of the registered executor factory. This doesn't have to match - the executor's ID, but it must be unique within the workflow. - - Example: - .. code-block:: python - from typing_extensions import Never - from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler - - - class UpperCaseExecutor(Executor): - @handler - async def process(self, text: str, ctx: WorkflowContext[str]) -> None: - await ctx.send_message(text.upper()) - - - class ReverseExecutor(Executor): - @handler - async def process(self, text: str, ctx: WorkflowContext[Never, str]) -> None: - await ctx.yield_output(text[::-1]) - - - # Build a workflow - workflow = ( - WorkflowBuilder(start_executor="UpperCase") - .register_executor(lambda: UpperCaseExecutor(id="upper"), name="UpperCase") - .register_executor(lambda: ReverseExecutor(id="reverse"), name="Reverse") - .add_edge("UpperCase", "Reverse") - .build() - ) - - If multiple names are provided, the same factory function will be registered under each name. - - .. code-block:: python - - from agent_framework import WorkflowBuilder, Executor, WorkflowContext, handler - - - class LoggerExecutor(Executor): - @handler - async def log(self, message: str, ctx: WorkflowContext) -> None: - print(f"Log: {message}") - - - # Register the same executor factory under multiple names - workflow = ( - WorkflowBuilder(start_executor="ExecutorA") - .register_executor(lambda: LoggerExecutor(id="logger"), name=["ExecutorA", "ExecutorB"]) - .add_edge("ExecutorA", "ExecutorB") - .build() - """ - names = [name] if isinstance(name, str) else name - - for n in names: - if n in self._executor_registry: - raise ValueError(f"An executor factory with the name '{n}' is already registered.") - - for n in names: - self._executor_registry[n] = factory_func - - return self - - def register_agent( - self, - factory_func: Callable[[], SupportsAgentRun], - name: str, - agent_thread: AgentThread | None = None, - ) -> Self: - """Register an agent factory function for lazy initialization. - - This method allows you to register a factory function that creates an agent. - The agent will be instantiated and wrapped in an AgentExecutor only when the workflow is built, - enabling deferred initialization and potentially reducing startup time. - - Args: - factory_func: A callable that returns an SupportsAgentRun instance when called. - name: The name of the registered agent factory. This doesn't have to match - the agent's internal name. But it must be unique within the workflow. - agent_thread: The thread to use for running the agent. If None, a new thread will be created when - the agent is instantiated. - - Example: - .. code-block:: python - - from agent_framework import WorkflowBuilder - from agent_framework_anthropic import AnthropicAgent - - - # Build a workflow - workflow = ( - WorkflowBuilder(start_executor="SomeOtherExecutor") - .register_executor(lambda: ..., name="SomeOtherExecutor") - .register_agent( - lambda: AnthropicAgent(name="writer", model="claude-3-5-sonnet-20241022"), - name="WriterAgent", - output_response=True, - ) - .add_edge("SomeOtherExecutor", "WriterAgent") - .build() - ) - """ - if name in self._executor_registry: - raise ValueError(f"An agent factory with the name '{name}' is already registered.") - - def wrapped_factory() -> AgentExecutor: - agent = factory_func() - return AgentExecutor( - agent, - agent_thread=agent_thread, - ) - - self._executor_registry[name] = wrapped_factory - - return self - def add_edge( self, - source: Executor | SupportsAgentRun | str, - target: Executor | SupportsAgentRun | str, + source: Executor | SupportsAgentRun, + target: Executor | SupportsAgentRun, condition: EdgeCondition | None = None, ) -> Self: """Add a directed edge between two executors. @@ -393,17 +189,12 @@ class WorkflowBuilder: Messages sent by the source executor will be routed to the target executor. Args: - source: The source executor or registered name of the source factory for the edge. - target: The target executor or registered name of the target factory for the edge. + source: The source executor or agent for the edge. + target: The target executor or agent for the edge. condition: An optional condition function `(data) -> bool | Awaitable[bool]` that determines whether the edge should be traversed. Example: `lambda data: data["ready"]`. - Note: If instances are provided for both source and target, they will be shared across - all workflow instances created from the built Workflow. To avoid this, consider - registering the executors and agents using `register_executor` and `register_agent` - and referencing them by factory name for lazy initialization instead. - Returns: Self: The WorkflowBuilder instance for method chaining. @@ -426,39 +217,13 @@ class WorkflowBuilder: await ctx.yield_output(f"Processed {count} characters") - # Connect executors with an edge - workflow = ( - WorkflowBuilder(start_executor="ProcessorA") - .register_executor(lambda: ProcessorA(id="a"), name="ProcessorA") - .register_executor(lambda: ProcessorB(id="b"), name="ProcessorB") - .add_edge("ProcessorA", "ProcessorB") - .build() - ) + a = ProcessorA(id="a") + b = ProcessorB(id="b") - workflow = ( - WorkflowBuilder(start_executor="ProcessorA") - .register_executor(lambda: ProcessorA(id="a"), name="ProcessorA") - .register_executor(lambda: ProcessorB(id="b"), name="ProcessorB") - .add_edge("ProcessorA", "ProcessorB", condition=only_large_numbers) - .build() - ) + workflow = WorkflowBuilder(start_executor=a).add_edge(a, b).build() """ - if (isinstance(source, str) and not isinstance(target, str)) or ( - not isinstance(source, str) and isinstance(target, str) - ): - raise ValueError( - "Both source and target must be either registered factory names (str) or " - "Executor/SupportsAgentRun instances." - ) - - if isinstance(source, str) and isinstance(target, str): - # Both are names; defer resolution to build time - self._edge_registry.append(_EdgeRegistration(source=source, target=target, condition=condition)) - return self - - # Both are Executor/SupportsAgentRun instances; wrap and add now - source_exec = self._maybe_wrap_agent(source) # type: ignore[arg-type] - target_exec = self._maybe_wrap_agent(target) # type: ignore[arg-type] + source_exec = self._maybe_wrap_agent(source) + target_exec = self._maybe_wrap_agent(target) source_id = self._add_executor(source_exec) target_id = self._add_executor(target_exec) self._edge_groups.append(SingleEdgeGroup(source_id, target_id, condition)) @@ -466,8 +231,8 @@ class WorkflowBuilder: def add_fan_out_edges( self, - source: Executor | SupportsAgentRun | str, - targets: Sequence[Executor | SupportsAgentRun | str], + source: Executor | SupportsAgentRun, + targets: Sequence[Executor | SupportsAgentRun], ) -> Self: """Add multiple edges to the workflow where messages from the source will be sent to all targets. @@ -475,17 +240,12 @@ class WorkflowBuilder: Messages from the source will be broadcast to all target executors concurrently. Args: - source: The source executor or registered name of the source factory for the edges. - targets: A list of target executors or registered names of the target factories for the edges. + source: The source executor or agent for the edges. + targets: A list of target executors or agents for the edges. Returns: Self: The WorkflowBuilder instance for method chaining. - Note: If instances are provided for source and targets, they will be shared across - all workflow instances created from the built Workflow. To avoid this, consider - registering the executors and agents using `register_executor` and `register_agent` - and referencing them by factory name for lazy initialization instead. - Example: .. code-block:: python @@ -511,32 +271,14 @@ class WorkflowBuilder: print(f"ValidatorB: {data}") - # Broadcast to multiple validators - workflow = ( - WorkflowBuilder(start_executor="DataSource") - .register_executor(lambda: DataSource(id="source"), name="DataSource") - .register_executor(lambda: ValidatorA(id="val_a"), name="ValidatorA") - .register_executor(lambda: ValidatorB(id="val_b"), name="ValidatorB") - .add_fan_out_edges("DataSource", ["ValidatorA", "ValidatorB"]) - .build() - ) + source = DataSource(id="source") + val_a = ValidatorA(id="val_a") + val_b = ValidatorB(id="val_b") + + workflow = WorkflowBuilder(start_executor=source).add_fan_out_edges(source, [val_a, val_b]).build() """ - if (isinstance(source, str) and not all(isinstance(t, str) for t in targets)) or ( - not isinstance(source, str) and any(isinstance(t, str) for t in targets) - ): - raise ValueError( - "Both source and targets must be either registered factory names (str) or " - "Executor/SupportsAgentRun instances." - ) - - if isinstance(source, str) and all(isinstance(t, str) for t in targets): - # Both are names; defer resolution to build time - self._edge_registry.append(_FanOutEdgeRegistration(source=source, targets=list(targets))) # type: ignore - return self - - # Both are Executor/SupportsAgentRun instances; wrap and add now - source_exec = self._maybe_wrap_agent(source) # type: ignore[arg-type] - target_execs = [self._maybe_wrap_agent(t) for t in targets] # type: ignore[arg-type] + source_exec = self._maybe_wrap_agent(source) + target_execs = [self._maybe_wrap_agent(t) for t in targets] source_id = self._add_executor(source_exec) target_ids = [self._add_executor(t) for t in target_execs] self._edge_groups.append(FanOutEdgeGroup(source_id, target_ids)) # type: ignore[call-arg] @@ -545,7 +287,7 @@ class WorkflowBuilder: def add_switch_case_edge_group( self, - source: Executor | SupportsAgentRun | str, + source: Executor | SupportsAgentRun, cases: Sequence[Case | Default], ) -> Self: """Add an edge group that represents a switch-case statement. @@ -562,17 +304,12 @@ class WorkflowBuilder: (i.e., no condition matched). Args: - source: The source executor or registered name of the source factory for the edge group. + source: The source executor or agent for the edge group. cases: A list of case objects that determine the target executor for each message. Returns: Self: The WorkflowBuilder instance for method chaining. - Note: If instances are provided for source and case targets, they will be shared across - all workflow instances created from the built Workflow. To avoid this, consider - registering the executors and agents using `register_executor` and `register_agent` - and referencing them by factory name for lazy initialization instead. - Example: .. code-block:: python @@ -603,37 +340,23 @@ class WorkflowBuilder: print(f"Low score: {result.score}") - # Route based on score value + evaluator = Evaluator(id="eval") + high = HighScoreHandler(id="high") + low = LowScoreHandler(id="low") + workflow = ( - WorkflowBuilder(start_executor="Evaluator") - .register_executor(lambda: Evaluator(id="eval"), name="Evaluator") - .register_executor(lambda: HighScoreHandler(id="high"), name="HighScoreHandler") - .register_executor(lambda: LowScoreHandler(id="low"), name="LowScoreHandler") + WorkflowBuilder(start_executor=evaluator) .add_switch_case_edge_group( - "Evaluator", + evaluator, [ - Case(condition=lambda r: r.score > 10, target="HighScoreHandler"), - Default(target="LowScoreHandler"), + Case(condition=lambda r: r.score > 10, target=high), + Default(target=low), ], ) .build() ) """ - if (isinstance(source, str) and not all(isinstance(case.target, str) for case in cases)) or ( - not isinstance(source, str) and any(isinstance(case.target, str) for case in cases) - ): - raise ValueError( - "Both source and case targets must be either registered factory names (str) " - "or Executor/SupportsAgentRun instances." - ) - - if isinstance(source, str) and all(isinstance(case.target, str) for case in cases): - # Source is a name; defer resolution to build time - self._edge_registry.append(_SwitchCaseEdgeGroupRegistration(source=source, cases=list(cases))) # type: ignore - return self - - # Source is an Executor/SupportsAgentRun instance; wrap and add now - source_exec = self._maybe_wrap_agent(source) # type: ignore[arg-type] + source_exec = self._maybe_wrap_agent(source) source_id = self._add_executor(source_exec) # Convert case data types to internal types that only uses target_id. internal_cases: list[SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault] = [] @@ -651,8 +374,8 @@ class WorkflowBuilder: def add_multi_selection_edge_group( self, - source: Executor | SupportsAgentRun | str, - targets: Sequence[Executor | SupportsAgentRun | str], + source: Executor | SupportsAgentRun, + targets: Sequence[Executor | SupportsAgentRun], selection_func: Callable[[Any, list[str]], list[str]], ) -> Self: """Add an edge group that represents a multi-selection execution model. @@ -665,19 +388,14 @@ class WorkflowBuilder: and return a list of executor IDs indicating which target executors should receive the message. Args: - source: The source executor or registered name of the source factory for the edge group. - targets: A list of target executors or registered names of the target factories for the edges. + source: The source executor or agent for the edge group. + targets: A list of target executors or agents for the edges. selection_func: A function that selects target executors for messages. Takes (message, list[executor_id]) and returns list[executor_id]. Returns: Self: The WorkflowBuilder instance for method chaining. - Note: If instances are provided for source and targets, they will be shared across - all workflow instances created from the built Workflow. To avoid this, consider - registering the executors and agents using `register_executor` and `register_agent` - and referencing them by factory name for lazy initialization instead. - Example: .. code-block:: python @@ -710,6 +428,11 @@ class WorkflowBuilder: print(f"WorkerB processing: {task.data}") + dispatcher = TaskDispatcher(id="dispatcher") + worker_a = WorkerA(id="worker_a") + worker_b = WorkerB(id="worker_b") + + # Select workers based on task priority def select_workers(task: Task, available: list[str]) -> list[str]: if task.priority == "high": @@ -718,40 +441,17 @@ class WorkflowBuilder: workflow = ( - WorkflowBuilder(start_executor="TaskDispatcher") - .register_executor(lambda: TaskDispatcher(id="dispatcher"), name="TaskDispatcher") - .register_executor(lambda: WorkerA(id="worker_a"), name="WorkerA") - .register_executor(lambda: WorkerB(id="worker_b"), name="WorkerB") + WorkflowBuilder(start_executor=dispatcher) .add_multi_selection_edge_group( - "TaskDispatcher", - ["WorkerA", "WorkerB"], + dispatcher, + [worker_a, worker_b], selection_func=select_workers, ) .build() ) """ - if (isinstance(source, str) and not all(isinstance(t, str) for t in targets)) or ( - not isinstance(source, str) and any(isinstance(t, str) for t in targets) - ): - raise ValueError( - "Both source and targets must be either registered factory names (str) or " - "Executor/SupportsAgentRun instances." - ) - - if isinstance(source, str) and all(isinstance(t, str) for t in targets): - # Both are names; defer resolution to build time - self._edge_registry.append( - _MultiSelectionEdgeGroupRegistration( - source=source, - targets=list(targets), # type: ignore - selection_func=selection_func, - ) - ) - return self - - # Both are Executor/SupportsAgentRun instances; wrap and add now - source_exec = self._maybe_wrap_agent(source) # type: ignore - target_execs = [self._maybe_wrap_agent(t) for t in targets] # type: ignore + source_exec = self._maybe_wrap_agent(source) + target_execs = [self._maybe_wrap_agent(t) for t in targets] source_id = self._add_executor(source_exec) target_ids = [self._add_executor(t) for t in target_execs] self._edge_groups.append(FanOutEdgeGroup(source_id, target_ids, selection_func)) # type: ignore[call-arg] @@ -760,8 +460,8 @@ class WorkflowBuilder: def add_fan_in_edges( self, - sources: Sequence[Executor | SupportsAgentRun | str], - target: Executor | SupportsAgentRun | str, + sources: Sequence[Executor | SupportsAgentRun], + target: Executor | SupportsAgentRun, ) -> Self: """Add multiple edges from sources to a single target executor. @@ -773,17 +473,12 @@ class WorkflowBuilder: types of the source executors. Args: - sources: A list of source executors or registered names of the source factories for the edges. - target: The target executor or registered name of the target factory for the edges. + sources: A list of source executors or agents for the edges. + target: The target executor or agent for the edges. Returns: Self: The WorkflowBuilder instance for method chaining. - Note: If instances are provided for sources and target, they will be shared across - all workflow instances created from the built Workflow. To avoid this, consider - registering the executors and agents using `register_executor` and `register_agent` - and referencing them by factory name for lazy initialization instead. - Example: .. code-block:: python @@ -804,39 +499,21 @@ class WorkflowBuilder: await ctx.yield_output(f"Combined: {combined}") - # Collect results from multiple producers - workflow = ( - WorkflowBuilder(start_executor="Producer1") - .register_executor(lambda: Producer(id="prod_1"), name="Producer1") - .register_executor(lambda: Producer(id="prod_2"), name="Producer2") - .register_executor(lambda: Aggregator(id="agg"), name="Aggregator") - .add_fan_in_edges(["Producer1", "Producer2"], "Aggregator") - .build() - ) + prod_1 = Producer(id="prod_1") + prod_2 = Producer(id="prod_2") + agg = Aggregator(id="agg") + + workflow = WorkflowBuilder(start_executor=prod_1).add_fan_in_edges([prod_1, prod_2], agg).build() """ - if (all(isinstance(s, str) for s in sources) and not isinstance(target, str)) or ( - not all(isinstance(s, str) for s in sources) and isinstance(target, str) - ): - raise ValueError( - "Both sources and target must be either registered factory names (str) or " - "Executor/SupportsAgentRun instances." - ) - - if all(isinstance(s, str) for s in sources) and isinstance(target, str): - # Both are names; defer resolution to build time - self._edge_registry.append(_FanInEdgeRegistration(sources=list(sources), target=target)) # type: ignore - return self - - # Both are Executor/SupportsAgentRun instances; wrap and add now - source_execs = [self._maybe_wrap_agent(s) for s in sources] # type: ignore - target_exec = self._maybe_wrap_agent(target) # type: ignore + source_execs = [self._maybe_wrap_agent(s) for s in sources] + target_exec = self._maybe_wrap_agent(target) source_ids = [self._add_executor(s) for s in source_execs] target_id = self._add_executor(target_exec) self._edge_groups.append(FanInEdgeGroup(source_ids, target_id)) # type: ignore[call-arg] return self - def add_chain(self, executors: Sequence[Executor | SupportsAgentRun | str]) -> Self: + def add_chain(self, executors: Sequence[Executor | SupportsAgentRun]) -> Self: """Add a chain of executors to the workflow. The output of each executor in the chain will be sent to the next executor in the chain. @@ -845,16 +522,11 @@ class WorkflowBuilder: Cycles in the chain are not allowed, meaning an executor cannot appear more than once in the chain. Args: - executors: A list of executors or registered names of the executor factories to chain together. + executors: A list of executors or agents to chain together. Returns: Self: The WorkflowBuilder instance for method chaining. - Note: If executor instances are provided, they will be shared across all workflow instances created - from the built Workflow. To avoid this, consider registering the executors and agents using - `register_executor` and `register_agent` and referencing them by factory name for lazy - initialization instead. - Example: .. code-block:: python @@ -880,148 +552,37 @@ class WorkflowBuilder: await ctx.yield_output(f"Final: {text}") - # Chain executors in sequence - workflow = ( - WorkflowBuilder(start_executor="step1") - .register_executor(lambda: Step1(id="step1"), name="step1") - .register_executor(lambda: Step2(id="step2"), name="step2") - .register_executor(lambda: Step3(id="step3"), name="step3") - .add_chain(["step1", "step2", "step3"]) - .build() - ) + step1 = Step1(id="step1") + step2 = Step2(id="step2") + step3 = Step3(id="step3") + + workflow = WorkflowBuilder(start_executor=step1).add_chain([step1, step2, step3]).build() """ if len(executors) < 2: raise ValueError("At least two executors are required to form a chain.") - if not all(isinstance(e, str) for e in executors) and any(isinstance(e, str) for e in executors): - raise ValueError( - "All executors in the chain must be either registered factory names (str) " - "or Executor/SupportsAgentRun instances." - ) - - if all(isinstance(e, str) for e in executors): - # All are names; defer resolution to build time - for i in range(len(executors) - 1): - self.add_edge(executors[i], executors[i + 1]) - return self - - # All are Executor/SupportsAgentRun instances; wrap and add now # Wrap each candidate first to ensure stable IDs before adding edges - wrapped: list[Executor] = [self._maybe_wrap_agent(e) for e in executors] # type: ignore[arg-type] + wrapped: list[Executor] = [self._maybe_wrap_agent(e) for e in executors] for i in range(len(wrapped) - 1): self.add_edge(wrapped[i], wrapped[i + 1]) return self - def _set_start_executor(self, executor: Executor | SupportsAgentRun | str) -> None: + def _set_start_executor(self, executor: Executor | SupportsAgentRun) -> None: """Set the starting executor for the workflow (internal method). Args: - executor: The starting executor, which can be an Executor instance, SupportsAgentRun instance, - or the name of a registered executor factory. + executor: The starting executor, which can be an Executor instance or SupportsAgentRun instance. """ if self._start_executor is not None: - start_id = self._start_executor if isinstance(self._start_executor, str) else self._start_executor.id - logger.warning(f"Overwriting existing start executor: {start_id} for the workflow.") + logger.warning(f"Overwriting existing start executor: {self._start_executor.id} for the workflow.") - if isinstance(executor, str): - self._start_executor = executor - else: - wrapped = self._maybe_wrap_agent(executor) # type: ignore[arg-type] - self._start_executor = wrapped - # Ensure the start executor is present in the executor map so validation succeeds - # even if no edges are added yet, or before edges wrap the same agent again. - existing = self._executors.get(wrapped.id) - if existing is not wrapped: - self._add_executor(wrapped) - - # Removed explicit set_agent_streaming() API; agents always stream updates. - - def _resolve_edge_registry(self) -> tuple[Executor, dict[str, Executor], list[EdgeGroup]]: - """Resolve deferred edge registrations into executors and edge groups. - - Returns: - tuple: A tuple containing: - - The starting Executor instance. - - A dictionary mapping registered factory names to resolved Executor instances. - - A list of EdgeGroup instances representing the workflow edges composed of resolved executors. - - Notes: - Non-factory executors (i.e., those added directly) are not included in the returned list, - as they are already part of the workflow builder's internal state. - """ - if not self._start_executor: - raise ValueError( - "Starting executor must be set via the start_executor constructor parameter before building." - ) - - start_executor: Executor | None = None - if isinstance(self._start_executor, Executor): - start_executor = self._start_executor - - # Maps registered factory names to created executor instances for edge resolution - factory_name_to_instance: dict[str, Executor] = {} - # Maps executor IDs to created executor instances to prevent duplicates - executor_id_to_instance: dict[str, Executor] = {} - deferred_edge_groups: list[EdgeGroup] = [] - for name, exec_factory in self._executor_registry.items(): - instance = exec_factory() - if instance.id in executor_id_to_instance: - raise ValueError(f"Executor with ID '{instance.id}' has already been registered.") - if instance.id in self._executors: - raise ValueError(f"Executor ID collision: An executor with ID '{instance.id}' already exists.") - executor_id_to_instance[instance.id] = instance - - if isinstance(self._start_executor, str) and name == self._start_executor: - start_executor = instance - - # All executors will get their own internal edge group for receiving system messages - deferred_edge_groups.append(InternalEdgeGroup(instance.id)) # type: ignore[call-arg] - factory_name_to_instance[name] = instance - - def _get_executor(name: str) -> Executor: - """Helper to get executor by the registered name. Raises if not found.""" - if name not in factory_name_to_instance: - raise ValueError(f"Factory '{name}' has not been registered.") - return factory_name_to_instance[name] - - for registration in self._edge_registry: - match registration: - case _EdgeRegistration(source, target, condition): - source_exec: Executor = _get_executor(source) - target_exec: Executor = _get_executor(target) - deferred_edge_groups.append(SingleEdgeGroup(source_exec.id, target_exec.id, condition)) # type: ignore[call-arg] - case _FanOutEdgeRegistration(source, targets): - source_exec = _get_executor(source) - target_execs = [_get_executor(t) for t in targets] - deferred_edge_groups.append(FanOutEdgeGroup(source_exec.id, [t.id for t in target_execs])) # type: ignore[call-arg] - case _SwitchCaseEdgeGroupRegistration(source, cases): - source_exec = _get_executor(source) - cases_converted: list[SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault] = [] - for case in cases: - if not isinstance(case.target, str): - raise ValueError("Switch case target must be a registered factory name (str) if deferred.") - target_exec = _get_executor(case.target) - if isinstance(case, Default): - cases_converted.append(SwitchCaseEdgeGroupDefault(target_id=target_exec.id)) - else: - cases_converted.append( - SwitchCaseEdgeGroupCase(condition=case.condition, target_id=target_exec.id) - ) - deferred_edge_groups.append(SwitchCaseEdgeGroup(source_exec.id, cases_converted)) # type: ignore[call-arg] - case _MultiSelectionEdgeGroupRegistration(source, targets, selection_func): - source_exec = _get_executor(source) - target_execs = [_get_executor(t) for t in targets] - deferred_edge_groups.append( - FanOutEdgeGroup(source_exec.id, [t.id for t in target_execs], selection_func) # type: ignore[call-arg] - ) - case _FanInEdgeRegistration(sources, target): - source_execs = [_get_executor(s) for s in sources] - target_exec = _get_executor(target) - deferred_edge_groups.append(FanInEdgeGroup([s.id for s in source_execs], target_exec.id)) # type: ignore[call-arg] - if start_executor is None: - raise ValueError("Failed to resolve starting executor from registered factories.") - - return (start_executor, factory_name_to_instance, deferred_edge_groups) + wrapped = self._maybe_wrap_agent(executor) + self._start_executor = wrapped + # Ensure the start executor is present in the executor map so validation succeeds + # even if no edges are added yet, or before edges wrap the same agent again. + existing = self._executors.get(wrapped.id) + if existing is not wrapped: + self._add_executor(wrapped) def build(self) -> Workflow: """Build and return the constructed workflow. @@ -1053,12 +614,9 @@ class WorkflowBuilder: await ctx.yield_output(text.upper()) - # Build and execute a workflow - workflow = ( - WorkflowBuilder(start_executor="MyExecutor") - .register_executor(lambda: MyExecutor(id="executor"), name="MyExecutor") - .build() - ) + executor = MyExecutor(id="executor") + + workflow = WorkflowBuilder(start_executor=executor).build() # The workflow is now immutable and ready to run events = await workflow.run("hello") @@ -1074,23 +632,17 @@ class WorkflowBuilder: # Add workflow build started event span.add_event(OtelAttr.BUILD_STARTED) - # Resolve lazy edge registrations - start_executor, deferred_executors, deferred_edge_groups = self._resolve_edge_registry() - executors = self._executors | {exe.id: exe for exe in deferred_executors.values()} - edge_groups = self._edge_groups + deferred_edge_groups - output_executors = ( - [ - deferred_executors[factory_name].id - for factory_name in self._output_executors - if isinstance(factory_name, str) - ] - + [ex.id for ex in self._output_executors if isinstance(ex, Executor)] - + [ - resolve_agent_id(agent) - for agent in self._output_executors - if isinstance(agent, SupportsAgentRun) - ] - ) + if not self._start_executor: + raise ValueError( + "Starting executor must be set via the start_executor constructor parameter before building." + ) + + start_executor = self._start_executor + executors = self._executors + edge_groups = self._edge_groups + output_executors = [ex.id for ex in self._output_executors if isinstance(ex, Executor)] + [ + resolve_agent_id(agent) for agent in self._output_executors if isinstance(agent, SupportsAgentRun) + ] # Perform validation before creating the workflow validate_workflow_graph( @@ -1111,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_context.py b/python/packages/core/agent_framework/_workflows/_workflow_context.py index 2bdd81ef41..51add07a5c 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_context.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_context.py @@ -19,7 +19,7 @@ from ._events import ( WorkflowEventSource, _framework_event_origin, # type: ignore ) -from ._runner_context import Message, RunnerContext +from ._runner_context import RunnerContext, WorkflowMessage from ._state import State if TYPE_CHECKING: @@ -321,7 +321,7 @@ class WorkflowContext(Generic[OutT, W_OutT]): attributes[OtelAttr.MESSAGE_DESTINATION_EXECUTOR_ID] = target_id with create_workflow_span(OtelAttr.MESSAGE_SEND_SPAN, attributes, kind=SpanKind.PRODUCER) as span: # Create Message wrapper - msg = Message(data=message, source_id=self._executor_id, target_id=target_id) + msg = WorkflowMessage(data=message, source_id=self._executor_id, target_id=target_id) # Track sent message for executor_completed event (type='executor_completed') self._sent_messages.append(message) diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py index 319af46076..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, @@ -19,7 +19,7 @@ from ._events import ( ) from ._executor import Executor, handler from ._request_info_mixin import response_handler -from ._runner_context import Message +from ._runner_context import WorkflowMessage from ._typing_utils import is_instance_of from ._workflow import WorkflowRunResult from ._workflow_context import WorkflowContext @@ -340,7 +340,7 @@ class WorkflowExecutor(Executor): data["workflow"] = self.workflow.to_dict() return data - def can_handle(self, message: Message) -> bool: + def can_handle(self, message: WorkflowMessage) -> bool: """Override can_handle to only accept messages that the wrapped workflow can handle. This prevents the WorkflowExecutor from accepting messages that should go to other @@ -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/ag_ui/__init__.py b/python/packages/core/agent_framework/ag_ui/__init__.py index 13d1e442cd..b469bb8a60 100644 --- a/python/packages/core/agent_framework/ag_ui/__init__.py +++ b/python/packages/core/agent_framework/ag_ui/__init__.py @@ -8,7 +8,6 @@ PACKAGE_NAME = "agent-framework-ag-ui" _IMPORTS = [ "__version__", "AgentFrameworkAgent", - "AGUIThread", "add_agent_framework_fastapi_endpoint", "AGUIChatClient", "AGUIEventConverter", diff --git a/python/packages/core/agent_framework/azure/_assistants_client.py b/python/packages/core/agent_framework/azure/_assistants_client.py index 3ded58e9b0..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 @@ -32,8 +33,8 @@ __all__ = ["AzureOpenAIAssistantsClient"] # region Azure OpenAI Assistants Options TypedDict -TAzureOpenAIAssistantsOptions = TypeVar( - "TAzureOpenAIAssistantsOptions", +AzureOpenAIAssistantsOptionsT = TypeVar( + "AzureOpenAIAssistantsOptionsT", bound=TypedDict, # type: ignore[valid-type] default="OpenAIAssistantsOptions", covariant=True, @@ -44,7 +45,7 @@ TAzureOpenAIAssistantsOptions = TypeVar( class AzureOpenAIAssistantsClient( - OpenAIAssistantsClient[TAzureOpenAIAssistantsOptions], Generic[TAzureOpenAIAssistantsOptions] + OpenAIAssistantsClient[AzureOpenAIAssistantsOptionsT], Generic[AzureOpenAIAssistantsOptionsT] ): """Azure OpenAI Assistants client.""" @@ -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 a603af52cc..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): @@ -53,7 +55,7 @@ logger: logging.Logger = logging.getLogger(__name__) __all__ = ["AzureOpenAIChatClient", "AzureOpenAIChatOptions", "AzureUserSecurityContext"] -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None) +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) # region Azure OpenAI Chat Options TypedDict @@ -81,7 +83,7 @@ class AzureUserSecurityContext(TypedDict, total=False): """The original client's IP address.""" -class AzureOpenAIChatOptions(OpenAIChatOptions[TResponseModel], Generic[TResponseModel], total=False): +class AzureOpenAIChatOptions(OpenAIChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): """Azure OpenAI-specific chat options dict. Extends OpenAIChatOptions with Azure-specific options including @@ -136,8 +138,8 @@ class AzureOpenAIChatOptions(OpenAIChatOptions[TResponseModel], Generic[TRespons Note: You will be charged based on tokens across all choices. Keep n=1 to minimize costs.""" -TAzureOpenAIChatOptions = TypeVar( - "TAzureOpenAIChatOptions", +AzureOpenAIChatOptionsT = TypeVar( + "AzureOpenAIChatOptionsT", bound=TypedDict, # type: ignore[valid-type] default="AzureOpenAIChatOptions", covariant=True, @@ -146,17 +148,17 @@ TAzureOpenAIChatOptions = TypeVar( # endregion -TChatResponse = TypeVar("TChatResponse", ChatResponse, ChatResponseUpdate) -TAzureOpenAIChatClient = TypeVar("TAzureOpenAIChatClient", bound="AzureOpenAIChatClient") +ChatResponseT = TypeVar("ChatResponseT", ChatResponse, ChatResponseUpdate) +AzureOpenAIChatClientT = TypeVar("AzureOpenAIChatClientT", bound="AzureOpenAIChatClient") class AzureOpenAIChatClient( # type: ignore[misc] AzureOpenAIConfigMixin, - ChatMiddlewareLayer[TAzureOpenAIChatOptions], - FunctionInvocationLayer[TAzureOpenAIChatOptions], - ChatTelemetryLayer[TAzureOpenAIChatOptions], - RawOpenAIChatClient[TAzureOpenAIChatOptions], - Generic[TAzureOpenAIChatOptions], + ChatMiddlewareLayer[AzureOpenAIChatOptionsT], + FunctionInvocationLayer[AzureOpenAIChatOptionsT], + ChatTelemetryLayer[AzureOpenAIChatOptionsT], + RawOpenAIChatClient[AzureOpenAIChatOptionsT], + Generic[AzureOpenAIChatOptionsT], ): """Azure OpenAI Chat completion class with middleware, telemetry, and function invocation support.""" @@ -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 11eee1900f..0a6c0cd8c8 100644 --- a/python/packages/core/agent_framework/azure/_responses_client.py +++ b/python/packages/core/agent_framework/azure/_responses_client.py @@ -5,13 +5,16 @@ 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.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI -from pydantic import ValidationError +from openai import AsyncOpenAI +from openai.lib.azure import AsyncAzureADTokenProvider 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 from ..observability import ChatTelemetryLayer @@ -19,6 +22,7 @@ from ..openai._responses_client import RawOpenAIResponsesClient from ._shared import ( AzureOpenAIConfigMixin, AzureOpenAISettings, + _apply_azure_defaults, ) if sys.version_info >= (3, 13): @@ -41,8 +45,8 @@ if TYPE_CHECKING: __all__ = ["AzureOpenAIResponsesClient"] -TAzureOpenAIResponsesOptions = TypeVar( - "TAzureOpenAIResponsesOptions", +AzureOpenAIResponsesOptionsT = TypeVar( + "AzureOpenAIResponsesOptionsT", bound=TypedDict, # type: ignore[valid-type] default="OpenAIResponsesOptions", covariant=True, @@ -51,11 +55,11 @@ TAzureOpenAIResponsesOptions = TypeVar( class AzureOpenAIResponsesClient( # type: ignore[misc] AzureOpenAIConfigMixin, - ChatMiddlewareLayer[TAzureOpenAIResponsesOptions], - FunctionInvocationLayer[TAzureOpenAIResponsesOptions], - ChatTelemetryLayer[TAzureOpenAIResponsesOptions], - RawOpenAIResponsesClient[TAzureOpenAIResponsesOptions], - Generic[TAzureOpenAIResponsesOptions], + ChatMiddlewareLayer[AzureOpenAIResponsesOptionsT], + FunctionInvocationLayer[AzureOpenAIResponsesOptionsT], + ChatTelemetryLayer[AzureOpenAIResponsesOptionsT], + RawOpenAIResponsesClient[AzureOpenAIResponsesOptionsT], + Generic[AzureOpenAIResponsesOptionsT], ): """Azure Responses completion class with middleware, telemetry, and function invocation support.""" @@ -72,7 +76,9 @@ class AzureOpenAIResponsesClient( # type: ignore[misc] token_endpoint: str | None = None, credential: TokenCredential | None = None, default_headers: Mapping[str, str] | None = None, - async_client: AsyncAzureOpenAI | None = None, + async_client: AsyncOpenAI | None = None, + project_client: Any | None = None, + project_endpoint: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, instruction_role: str | None = None, @@ -82,6 +88,14 @@ class AzureOpenAIResponsesClient( # type: ignore[misc] ) -> None: """Initialize an Azure OpenAI Responses client. + The client can be created in two ways: + + 1. **Direct Azure OpenAI** (default): Provide endpoint, api_key, or credential + to connect directly to an Azure OpenAI deployment. + 2. **Foundry project endpoint**: Provide a ``project_client`` or ``project_endpoint`` + (with ``credential``) to create the client via an Azure AI Foundry project. + This requires the ``azure-ai-projects`` package to be installed. + Keyword Args: api_key: The API key. If provided, will override the value in the env vars or .env file. Can also be set via environment variable AZURE_OPENAI_API_KEY. @@ -105,6 +119,12 @@ class AzureOpenAIResponsesClient( # type: ignore[misc] default_headers: The default headers mapping of string keys to string values for HTTP requests. async_client: An existing client to use. + project_client: An existing ``AIProjectClient`` (from ``azure.ai.projects.aio``) to use. + The OpenAI client will be obtained via ``project_client.get_openai_client()``. + Requires the ``azure-ai-projects`` package. + project_endpoint: The Azure AI Foundry project endpoint URL. + When provided with ``credential``, an ``AIProjectClient`` will be created + and used to obtain the OpenAI client. Requires the ``azure-ai-projects`` package. env_file_path: Use the environment settings file as a fallback to using env vars. env_file_encoding: The encoding of the environment settings file, defaults to 'utf-8'. instruction_role: The role to use for 'instruction' messages, for example, summarization @@ -132,6 +152,27 @@ class AzureOpenAIResponsesClient( # type: ignore[misc] # Or loading from a .env file client = AzureOpenAIResponsesClient(env_file_path="path/to/.env") + # Using a Foundry project endpoint + from azure.identity import DefaultAzureCredential + + client = AzureOpenAIResponsesClient( + project_endpoint="https://your-project.services.ai.azure.com", + deployment_name="gpt-4o", + credential=DefaultAzureCredential(), + ) + + # Or using an existing AIProjectClient + from azure.ai.projects.aio import AIProjectClient + + project_client = AIProjectClient( + endpoint="https://your-project.services.ai.azure.com", + credential=DefaultAzureCredential(), + ) + client = AzureOpenAIResponsesClient( + project_client=project_client, + deployment_name="gpt-4o", + ) + # Using custom ChatOptions with type safety: from typing import TypedDict from agent_framework.azure import AzureOpenAIResponsesOptions @@ -146,47 +187,54 @@ class AzureOpenAIResponsesClient( # type: ignore[misc] """ if model_id := kwargs.pop("model_id", None) and not deployment_name: deployment_name = str(model_id) - 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", - ) - # 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: + # 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): + async_client = self._create_client_from_project( + project_client=project_client, + project_endpoint=project_endpoint, + credential=credential, + ) + + 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/") + + 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, @@ -195,9 +243,48 @@ class AzureOpenAIResponsesClient( # type: ignore[misc] function_invocation_configuration=function_invocation_configuration, ) + @staticmethod + def _create_client_from_project( + *, + project_client: AIProjectClient | None, + project_endpoint: str | None, + credential: TokenCredential | None, + ) -> AsyncOpenAI: + """Create an AsyncOpenAI client from an Azure AI Foundry project. + + Args: + project_client: An existing AIProjectClient to use. + project_endpoint: The Azure AI Foundry project endpoint URL. + credential: Azure credential for authentication. + + Returns: + An AsyncAzureOpenAI client obtained from the project client. + + Raises: + ServiceInitializationError: If required parameters are missing or + the azure-ai-projects package is not installed. + """ + if project_client is not None: + return project_client.get_openai_client() + + if not project_endpoint: + raise ServiceInitializationError( + "Azure AI project endpoint is required when project_client is not provided." + ) + if not credential: + raise ServiceInitializationError( + "Azure credential is required when using project_endpoint without a project_client." + ) + project_client = AIProjectClient( + endpoint=project_endpoint, + credential=credential, # type: ignore[arg-type] + user_agent=AGENT_FRAMEWORK_USER_AGENT, + ) + return project_client.get_openai_client() + @override - def _check_model_presence(self, run_options: dict[str, Any]) -> None: - if not run_options.get("model"): + def _check_model_presence(self, options: dict[str, Any]) -> None: + if not options.get("model"): if not self.model_id: raise ValueError("deployment_name must be a non-empty string") - run_options["model"] = self.model_id + options["model"] = self.model_id diff --git a/python/packages/core/agent_framework/azure/_shared.py b/python/packages/core/agent_framework/azure/_shared.py index 8e90002a75..c3e4399555 100644 --- a/python/packages/core/agent_framework/azure/_shared.py +++ b/python/packages/core/agent_framework/azure/_shared.py @@ -9,29 +9,28 @@ from copy import copy 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_'. @@ -61,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 @@ -70,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 @@ -88,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): @@ -153,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, @@ -162,14 +141,14 @@ class AzureOpenAIConfigMixin(OpenAIBase): token_endpoint: str | None = None, credential: TokenCredential | None = None, default_headers: Mapping[str, str] | None = None, - client: AsyncAzureOpenAI | None = None, + client: AsyncOpenAI | None = None, instruction_role: str | None = None, **kwargs: Any, ) -> None: """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/devui/__init__.py b/python/packages/core/agent_framework/devui/__init__.py index 3e3312f10c..cd18b0c5da 100644 --- a/python/packages/core/agent_framework/devui/__init__.py +++ b/python/packages/core/agent_framework/devui/__init__.py @@ -14,6 +14,7 @@ _IMPORTS = [ "OpenAIResponse", "ResponseStreamEvent", "main", + "register_cleanup", "serve", "__version__", ] diff --git a/python/packages/core/agent_framework/devui/__init__.pyi b/python/packages/core/agent_framework/devui/__init__.pyi index 3c1cac827f..9396af54bb 100644 --- a/python/packages/core/agent_framework/devui/__init__.pyi +++ b/python/packages/core/agent_framework/devui/__init__.pyi @@ -10,6 +10,7 @@ from agent_framework_devui import ( ResponseStreamEvent, __version__, main, + register_cleanup, serve, ) @@ -23,5 +24,6 @@ __all__ = [ "ResponseStreamEvent", "__version__", "main", + "register_cleanup", "serve", ] diff --git a/python/packages/core/agent_framework/exceptions.py b/python/packages/core/agent_framework/exceptions.py index 971b612ea3..21e50e571e 100644 --- a/python/packages/core/agent_framework/exceptions.py +++ b/python/packages/core/agent_framework/exceptions.py @@ -49,8 +49,8 @@ class AgentInitializationError(AgentException): pass -class AgentThreadException(AgentException): - """An error occurred while managing the agent thread.""" +class AgentSessionException(AgentException): + """An error occurred while managing the agent session.""" pass @@ -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/mem0/__init__.py b/python/packages/core/agent_framework/mem0/__init__.py index dd28c5459b..dddc742ef0 100644 --- a/python/packages/core/agent_framework/mem0/__init__.py +++ b/python/packages/core/agent_framework/mem0/__init__.py @@ -5,7 +5,7 @@ from typing import Any IMPORT_PATH = "agent_framework_mem0" PACKAGE_NAME = "agent-framework-mem0" -_IMPORTS = ["__version__", "Mem0Provider"] +_IMPORTS = ["__version__", "Mem0ContextProvider"] def __getattr__(name: str) -> Any: diff --git a/python/packages/core/agent_framework/mem0/__init__.pyi b/python/packages/core/agent_framework/mem0/__init__.pyi index 29250a02ad..18ee3bf2bd 100644 --- a/python/packages/core/agent_framework/mem0/__init__.pyi +++ b/python/packages/core/agent_framework/mem0/__init__.pyi @@ -1,11 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. from agent_framework_mem0 import ( - Mem0Provider, + Mem0ContextProvider, __version__, ) __all__ = [ - "Mem0Provider", + "Mem0ContextProvider", "__version__", ] diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 9a839bb566..5417c8988d 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 @@ -39,22 +38,22 @@ if TYPE_CHECKING: # pragma: no cover from pydantic import BaseModel from ._agents import SupportsAgentRun - from ._clients import ChatClientProtocol - from ._threads import AgentThread + from ._clients import SupportsChatGetResponse + from ._sessions import AgentSession from ._tools import FunctionTool from ._types import ( AgentResponse, AgentResponseUpdate, - ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate, Content, FinishReason, + Message, ResponseStream, ) - TResponseModelT = TypeVar("TResponseModelT", bound=BaseModel) + ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) __all__ = [ "OBSERVABILITY_SETTINGS", @@ -71,7 +70,7 @@ __all__ = [ AgentT = TypeVar("AgentT", bound="SupportsAgentRun") -TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol[Any]") +ChatClientT = TypeVar("ChatClientT", bound="SupportsChatGetResponse[Any]") logger = get_logger() @@ -122,7 +121,7 @@ OPERATION_DURATION_BUCKET_BOUNDARIES: Final[tuple[float, ...]] = ( # # This is a workaround, we'll find a generic and better solution - see # https://github.com/open-telemetry/semantic-conventions/issues/1701 -class ChatMessageListTimestampFilter(logging.Filter): +class MessageListTimestampFilter(logging.Filter): """A filter to increment the timestamp of INFO logs by 1 microsecond.""" INDEX_KEY: ClassVar[str] = "chat_message_index" @@ -135,7 +134,7 @@ class ChatMessageListTimestampFilter(logging.Filter): return True -logger.addFilter(ChatMessageListTimestampFilter()) +logger.addFilter(MessageListTimestampFilter()) class OtelAttr(str, Enum): @@ -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: @@ -1049,15 +1063,15 @@ def _get_token_usage_histogram() -> metrics.Histogram: ) -TOptions_co = TypeVar( - "TOptions_co", +OptionsCoT = TypeVar( + "OptionsCoT", bound=TypedDict, # type: ignore[valid-type] default="ChatOptions[None]", covariant=True, ) -class ChatTelemetryLayer(Generic[TOptions_co]): +class ChatTelemetryLayer(Generic[OptionsCoT]): """Layer that wraps chat client get_response with OpenTelemetry tracing.""" def __init__(self, *args: Any, otel_provider_name: str | None = None, **kwargs: Any) -> None: @@ -1070,39 +1084,39 @@ class ChatTelemetryLayer(Generic[TOptions_co]): @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[False] = ..., - options: ChatOptions[TResponseModelT], + options: ChatOptions[ResponseModelBoundT], **kwargs: Any, - ) -> Awaitable[ChatResponse[TResponseModelT]]: ... + ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[False] = ..., - options: TOptions_co | ChatOptions[None] | None = None, + options: OptionsCoT | ChatOptions[None] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @overload def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: Literal[True], - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... def get_response( self, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], *, stream: bool = False, - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Trace chat responses with OpenTelemetry spans and metrics.""" @@ -1139,8 +1153,14 @@ class ChatTelemetryLayer(Generic[TOptions_co]): else: raise RuntimeError("Streaming telemetry requires a ResponseStream result.") - span_cm = _get_span(attributes=attributes, span_name_attribute=SpanAttributes.LLM_REQUEST_MODEL) - span = span_cm.__enter__() + # Create span directly without trace.use_span() context attachment. + # Streaming spans are closed asynchronously in cleanup hooks, which run + # in a different async context than creation — using use_span() would + # cause "Failed to detach context" errors from OpenTelemetry. + operation = attributes.get(OtelAttr.OPERATION, "operation") + span_name = attributes.get(SpanAttributes.LLM_REQUEST_MODEL, "unknown") + span = get_tracer().start_span(f"{operation} {span_name}") + span.set_attributes(attributes) if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: _capture_messages( span=span, @@ -1157,7 +1177,7 @@ class ChatTelemetryLayer(Generic[TOptions_co]): if span_state["closed"]: return span_state["closed"] = True - span_cm.__exit__(None, None, None) + span.end() def _record_duration() -> None: duration_state["duration"] = perf_counter() - start_time @@ -1257,29 +1277,29 @@ class AgentTelemetryLayer: @overload def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[False] = ..., - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @overload def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[True], - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Trace agent runs with OpenTelemetry spans and metrics.""" @@ -1292,7 +1312,7 @@ class AgentTelemetryLayer: return super_run( # type: ignore[no-any-return] messages=messages, stream=stream, - thread=thread, + session=session, **kwargs, ) @@ -1307,7 +1327,7 @@ class AgentTelemetryLayer: agent_id=getattr(self, "id", "unknown"), agent_name=getattr(self, "name", None) or getattr(self, "id", "unknown"), agent_description=getattr(self, "description", None), - thread_id=thread.service_thread_id if thread else None, + thread_id=session.service_session_id if session else None, all_options=merged_options, **kwargs, ) @@ -1316,7 +1336,7 @@ class AgentTelemetryLayer: run_result = super_run( messages=messages, stream=True, - thread=thread, + session=session, **kwargs, ) if isinstance(run_result, ResponseStream): @@ -1326,8 +1346,14 @@ class AgentTelemetryLayer: else: raise RuntimeError("Streaming telemetry requires a ResponseStream result.") - span_cm = _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) - span = span_cm.__enter__() + # Create span directly without trace.use_span() context attachment. + # Streaming spans are closed asynchronously in cleanup hooks, which run + # in a different async context than creation — using use_span() would + # cause "Failed to detach context" errors from OpenTelemetry. + operation = attributes.get(OtelAttr.OPERATION, "operation") + span_name = attributes.get(OtelAttr.AGENT_NAME, "unknown") + span = get_tracer().start_span(f"{operation} {span_name}") + span.set_attributes(attributes) if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: _capture_messages( span=span, @@ -1344,7 +1370,7 @@ class AgentTelemetryLayer: if span_state["closed"]: return span_state["closed"] = True - span_cm.__exit__(None, None, None) + span.end() def _record_duration() -> None: duration_state["duration"] = perf_counter() - start_time @@ -1397,7 +1423,7 @@ class AgentTelemetryLayer: response = await super_run( messages=messages, stream=False, - thread=thread, + session=session, **kwargs, ) except Exception as exception: @@ -1422,7 +1448,7 @@ class AgentTelemetryLayer: # region Otel Helpers -def get_function_span_attributes(function: FunctionTool[Any, Any], tool_call_id: str | None = None) -> dict[str, str]: +def get_function_span_attributes(function: FunctionTool[Any], tool_call_id: str | None = None) -> dict[str, str]: """Get the span attributes for the given function. Args: @@ -1531,7 +1557,7 @@ OTEL_ATTR_MAP: dict[str | tuple[str, ...], tuple[str, Callable[[Any], Any] | Non "tools": ( OtelAttr.TOOL_DEFINITIONS, lambda tools: ( - json.dumps(tools_dict) + json.dumps(tools_dict, ensure_ascii=False) if (tools_dict := __import__("agent_framework._tools", fromlist=["_tools_to_dict"])._tools_to_dict(tools)) else None ), @@ -1588,7 +1614,7 @@ def capture_exception(span: trace.Span, exception: Exception, timestamp: int | N def _capture_messages( span: trace.Span, provider_name: str, - messages: str | ChatMessage | Sequence[str | ChatMessage], + messages: str | Message | Sequence[str | Message], system_instructions: str | list[str] | None = None, output: bool = False, finish_reason: FinishReason | None = None, @@ -1608,20 +1634,22 @@ def _capture_messages( extra={ OtelAttr.EVENT_NAME: OtelAttr.CHOICE if output else ROLE_EVENT_MAP.get(message.role), OtelAttr.PROVIDER_NAME: provider_name, - ChatMessageListTimestampFilter.INDEX_KEY: index, + MessageListTimestampFilter.INDEX_KEY: index, }, ) if finish_reason: otel_messages[-1]["finish_reason"] = FINISH_REASON_MAP[finish_reason] - span.set_attribute(OtelAttr.OUTPUT_MESSAGES if output else OtelAttr.INPUT_MESSAGES, json.dumps(otel_messages)) + span.set_attribute( + OtelAttr.OUTPUT_MESSAGES if output else OtelAttr.INPUT_MESSAGES, json.dumps(otel_messages, ensure_ascii=False) + ) if system_instructions: if not isinstance(system_instructions, list): system_instructions = [system_instructions] otel_sys_instructions = [{"type": "text", "content": instruction} for instruction in system_instructions] - span.set_attribute(OtelAttr.SYSTEM_INSTRUCTIONS, json.dumps(otel_sys_instructions)) + span.set_attribute(OtelAttr.SYSTEM_INSTRUCTIONS, json.dumps(otel_sys_instructions, ensure_ascii=False)) -def _to_otel_message(message: ChatMessage) -> dict[str, Any]: +def _to_otel_message(message: Message) -> dict[str, Any]: """Create a otel representation of a message.""" return {"role": message.role, "parts": [_to_otel_part(content) for content in message.contents]} @@ -1652,12 +1680,10 @@ def _to_otel_part(content: Content) -> dict[str, Any] | None: case "function_call": return {"type": "tool_call", "id": content.call_id, "name": content.name, "arguments": content.arguments} case "function_result": - from ._types import prepare_function_call_results - return { "type": "tool_call_response", "id": content.call_id, - "response": prepare_function_call_results(content), + "response": content.result if content.result is not None else "", } case _: # GenericPart in otel output messages json spec. diff --git a/python/packages/core/agent_framework/openai/_assistant_provider.py b/python/packages/core/agent_framework/openai/_assistant_provider.py index 263c4dcab1..8082a4ad9b 100644 --- a/python/packages/core/agent_framework/openai/_assistant_provider.py +++ b/python/packages/core/agent_framework/openai/_assistant_provider.py @@ -8,12 +8,14 @@ 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 .._agents import ChatAgent -from .._memory import ContextProvider +from agent_framework._settings import SecretString, load_settings + +from .._agents import Agent from .._middleware import MiddlewareTypes -from .._tools import FunctionTool, ToolProtocol +from .._sessions import BaseContextProvider +from .._tools import FunctionTool from .._types import normalize_tools from ..exceptions import ServiceInitializationError from ._assistants_client import OpenAIAssistantsClient @@ -33,28 +35,28 @@ else: __all__ = ["OpenAIAssistantProvider"] -# Type variable for options - allows typed ChatAgent[TOptions] returns +# Type variable for options - allows typed OpenAIAssistantProvider[OptionsCoT] returns # Default matches OpenAIAssistantsClient's default options type -TOptions_co = TypeVar( - "TOptions_co", +OptionsCoT = TypeVar( + "OptionsCoT", bound=TypedDict, # type: ignore[valid-type] default="OpenAIAssistantsOptions", covariant=True, ) _ToolsType = ( - ToolProtocol + FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] ) -class OpenAIAssistantProvider(Generic[TOptions_co]): - """Provider for creating ChatAgent instances from OpenAI Assistants API. +class OpenAIAssistantProvider(Generic[OptionsCoT]): + """Provider for creating Agent instances from OpenAI Assistants API. This provider allows you to create, retrieve, and wrap OpenAI Assistants - as ChatAgent instances for use in the agent framework. + as Agent instances for use in the agent framework. Examples: Basic usage with automatic client creation: @@ -107,7 +109,7 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): 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[TOptions_co]): 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) @@ -205,14 +206,14 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): description: str | None = None, tools: _ToolsType | None = None, metadata: dict[str, str] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: - """Create a new assistant on OpenAI and return a ChatAgent. + context_providers: Sequence[BaseContextProvider] | None = None, + ) -> Agent[OptionsCoT]: + """Create a new assistant on OpenAI and return a Agent. This method creates a new assistant on the OpenAI service and wraps it - in a ChatAgent instance. The assistant will persist on OpenAI until deleted. + in a Agent instance. The assistant will persist on OpenAI until deleted. Keyword Args: name: The name of the assistant (required). @@ -221,18 +222,18 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): description: A description of the assistant. tools: Tools available to the assistant. Can include: - FunctionTool instances or callables decorated with @tool - - HostedCodeInterpreterTool for code execution - - HostedFileSearchTool for vector store search + - Dict-based tools from OpenAIAssistantsClient.get_code_interpreter_tool() + - Dict-based tools from OpenAIAssistantsClient.get_file_search_tool() - Raw tool dictionaries metadata: Metadata to attach to the assistant (max 16 key-value pairs). default_options: A TypedDict containing default chat options for the agent. These options are applied to every run unless overridden. Include ``response_format`` here for structured output responses. - middleware: MiddlewareTypes for the ChatAgent. - context_provider: Context provider for the ChatAgent. + middleware: MiddlewareTypes for the Agent. + context_providers: Context providers for the Agent. Returns: - A ChatAgent instance wrapping the created assistant. + A Agent instance wrapping the created assistant. Raises: ServiceInitializationError: If assistant creation fails. @@ -297,13 +298,13 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): assistant = await self._client.beta.assistants.create(**create_params) - # Create ChatAgent - pass default_options which contains response_format + # Create Agent - pass default_options which contains response_format return self._create_chat_agent_from_assistant( assistant=assistant, tools=normalized_tools, instructions=instructions, middleware=middleware, - context_provider=context_provider, + context_providers=context_providers, default_options=default_options, ) @@ -313,14 +314,14 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): *, tools: _ToolsType | None = None, instructions: str | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: - """Retrieve an existing assistant by ID and return a ChatAgent. + context_providers: Sequence[BaseContextProvider] | None = None, + ) -> Agent[OptionsCoT]: + """Retrieve an existing assistant by ID and return a Agent. This method fetches an existing assistant from OpenAI by its ID - and wraps it in a ChatAgent instance. + and wraps it in a Agent instance. Args: assistant_id: The ID of the assistant to retrieve (e.g., "asst_123"). @@ -333,11 +334,11 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): instructions: Override the assistant's instructions (optional). default_options: A TypedDict containing default chat options for the agent. These options are applied to every run unless overridden. - middleware: MiddlewareTypes for the ChatAgent. - context_provider: Context provider for the ChatAgent. + middleware: MiddlewareTypes for the Agent. + context_providers: Context providers for the Agent. Returns: - A ChatAgent instance wrapping the retrieved assistant. + A Agent instance wrapping the retrieved assistant. Raises: ServiceInitializationError: If the assistant cannot be retrieved. @@ -370,7 +371,7 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): instructions=instructions, default_options=default_options, middleware=middleware, - context_provider=context_provider, + context_providers=context_providers, ) def as_agent( @@ -379,14 +380,14 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): *, tools: _ToolsType | None = None, instructions: str | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: - """Wrap an existing SDK Assistant object as a ChatAgent. + context_providers: Sequence[BaseContextProvider] | None = None, + ) -> Agent[OptionsCoT]: + """Wrap an existing SDK Assistant object as a Agent. This method does NOT make any HTTP calls. It simply wraps an already- - fetched Assistant object in a ChatAgent. + fetched Assistant object in a Agent. Args: assistant: The OpenAI Assistant SDK object to wrap. @@ -398,11 +399,11 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): instructions: Override the assistant's instructions (optional). default_options: A TypedDict containing default chat options for the agent. These options are applied to every run unless overridden. - middleware: MiddlewareTypes for the ChatAgent. - context_provider: Context provider for the ChatAgent. + middleware: MiddlewareTypes for the Agent. + context_providers: Context providers for the Agent. Returns: - A ChatAgent instance wrapping the assistant. + A Agent instance wrapping the assistant. Raises: ValueError: If required function tools are missing. @@ -429,14 +430,14 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): # Merge hosted tools with user-provided function tools merged_tools = self._merge_tools(assistant.tools or [], tools) - # Create ChatAgent + # Create Agent return self._create_chat_agent_from_assistant( assistant=assistant, tools=merged_tools, instructions=instructions, default_options=default_options, middleware=middleware, - context_provider=context_provider, + context_providers=context_providers, ) def _validate_function_tools( @@ -494,7 +495,7 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): self, assistant_tools: list[Any], user_tools: _ToolsType | None, - ) -> list[ToolProtocol | MutableMapping[str, Any]]: + ) -> list[FunctionTool | MutableMapping[str, Any]]: """Merge hosted tools from assistant with user-provided function tools. Args: @@ -504,7 +505,7 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): Returns: A list of all tools (hosted tools + user function implementations). """ - merged: list[ToolProtocol | MutableMapping[str, Any]] = [] + merged: list[FunctionTool | MutableMapping[str, Any]] = [] # Add hosted tools from assistant using shared conversion hosted_tools = from_assistant_tools(assistant_tools) @@ -520,29 +521,29 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): def _create_chat_agent_from_assistant( self, assistant: Assistant, - tools: list[ToolProtocol | MutableMapping[str, Any]] | None, + tools: list[FunctionTool | MutableMapping[str, Any]] | None, instructions: str | None, middleware: Sequence[MiddlewareTypes] | None, - context_provider: ContextProvider | None, - default_options: TOptions_co | None = None, + context_providers: Sequence[BaseContextProvider] | None, + default_options: OptionsCoT | None = None, **kwargs: Any, - ) -> ChatAgent[TOptions_co]: - """Create a ChatAgent from an Assistant. + ) -> Agent[OptionsCoT]: + """Create a Agent from an Assistant. Args: assistant: The OpenAI Assistant object. tools: Tools for the agent. instructions: Instructions override. middleware: MiddlewareTypes for the agent. - context_provider: Context provider for the agent. + context_providers: Context providers for the agent. default_options: Default chat options for the agent (may include response_format). - **kwargs: Additional arguments passed to ChatAgent. + **kwargs: Additional arguments passed to Agent. Returns: - A configured ChatAgent instance. + A configured Agent instance. """ # Create the chat client with the assistant - chat_client = OpenAIAssistantsClient( + client = OpenAIAssistantsClient( model_id=assistant.model, assistant_id=assistant.id, assistant_name=assistant.name, @@ -553,16 +554,16 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): # Use instructions from assistant if not overridden final_instructions = instructions if instructions is not None else assistant.instructions - # Create and return ChatAgent - return ChatAgent( - chat_client=chat_client, + # Create and return Agent + return Agent( + client=client, id=assistant.id, name=assistant.name, description=assistant.description, instructions=final_instructions, tools=tools if tools else None, middleware=middleware, - context_provider=context_provider, + context_providers=context_providers, default_options=default_options, # type: ignore[arg-type] **kwargs, ) diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index 1f6bdb87dc..03899d4891 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -27,26 +27,24 @@ 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, FunctionTool, - HostedCodeInterpreterTool, - HostedFileSearchTool, ) from .._types import ( - ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate, Content, + Message, ResponseStream, UsageDetails, - prepare_function_call_results, ) from ..exceptions import ServiceInitializationError from ..observability import ChatTelemetryLayer @@ -79,7 +77,7 @@ __all__ = [ # region OpenAI Assistants Options TypedDict -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None) +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) class VectorStoreToolResource(TypedDict, total=False): @@ -109,7 +107,7 @@ class AssistantToolResources(TypedDict, total=False): """Resources for file search tool, including vector store IDs.""" -class OpenAIAssistantsOptions(ChatOptions[TResponseModel], Generic[TResponseModel], total=False): +class OpenAIAssistantsOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): """OpenAI Assistants API-specific options dict. Extends base ChatOptions with Assistants API-specific parameters @@ -193,8 +191,8 @@ ASSISTANTS_OPTION_TRANSLATIONS: dict[str, str] = { } """Maps ChatOptions keys to OpenAI Assistants API parameter names.""" -TOpenAIAssistantsOptions = TypeVar( - "TOpenAIAssistantsOptions", +OpenAIAssistantsOptionsT = TypeVar( + "OpenAIAssistantsOptionsT", bound=TypedDict, # type: ignore[valid-type] default="OpenAIAssistantsOptions", covariant=True, @@ -206,14 +204,70 @@ TOpenAIAssistantsOptions = TypeVar( class OpenAIAssistantsClient( # type: ignore[misc] OpenAIConfigMixin, - ChatMiddlewareLayer[TOpenAIAssistantsOptions], - FunctionInvocationLayer[TOpenAIAssistantsOptions], - ChatTelemetryLayer[TOpenAIAssistantsOptions], - BaseChatClient[TOpenAIAssistantsOptions], - Generic[TOpenAIAssistantsOptions], + ChatMiddlewareLayer[OpenAIAssistantsOptionsT], + FunctionInvocationLayer[OpenAIAssistantsOptionsT], + ChatTelemetryLayer[OpenAIAssistantsOptionsT], + BaseChatClient[OpenAIAssistantsOptionsT], + Generic[OpenAIAssistantsOptionsT], ): """OpenAI Assistants client with middleware, telemetry, and function invocation support.""" + # region Hosted Tool Factory Methods + + @staticmethod + def get_code_interpreter_tool() -> dict[str, Any]: + """Create a code interpreter tool configuration for the Assistants API. + + Returns: + A dict tool configuration ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.openai import OpenAIAssistantsClient + + # Enable code interpreter + tool = OpenAIAssistantsClient.get_code_interpreter_tool() + + agent = ChatAgent(client, tools=[tool]) + """ + return {"type": "code_interpreter"} + + @staticmethod + def get_file_search_tool( + *, + max_num_results: int | None = None, + ) -> dict[str, Any]: + """Create a file search tool configuration for the Assistants API. + + Keyword Args: + max_num_results: Maximum number of results to return from file search. + + Returns: + A dict tool configuration ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.openai import OpenAIAssistantsClient + + # Basic file search + tool = OpenAIAssistantsClient.get_file_search_tool() + + # With result limit + tool = OpenAIAssistantsClient.get_file_search_tool(max_num_results=10) + + agent = ChatAgent(client, tools=[tool]) + """ + tool: dict[str, Any] = {"type": "file_search"} + + if max_num_results is not None: + tool["file_search"] = {"max_num_results": max_num_results} + + return tool + + # endregion + def __init__( self, *, @@ -289,35 +343,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, ) @@ -352,7 +405,7 @@ class OpenAIAssistantsClient( # type: ignore[misc] def _inner_get_response( self, *, - messages: Sequence[ChatMessage], + messages: Sequence[Message], options: Mapping[str, Any], stream: bool = False, **kwargs: Any, @@ -605,7 +658,7 @@ class OpenAIAssistantsClient( # type: ignore[misc] def _prepare_options( self, - messages: Sequence[ChatMessage], + messages: Sequence[Message], options: Mapping[str, Any], **kwargs: Any, ) -> tuple[dict[str, Any], list[Content] | None]: @@ -643,16 +696,8 @@ class OpenAIAssistantsClient( # type: ignore[misc] for tool in tools: if isinstance(tool, FunctionTool): tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType] - elif isinstance(tool, HostedCodeInterpreterTool): - tool_definitions.append({"type": "code_interpreter"}) - elif isinstance(tool, HostedFileSearchTool): - params: dict[str, Any] = { - "type": "file_search", - } - if tool.max_results is not None: - params["max_num_results"] = tool.max_results - tool_definitions.append(params) elif isinstance(tool, MutableMapping): + # Pass through dict-based tools directly (from static factory methods) tool_definitions.append(tool) if len(tool_definitions) > 0: @@ -759,10 +804,11 @@ class OpenAIAssistantsClient( # type: ignore[misc] if tool_outputs is None: tool_outputs = [] - if function_result_content.result: - output = prepare_function_call_results(function_result_content.result) - else: - output = "No output received." + output = ( + function_result_content.result + if function_result_content.result is not None + else "No output received." + ) tool_outputs.append(ToolOutput(tool_call_id=call_id, output=output)) return run_id, tool_outputs diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index 4ca47a4481..fa232c20a1 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -16,28 +16,27 @@ from openai.types.chat.chat_completion import ChatCompletion, Choice 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 pydantic import BaseModel, ValidationError +from openai.types.chat.completion_create_params import WebSearchOptions +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, FunctionTool, - HostedWebSearchTool, - ToolProtocol, ) from .._types import ( - ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate, Content, FinishReason, + Message, ResponseStream, UsageDetails, - prepare_function_call_results, ) from ..exceptions import ( ServiceInitializationError, @@ -65,7 +64,7 @@ __all__ = ["OpenAIChatClient", "OpenAIChatOptions"] logger = get_logger("agent_framework.openai") -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None) +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) # region OpenAI Chat Options TypedDict @@ -85,7 +84,7 @@ class Prediction(TypedDict, total=False): content: str | list[PredictionTextContent] -class OpenAIChatOptions(ChatOptions[TResponseModel], Generic[TResponseModel], total=False): +class OpenAIChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): """OpenAI-specific chat options dict. Extends ChatOptions with options specific to OpenAI's Chat Completions API. @@ -124,7 +123,7 @@ class OpenAIChatOptions(ChatOptions[TResponseModel], Generic[TResponseModel], to prediction: Prediction -TOpenAIChatOptions = TypeVar("TOpenAIChatOptions", bound=TypedDict, default="OpenAIChatOptions", covariant=True) # type: ignore[valid-type] +OpenAIChatOptionsT = TypeVar("OpenAIChatOptionsT", bound=TypedDict, default="OpenAIChatOptions", covariant=True) # type: ignore[valid-type] OPTION_TRANSLATIONS: dict[str, str] = { "model_id": "model", @@ -136,8 +135,8 @@ OPTION_TRANSLATIONS: dict[str, str] = { # region Base Client class RawOpenAIChatClient( # type: ignore[misc] OpenAIBase, - BaseChatClient[TOpenAIChatOptions], - Generic[TOpenAIChatOptions], + BaseChatClient[OpenAIChatOptionsT], + Generic[OpenAIChatOptionsT], ): """Raw OpenAI Chat completion class without middleware, telemetry, or function invocation. @@ -154,11 +153,63 @@ class RawOpenAIChatClient( # type: ignore[misc] Use ``OpenAIChatClient`` instead for a fully-featured client with all layers applied. """ + # region Hosted Tool Factory Methods + + @staticmethod + def get_web_search_tool( + *, + web_search_options: WebSearchOptions | None = None, + ) -> dict[str, Any]: + """Create a web search tool configuration for the Chat Completions API. + + Note: For the Chat Completions API, web search is passed via the `web_search_options` + parameter rather than in the `tools` array. This method returns a dict that can be + passed as a tool to ChatAgent, which will handle it appropriately. + + Keyword Args: + web_search_options: The full WebSearchOptions configuration. This TypedDict includes: + - user_location: Location context with "type" and "approximate" containing + "city", "country", "region", "timezone". + - search_context_size: One of "low", "medium", "high". + + Returns: + A dict configuration that enables web search when passed to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.openai import OpenAIChatClient + + # Basic web search + tool = OpenAIChatClient.get_web_search_tool() + + # With location context + tool = OpenAIChatClient.get_web_search_tool( + web_search_options={ + "user_location": { + "type": "approximate", + "approximate": {"city": "Seattle", "country": "US"}, + }, + "search_context_size": "medium", + } + ) + + agent = ChatAgent(client, tools=[tool]) + """ + tool: dict[str, Any] = {"type": "web_search"} + + if web_search_options: + tool.update(web_search_options) + + return tool + + # endregion + @override def _inner_get_response( self, *, - messages: Sequence[ChatMessage], + messages: Sequence[Message], options: Mapping[str, Any], stream: bool = False, **kwargs: Any, @@ -222,37 +273,37 @@ class RawOpenAIChatClient( # type: ignore[misc] # region content creation - def _prepare_tools_for_openai(self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]]) -> dict[str, Any]: - chat_tools: list[dict[str, Any]] = [] + def _prepare_tools_for_openai(self, tools: Sequence[Any]) -> dict[str, Any]: + """Prepare tools for the OpenAI Chat Completions API. + + Converts FunctionTool to JSON schema format. Web search tools are routed + to web_search_options parameter. All other tools pass through unchanged. + + Args: + tools: Sequence of tools to prepare. + + Returns: + Dict containing tools and optionally web_search_options. + """ + chat_tools: list[Any] = [] web_search_options: dict[str, Any] | None = None for tool in tools: - if isinstance(tool, ToolProtocol): - match tool: - case FunctionTool(): - chat_tools.append(tool.to_json_schema_spec()) - case HostedWebSearchTool(): - web_search_options = ( - { - "user_location": { - "approximate": tool.additional_properties.get("user_location", None), - "type": "approximate", - } - } - if tool.additional_properties and "user_location" in tool.additional_properties - else {} - ) - case _: - logger.debug("Unsupported tool passed (type: %s), ignoring", type(tool)) + if isinstance(tool, FunctionTool): + chat_tools.append(tool.to_json_schema_spec()) + elif isinstance(tool, MutableMapping) and tool.get("type") == "web_search": + # Web search is handled via web_search_options, not tools array + web_search_options = {k: v for k, v in tool.items() if k != "type"} else: - chat_tools.append(tool) # type: ignore[arg-type] - ret_dict: dict[str, Any] = {} + # Pass through all other tools (dicts, SDK types) unchanged + chat_tools.append(tool) + result: dict[str, Any] = {} if chat_tools: - ret_dict["tools"] = chat_tools + result["tools"] = chat_tools if web_search_options is not None: - ret_dict["web_search_options"] = web_search_options - return ret_dict + result["web_search_options"] = web_search_options + return result - def _prepare_options(self, messages: Sequence[ChatMessage], options: Mapping[str, Any]) -> dict[str, Any]: + def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, Any]) -> dict[str, Any]: # Prepend instructions from options if they exist from .._types import prepend_instructions_to_messages, validate_tool_mode @@ -310,7 +361,7 @@ class RawOpenAIChatClient( # type: ignore[misc] def _parse_response_from_openai(self, response: ChatCompletion, options: Mapping[str, Any]) -> ChatResponse: """Parse a response from OpenAI into a ChatResponse.""" response_metadata = self._get_metadata_from_chat_response(response) - messages: list[ChatMessage] = [] + messages: list[Message] = [] finish_reason: FinishReason | None = None for choice in response.choices: response_metadata.update(self._get_metadata_from_chat_choice(choice)) @@ -323,7 +374,7 @@ class RawOpenAIChatClient( # type: ignore[misc] contents.extend(parsed_tool_calls) if reasoning_details := getattr(choice.message, "reasoning_details", None): contents.append(Content.from_text_reasoning(protected_data=json.dumps(reasoning_details))) - messages.append(ChatMessage(role="assistant", contents=contents)) + messages.append(Message(role="assistant", contents=contents)) return ChatResponse( response_id=response.id, created_at=datetime.fromtimestamp(response.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), @@ -448,7 +499,7 @@ class RawOpenAIChatClient( # type: ignore[misc] def _prepare_messages_for_openai( self, - chat_messages: Sequence[ChatMessage], + chat_messages: Sequence[Message], role_key: str = "role", content_key: str = "content", ) -> list[dict[str, Any]]: @@ -476,7 +527,7 @@ class RawOpenAIChatClient( # type: ignore[misc] # region Parsers - def _prepare_message_for_openai(self, message: ChatMessage) -> list[dict[str, Any]]: + def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: """Prepare a chat message for OpenAI.""" all_messages: list[dict[str, Any]] = [] for content in message.contents: @@ -504,9 +555,7 @@ class RawOpenAIChatClient( # type: ignore[misc] args["tool_call_id"] = content.call_id # Always include content for tool results - API requires it even if empty # Functions returning None should still have a tool result message - args["content"] = ( - prepare_function_call_results(content.result) if content.result is not None else "" - ) + args["content"] = content.result if content.result is not None else "" case "text_reasoning" if (protected_data := content.protected_data) is not None: all_messages[-1]["reasoning_details"] = json.loads(protected_data) case _: @@ -593,11 +642,11 @@ class RawOpenAIChatClient( # type: ignore[misc] class OpenAIChatClient( # type: ignore[misc] OpenAIConfigMixin, - ChatMiddlewareLayer[TOpenAIChatOptions], - FunctionInvocationLayer[TOpenAIChatOptions], - ChatTelemetryLayer[TOpenAIChatOptions], - RawOpenAIChatClient[TOpenAIChatOptions], - Generic[TOpenAIChatOptions], + ChatMiddlewareLayer[OpenAIChatOptionsT], + FunctionInvocationLayer[OpenAIChatOptionsT], + ChatTelemetryLayer[OpenAIChatOptionsT], + RawOpenAIChatClient[OpenAIChatOptionsT], + Generic[OpenAIChatOptionsT], ): """OpenAI Chat completion class with middleware, telemetry, and function invocation support.""" @@ -667,33 +716,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 b2b7451918..5ab414dc85 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -13,7 +13,7 @@ from collections.abc import ( ) from datetime import datetime, timezone from itertools import chain -from typing import TYPE_CHECKING, Any, Generic, Literal, NoReturn, TypedDict, cast +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, NoReturn, TypedDict, cast from openai import AsyncOpenAI, BadRequestError from openai.types.responses.file_search_tool_param import FileSearchToolParam @@ -29,39 +29,34 @@ from openai.types.responses.response_usage import ResponseUsage from openai.types.responses.tool_param import ( CodeInterpreter, CodeInterpreterContainerCodeInterpreterToolAuto, + ImageGeneration, Mcp, - ToolParam, ) 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, FunctionTool, - HostedCodeInterpreterTool, - HostedFileSearchTool, - HostedImageGenerationTool, - HostedMCPTool, - HostedWebSearchTool, - ToolProtocol, ) from .._types import ( Annotation, - ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate, Content, + ContinuationToken, + Message, ResponseStream, Role, TextSpanRegion, UsageDetails, detect_media_type_from_base64, - prepare_function_call_results, prepend_instructions_to_messages, validate_tool_mode, ) @@ -98,7 +93,14 @@ if TYPE_CHECKING: logger = get_logger("agent_framework.openai") -__all__ = ["OpenAIResponsesClient", "OpenAIResponsesOptions", "RawOpenAIResponsesClient"] +__all__ = ["OpenAIContinuationToken", "OpenAIResponsesClient", "OpenAIResponsesOptions", "RawOpenAIResponsesClient"] + + +class OpenAIContinuationToken(ContinuationToken): + """Continuation token for OpenAI Responses API background operations.""" + + response_id: str + """OpenAI Responses API response ID.""" # region OpenAI Responses Options TypedDict @@ -124,10 +126,10 @@ class StreamOptions(TypedDict, total=False): """Whether to include usage statistics in stream events.""" -TResponseFormat = TypeVar("TResponseFormat", bound=BaseModel | None, default=None) +ResponseFormatT = TypeVar("ResponseFormatT", bound=BaseModel | None, default=None) -class OpenAIResponsesOptions(ChatOptions[TResponseFormat], Generic[TResponseFormat], total=False): +class OpenAIResponsesOptions(ChatOptions[ResponseFormatT], Generic[ResponseFormatT], total=False): """OpenAI Responses API-specific chat options. Extends ChatOptions with options specific to OpenAI's Responses API. @@ -190,9 +192,20 @@ class OpenAIResponsesOptions(ChatOptions[TResponseFormat], Generic[TResponseForm - 'auto': Truncate from beginning if exceeds context - 'disabled': Fail with 400 error if exceeds context""" + background: bool + """Whether to run the model response in the background. + When True, the response returns immediately with a continuation token + that can be used to poll for the result. + See: https://platform.openai.com/docs/guides/background""" -TOpenAIResponsesOptions = TypeVar( - "TOpenAIResponsesOptions", + continuation_token: OpenAIContinuationToken + """Token for resuming or polling a long-running background operation. + Pass the ``continuation_token`` from a previous response to poll for + completion or resume a streaming response.""" + + +OpenAIResponsesOptionsT = TypeVar( + "OpenAIResponsesOptionsT", bound=TypedDict, # type: ignore[valid-type] default="OpenAIResponsesOptions", covariant=True, @@ -207,8 +220,8 @@ TOpenAIResponsesOptions = TypeVar( class RawOpenAIResponsesClient( # type: ignore[misc] OpenAIBase, - BaseChatClient[TOpenAIResponsesOptions], - Generic[TOpenAIResponsesOptions], + BaseChatClient[OpenAIResponsesOptionsT], + Generic[OpenAIResponsesOptionsT], ): """Raw OpenAI Responses client without middleware, telemetry, or function invocation. @@ -225,13 +238,15 @@ class RawOpenAIResponsesClient( # type: ignore[misc] Use ``OpenAIResponsesClient`` instead for a fully-featured client with all layers applied. """ + STORES_BY_DEFAULT: ClassVar[bool] = True # type: ignore[reportIncompatibleVariableOverride, misc] + FILE_SEARCH_MAX_RESULTS: int = 50 # region Inner Methods async def _prepare_request( self, - messages: Sequence[ChatMessage], + messages: Sequence[Message], options: Mapping[str, Any], **kwargs: Any, ) -> tuple[AsyncOpenAI, dict[str, Any], dict[str, Any]]: @@ -261,38 +276,65 @@ class RawOpenAIResponsesClient( # type: ignore[misc] def _inner_get_response( self, *, - messages: Sequence[ChatMessage], + messages: Sequence[Message], options: Mapping[str, Any], stream: bool = False, **kwargs: Any, ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: + continuation_token: OpenAIContinuationToken | None = options.get("continuation_token") # type: ignore[assignment] + if stream: function_call_ids: dict[int, tuple[str, str]] = {} validated_options: dict[str, Any] | None = None async def _stream() -> AsyncIterable[ChatResponseUpdate]: nonlocal validated_options - client, run_options, validated_options = await self._prepare_request(messages, options, **kwargs) - try: - if "text_format" in run_options: - async with client.responses.stream(**run_options) as response: - async for chunk in response: - yield self._parse_chunk_from_openai( - chunk, options=validated_options, function_call_ids=function_call_ids - ) - else: - async for chunk in await client.responses.create(stream=True, **run_options): + if continuation_token is not None: + # Resume a background streaming response by retrieving with stream=True + client = await self._ensure_client() + validated_options = await self._validate_options(options) + try: + stream_response = await client.responses.retrieve( + continuation_token["response_id"], + stream=True, + ) + async for chunk in stream_response: yield self._parse_chunk_from_openai( chunk, options=validated_options, function_call_ids=function_call_ids ) - except Exception as ex: - self._handle_request_error(ex) + except Exception as ex: + self._handle_request_error(ex) + else: + client, run_options, validated_options = await self._prepare_request(messages, options, **kwargs) + try: + if "text_format" in run_options: + async with client.responses.stream(**run_options) as response: + async for chunk in response: + yield self._parse_chunk_from_openai( + chunk, options=validated_options, function_call_ids=function_call_ids + ) + else: + async for chunk in await client.responses.create(stream=True, **run_options): + yield self._parse_chunk_from_openai( + chunk, options=validated_options, function_call_ids=function_call_ids + ) + except Exception as ex: + self._handle_request_error(ex) response_format = validated_options.get("response_format") if validated_options else None return self._build_response_stream(_stream(), response_format=response_format) # Non-streaming async def _get_response() -> ChatResponse: + if continuation_token is not None: + # Poll a background response by retrieving without stream + client = await self._ensure_client() + validated_options = await self._validate_options(options) + try: + response = await client.responses.retrieve(continuation_token["response_id"]) + except Exception as ex: + self._handle_request_error(ex) + return self._parse_response_from_openai(response, options=validated_options) client, run_options, validated_options = await self._prepare_request(messages, options, **kwargs) try: if "text_format" in run_options: @@ -387,141 +429,337 @@ class RawOpenAIResponsesClient( # type: ignore[misc] # region Prep methods - def _prepare_tools_for_openai( - self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]] | None - ) -> list[ToolParam | dict[str, Any]]: - response_tools: list[ToolParam | dict[str, Any]] = [] - if not tools: - return response_tools - for tool in tools: - if isinstance(tool, ToolProtocol): - match tool: - case HostedMCPTool(): - response_tools.append(self._prepare_mcp_tool(tool)) - case HostedCodeInterpreterTool(): - tool_args: CodeInterpreterContainerCodeInterpreterToolAuto = {"type": "auto"} - if tool.inputs: - tool_args["file_ids"] = [] - for tool_input in tool.inputs: - if tool_input.type == "hosted_file": - tool_args["file_ids"].append(tool_input.file_id) # type: ignore[attr-defined] - if not tool_args["file_ids"]: - tool_args.pop("file_ids") - response_tools.append( - CodeInterpreter( - type="code_interpreter", - container=tool_args, - ) - ) - case FunctionTool(): - params = tool.parameters() - params["additionalProperties"] = False - response_tools.append( - FunctionToolParam( - name=tool.name, - parameters=params, - strict=False, - type="function", - description=tool.description, - ) - ) - case HostedFileSearchTool(): - if not tool.inputs: - raise ValueError("HostedFileSearchTool requires inputs to be specified.") - inputs: list[str] = [ - inp.vector_store_id # type: ignore[misc] - for inp in tool.inputs - if inp.type == "hosted_vector_store" # type: ignore[attr-defined] - ] - if not inputs: - raise ValueError( - "HostedFileSearchTool requires inputs to be of type `HostedVectorStoreContent`." - ) + def _prepare_tools_for_openai(self, tools: Sequence[Any] | None) -> list[Any]: + """Prepare tools for the OpenAI Responses API. - response_tools.append( - FileSearchToolParam( - type="file_search", - vector_store_ids=inputs, - max_num_results=tool.max_results - or self.FILE_SEARCH_MAX_RESULTS, # default to max results if not specified - ) - ) - case HostedWebSearchTool(): - web_search_tool = WebSearchToolParam(type="web_search") - if location := ( - tool.additional_properties.get("user_location", None) - if tool.additional_properties - else None - ): - web_search_tool["user_location"] = { - "type": "approximate", - "city": location.get("city", None), - "country": location.get("country", None), - "region": location.get("region", None), - "timezone": location.get("timezone", None), - } - if filters := ( - tool.additional_properties.get("filters", None) if tool.additional_properties else None - ): - web_search_tool["filters"] = filters - if search_context_size := ( - tool.additional_properties.get("search_context_size", None) - if tool.additional_properties - else None - ): - web_search_tool["search_context_size"] = search_context_size - response_tools.append(web_search_tool) - case HostedImageGenerationTool(): - mapped_tool: dict[str, Any] = {"type": "image_generation"} - if tool.options: - option_mapping = { - "image_size": "size", - "media_type": "output_format", - "model_id": "model", - "streaming_count": "partial_images", - } - # count and response_format are not supported by Responses API - for key, value in tool.options.items(): - mapped_key = option_mapping.get(key, key) - mapped_tool[mapped_key] = value - if tool.additional_properties: - mapped_tool.update(tool.additional_properties) - response_tools.append(mapped_tool) - case _: - logger.debug("Unsupported tool passed (type: %s)", type(tool)) + Converts FunctionTool to Responses API format. All other tools pass through unchanged. + + Args: + tools: Sequence of tools to prepare. + + Returns: + List of tool parameters ready for the OpenAI API. + """ + if not tools: + return [] + response_tools: list[Any] = [] + for tool in tools: + if isinstance(tool, FunctionTool): + params = tool.parameters() + params["additionalProperties"] = False + response_tools.append( + FunctionToolParam( + name=tool.name, + parameters=params, + strict=False, + type="function", + description=tool.description, + ) + ) else: - # Handle raw dictionary tools - tool_dict = tool if isinstance(tool, dict) else dict(tool) - response_tools.append(tool_dict) + # Pass through all other tools (dicts, SDK types) unchanged + response_tools.append(tool) return response_tools + # region Hosted Tool Factory Methods + @staticmethod - def _prepare_mcp_tool(tool: HostedMCPTool) -> Mcp: - """Get MCP tool from HostedMCPTool.""" + def get_code_interpreter_tool( + *, + file_ids: list[str] | None = None, + container: Literal["auto"] | CodeInterpreterContainerCodeInterpreterToolAuto = "auto", + ) -> Any: + """Create a code interpreter tool configuration for the Responses API. + + Keyword Args: + file_ids: List of file IDs to make available to the code interpreter. + container: Container configuration. Use "auto" for automatic container management, + or provide a TypedDict with custom container settings. + + Returns: + A CodeInterpreter tool parameter ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.openai import OpenAIResponsesClient + + # Basic code interpreter + tool = OpenAIResponsesClient.get_code_interpreter_tool() + + # With file access + tool = OpenAIResponsesClient.get_code_interpreter_tool(file_ids=["file-abc123"]) + + # Use with agent + agent = ChatAgent(client, tools=[tool]) + """ + container_config: CodeInterpreterContainerCodeInterpreterToolAuto = ( + container if isinstance(container, dict) else {"type": "auto"} + ) + + if file_ids: + container_config["file_ids"] = file_ids + + return CodeInterpreter(type="code_interpreter", container=container_config) + + @staticmethod + def get_web_search_tool( + *, + user_location: dict[str, str] | None = None, + search_context_size: Literal["low", "medium", "high"] | None = None, + filters: dict[str, Any] | None = None, + ) -> Any: + """Create a web search tool configuration for the Responses API. + + Keyword Args: + user_location: Location context for search results. Dict with keys like + "city", "country", "region", "timezone". + search_context_size: Amount of context to include from search results. + One of "low", "medium", or "high". + filters: Additional search filters. + + Returns: + A WebSearchToolParam dict ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.openai import OpenAIResponsesClient + + # Basic web search + tool = OpenAIResponsesClient.get_web_search_tool() + + # With location context + tool = OpenAIResponsesClient.get_web_search_tool( + user_location={"city": "Seattle", "country": "US"}, + search_context_size="medium", + ) + + agent = ChatAgent(client, tools=[tool]) + """ + web_search_tool = WebSearchToolParam(type="web_search") + + if user_location: + web_search_tool["user_location"] = { + "type": "approximate", + "city": user_location.get("city"), + "country": user_location.get("country"), + "region": user_location.get("region"), + "timezone": user_location.get("timezone"), + } + + if search_context_size: + web_search_tool["search_context_size"] = search_context_size + + if filters: + web_search_tool["filters"] = filters # type: ignore[typeddict-item] + + return web_search_tool + + @staticmethod + def get_image_generation_tool( + *, + size: Literal["1024x1024", "1024x1536", "1536x1024", "auto"] | None = None, + output_format: Literal["png", "jpeg", "webp"] | None = None, + model: Literal["gpt-image-1", "gpt-image-1-mini"] | str | None = None, + quality: Literal["low", "medium", "high", "auto"] | None = None, + partial_images: int | None = None, + background: Literal["transparent", "opaque", "auto"] | None = None, + moderation: Literal["auto", "low"] | None = None, + output_compression: int | None = None, + ) -> Any: + """Create an image generation tool configuration for the Responses API. + + Keyword Args: + size: Image dimensions. One of "1024x1024", "1024x1536", "1536x1024", or "auto". + output_format: Output image format. One of "png", "jpeg", or "webp". + model: Model to use for image generation. One of "gpt-image-1" or "gpt-image-1-mini". + quality: Image quality level. One of "low", "medium", "high", or "auto". + partial_images: Number of partial images to stream during generation. + background: Background type. One of "transparent", "opaque", or "auto". + moderation: Moderation level. One of "auto" or "low". + output_compression: Compression level for output (0-100). + + Returns: + An ImageGeneration tool parameter dict ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.openai import OpenAIResponsesClient + + # Basic image generation + tool = OpenAIResponsesClient.get_image_generation_tool() + + # High quality large image + tool = OpenAIResponsesClient.get_image_generation_tool( + size="1536x1024", + quality="high", + output_format="png", + ) + + agent = ChatAgent(client, tools=[tool]) + """ + tool: ImageGeneration = {"type": "image_generation"} + + if size: + tool["size"] = size + if output_format: + tool["output_format"] = output_format + if model: + tool["model"] = model + if quality: + tool["quality"] = quality + if partial_images is not None: + tool["partial_images"] = partial_images + if background: + tool["background"] = background + if moderation: + tool["moderation"] = moderation + if output_compression is not None: + tool["output_compression"] = output_compression + + return tool + + @staticmethod + def get_mcp_tool( + *, + name: str, + url: str, + description: str | None = None, + approval_mode: Literal["always_require", "never_require"] | dict[str, list[str]] | None = None, + allowed_tools: list[str] | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + """Create a hosted MCP (Model Context Protocol) tool configuration for the Responses API. + + This configures an MCP server that will be called by OpenAI's service. + The tools from this MCP server are executed remotely by OpenAI, + not locally by your application. + + Note: + For local MCP execution where your application calls the MCP server + directly, use the MCP client tools instead of this method. + + Keyword Args: + name: A label/name for the MCP server. + url: The URL of the MCP server. + description: A description of what the MCP server provides. + approval_mode: Tool approval mode. Use "always_require" or "never_require" for all tools, + or provide a dict with "always_require_approval" and/or "never_require_approval" + keys mapping to lists of tool names. + allowed_tools: List of tool names that are allowed to be used from this MCP server. + headers: HTTP headers to include in requests to the MCP server. + + Returns: + An Mcp tool parameter dict ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.openai import OpenAIResponsesClient + + # Basic MCP tool + tool = OpenAIResponsesClient.get_mcp_tool( + name="my_mcp", + url="https://mcp.example.com", + ) + + # With approval settings + tool = OpenAIResponsesClient.get_mcp_tool( + name="github_mcp", + url="https://mcp.github.com", + description="GitHub MCP server", + approval_mode="always_require", + headers={"Authorization": "Bearer token"}, + ) + + # With specific tool approvals + tool = OpenAIResponsesClient.get_mcp_tool( + name="tools_mcp", + url="https://tools.example.com", + approval_mode={ + "always_require_approval": ["dangerous_tool"], + "never_require_approval": ["safe_tool"], + }, + ) + + agent = ChatAgent(client, tools=[tool]) + """ mcp: Mcp = { "type": "mcp", - "server_label": tool.name.replace(" ", "_"), - "server_url": str(tool.url), - "server_description": tool.description, - "headers": tool.headers, + "server_label": name.replace(" ", "_"), + "server_url": url, } - if tool.allowed_tools: - mcp["allowed_tools"] = list(tool.allowed_tools) - if tool.approval_mode: - match tool.approval_mode: - case str(): - mcp["require_approval"] = "always" if tool.approval_mode == "always_require" else "never" - case _: - if always_require_approvals := tool.approval_mode.get("always_require_approval"): - mcp["require_approval"] = {"always": {"tool_names": list(always_require_approvals)}} - if never_require_approvals := tool.approval_mode.get("never_require_approval"): - mcp["require_approval"] = {"never": {"tool_names": list(never_require_approvals)}} + + if description: + mcp["server_description"] = description + + if headers: + mcp["headers"] = headers + + if allowed_tools: + mcp["allowed_tools"] = allowed_tools + + if approval_mode: + if isinstance(approval_mode, str): + mcp["require_approval"] = "always" if approval_mode == "always_require" else "never" + else: + if always_require := approval_mode.get("always_require_approval"): + mcp["require_approval"] = {"always": {"tool_names": always_require}} + if never_require := approval_mode.get("never_require_approval"): + mcp["require_approval"] = {"never": {"tool_names": never_require}} return mcp + @staticmethod + def get_file_search_tool( + *, + vector_store_ids: list[str], + max_num_results: int | None = None, + ) -> Any: + """Create a file search tool configuration for the Responses API. + + Keyword Args: + vector_store_ids: List of vector store IDs to search within. + max_num_results: Maximum number of results to return. Defaults to 50 if not specified. + + Returns: + A FileSearchToolParam dict ready to pass to ChatAgent. + + Examples: + .. code-block:: python + + from agent_framework.openai import OpenAIResponsesClient + + # Basic file search + tool = OpenAIResponsesClient.get_file_search_tool( + vector_store_ids=["vs_abc123"], + ) + + # With result limit + tool = OpenAIResponsesClient.get_file_search_tool( + vector_store_ids=["vs_abc123", "vs_def456"], + max_num_results=10, + ) + + agent = ChatAgent(client, tools=[tool]) + """ + tool = FileSearchToolParam( + type="file_search", + vector_store_ids=vector_store_ids, + ) + + if max_num_results is not None: + tool["max_num_results"] = max_num_results + + return tool + + # endregion + async def _prepare_options( self, - messages: Sequence[ChatMessage], + messages: Sequence[Message], options: Mapping[str, Any], **kwargs: Any, ) -> dict[str, Any]: @@ -538,6 +776,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] "response_format", # handled separately "conversation_id", # handled separately "tool_choice", # handled separately + "continuation_token", # handled separately in _inner_get_response } run_options: dict[str, Any] = {k: v for k, v in options.items() if k not in exclude_keys and v is not None} @@ -626,7 +865,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] """ return kwargs.get("conversation_id") or options.get("conversation_id") - def _prepare_messages_for_openai(self, chat_messages: Sequence[ChatMessage]) -> list[dict[str, Any]]: + def _prepare_messages_for_openai(self, chat_messages: Sequence[Message]) -> list[dict[str, Any]]: """Prepare the chat messages for a request. Allowing customization of the key names for role/author, and optionally overriding the role. @@ -658,12 +897,13 @@ class RawOpenAIResponsesClient( # type: ignore[misc] def _prepare_message_for_openai( self, - message: ChatMessage, + message: Message, call_id_to_id: dict[str, str], ) -> list[dict[str, Any]]: """Prepare a chat message for the OpenAI Responses API format.""" all_messages: list[dict[str, Any]] = [] args: dict[str, Any] = { + "type": "message", "role": message.role, } for content in message.contents: @@ -674,16 +914,22 @@ class RawOpenAIResponsesClient( # type: ignore[misc] case "function_result": new_args: dict[str, Any] = {} new_args.update(self._prepare_content_for_openai(message.role, content, call_id_to_id)) # type: ignore[arg-type] - all_messages.append(new_args) + if new_args: + all_messages.append(new_args) case "function_call": function_call = self._prepare_content_for_openai(message.role, content, call_id_to_id) # type: ignore[arg-type] - all_messages.append(function_call) # type: ignore + if function_call: + all_messages.append(function_call) # type: ignore case "function_approval_response" | "function_approval_request": - all_messages.append(self._prepare_content_for_openai(message.role, content, call_id_to_id)) # type: ignore + prepared = self._prepare_content_for_openai(Role(message.role), content, call_id_to_id) + if prepared: + all_messages.append(prepared) # type: ignore case _: - if "content" not in args: - args["content"] = [] - args["content"].append(self._prepare_content_for_openai(message.role, content, call_id_to_id)) # type: ignore + prepared_content = self._prepare_content_for_openai(message.role, content, call_id_to_id) # type: ignore + if prepared_content: + if "content" not in args: + args["content"] = [] + args["content"].append(prepared_content) # type: ignore if "content" in args or "tool_calls" in args: all_messages.append(args) return all_messages @@ -697,8 +943,16 @@ class RawOpenAIResponsesClient( # type: ignore[misc] """Prepare content for the OpenAI Responses API format.""" match content.type: case "text": + if role == "assistant": + # Assistant history is represented as output text items; Azure validation + # requires `annotations` to be present for this type. + return { + "type": "output_text", + "text": content.text, + "annotations": [], + } return { - "type": "output_text" if role == "assistant" else "input_text", + "type": "input_text", "text": content.text, } case "text_reasoning": @@ -784,7 +1038,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] args: dict[str, Any] = { "call_id": content.call_id, "type": "function_call_output", - "output": prepare_function_call_results(content.result), + "output": content.result if content.result is not None else "", } return args case "function_approval_request": @@ -857,7 +1111,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] for annotation in message_content.annotations: match annotation.type: case "file_path": - text_content.annotations.append( + text_content.annotations.append( # pyright: ignore[reportUnknownMemberType] Annotation( type="citation", file_id=annotation.file_id, @@ -868,7 +1122,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] ) ) case "file_citation": - text_content.annotations.append( + text_content.annotations.append( # pyright: ignore[reportUnknownMemberType] Annotation( type="citation", url=annotation.filename, @@ -880,7 +1134,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] ) ) case "url_citation": - text_content.annotations.append( + text_content.annotations.append( # pyright: ignore[reportUnknownMemberType] Annotation( type="citation", title=annotation.title, @@ -896,7 +1150,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] ) ) case "container_file_citation": - text_content.annotations.append( + text_content.annotations.append( # pyright: ignore[reportUnknownMemberType] Annotation( type="citation", file_id=annotation.file_id, @@ -1048,7 +1302,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] ) case _: logger.debug("Unparsed output of type: %s: %s", item.type, item) - response_message = ChatMessage(role="assistant", contents=contents) + response_message = Message(role="assistant", contents=contents) args: dict[str, Any] = { "response_id": response.id, "created_at": datetime.fromtimestamp(response.created_at, tz=timezone.utc).strftime( @@ -1060,7 +1314,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] "raw_representation": response, } - if conversation_id := self._get_conversation_id(response, options.get("store")): + if conversation_id := self._get_conversation_id(response, options.get("store")): # pyright: ignore[reportUnknownArgumentType] args["conversation_id"] = conversation_id if response.usage and (usage_details := self._parse_usage_from_openai(response.usage)): args["usage_details"] = usage_details @@ -1070,6 +1324,9 @@ class RawOpenAIResponsesClient( # type: ignore[misc] # Only pass response_format to ChatResponse if it's a Pydantic model type, # not a runtime JSON schema dict args["response_format"] = response_format + # Set continuation_token when background operation is still in progress + if response.status and response.status in ("in_progress", "queued"): + args["continuation_token"] = OpenAIContinuationToken(response_id=response.id) return ChatResponse(**args) def _parse_chunk_from_openai( @@ -1083,6 +1340,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] contents: list[Content] = [] conversation_id: str | None = None response_id: str | None = None + continuation_token: OpenAIContinuationToken | None = None model = self.model_id match event.type: # types: @@ -1164,12 +1422,59 @@ class RawOpenAIResponsesClient( # type: ignore[misc] case "response.reasoning_summary_text.done": contents.append(Content.from_text_reasoning(text=event.text, raw_representation=event)) metadata.update(self._get_metadata_from_response(event)) + case "response.code_interpreter_call_code.delta": + call_id = getattr(event, "call_id", None) or getattr(event, "id", None) or event.item_id + ci_additional_properties = { + "output_index": event.output_index, + "sequence_number": event.sequence_number, + "item_id": event.item_id, + } + contents.append( + Content.from_code_interpreter_tool_call( + call_id=call_id, + inputs=[ + Content.from_text( + text=event.delta, + raw_representation=event, + additional_properties=ci_additional_properties, + ) + ], + raw_representation=event, + additional_properties=ci_additional_properties, + ) + ) + metadata.update(self._get_metadata_from_response(event)) + case "response.code_interpreter_call_code.done": + call_id = getattr(event, "call_id", None) or getattr(event, "id", None) or event.item_id + ci_additional_properties = { + "output_index": event.output_index, + "sequence_number": event.sequence_number, + "item_id": event.item_id, + } + contents.append( + Content.from_code_interpreter_tool_call( + call_id=call_id, + inputs=[ + Content.from_text( + text=event.code, + raw_representation=event, + additional_properties=ci_additional_properties, + ) + ], + raw_representation=event, + additional_properties=ci_additional_properties, + ) + ) + metadata.update(self._get_metadata_from_response(event)) case "response.created": response_id = event.response.id conversation_id = self._get_conversation_id(event.response, options.get("store")) + if event.response.status and event.response.status in ("in_progress", "queued"): + continuation_token = OpenAIContinuationToken(response_id=event.response.id) case "response.in_progress": response_id = event.response.id conversation_id = self._get_conversation_id(event.response, options.get("store")) + continuation_token = OpenAIContinuationToken(response_id=event.response.id) case "response.completed": response_id = event.response.id conversation_id = self._get_conversation_id(event.response, options.get("store")) @@ -1231,13 +1536,13 @@ class RawOpenAIResponsesClient( # type: ignore[misc] ) parsed_output: list[Content] | None = None if result_output: - normalized = ( + normalized = ( # pyright: ignore[reportUnknownVariableType] result_output if isinstance(result_output, Sequence) and not isinstance(result_output, (str, bytes, MutableMapping)) else [result_output] ) - parsed_output = [Content.from_dict(output_item) for output_item in normalized] + parsed_output = [Content.from_dict(output_item) for output_item in normalized] # pyright: ignore[reportArgumentType,reportUnknownVariableType] contents.append( Content.from_mcp_server_tool_result( call_id=call_id, @@ -1410,6 +1715,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] response_id=response_id, role="assistant", model_id=model, + continuation_token=continuation_token, additional_properties=metadata, raw_representation=event, ) @@ -1437,11 +1743,11 @@ class RawOpenAIResponsesClient( # type: ignore[misc] class OpenAIResponsesClient( # type: ignore[misc] OpenAIConfigMixin, - ChatMiddlewareLayer[TOpenAIResponsesOptions], - FunctionInvocationLayer[TOpenAIResponsesOptions], - ChatTelemetryLayer[TOpenAIResponsesOptions], - RawOpenAIResponsesClient[TOpenAIResponsesOptions], - Generic[TOpenAIResponsesOptions], + ChatMiddlewareLayer[OpenAIResponsesOptionsT], + FunctionInvocationLayer[OpenAIResponsesOptionsT], + ChatTelemetryLayer[OpenAIResponsesOptionsT], + RawOpenAIResponsesClient[OpenAIResponsesOptionsT], + Generic[OpenAIResponsesOptionsT], ): """OpenAI Responses client class with middleware, telemetry, and function invocation support.""" @@ -1514,36 +1820,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 dbf0d9f6f6..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,13 +21,12 @@ 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, HostedCodeInterpreterTool, HostedFileSearchTool, ToolProtocol +from .._tools import FunctionTool from ..exceptions import ServiceInitializationError logger: logging.Logger = get_logger("agent_framework.openai") @@ -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 @@ -284,12 +284,14 @@ class OpenAIConfigMixin(OpenAIBase): def to_assistant_tools( - tools: Sequence[ToolProtocol | MutableMapping[str, Any]] | None, + tools: Sequence[FunctionTool | MutableMapping[str, Any]] | None, ) -> list[dict[str, Any]]: """Convert Agent Framework tools to OpenAI Assistants API format. + Handles FunctionTool instances and dict-based tools from static factory methods. + Args: - tools: Normalized tools (from ChatOptions.tools). + tools: Sequence of Agent Framework tools. Returns: List of tool definitions for OpenAI Assistants API. @@ -302,15 +304,8 @@ def to_assistant_tools( for tool in tools: if isinstance(tool, FunctionTool): tool_definitions.append(tool.to_json_schema_spec()) - elif isinstance(tool, HostedCodeInterpreterTool): - tool_definitions.append({"type": "code_interpreter"}) - elif isinstance(tool, HostedFileSearchTool): - params: dict[str, Any] = {"type": "file_search"} - if tool.max_results is not None: - params["file_search"] = {"max_num_results": tool.max_results} - tool_definitions.append(params) elif isinstance(tool, MutableMapping): - # Pass through raw dict definitions + # Pass through dict-based tools directly (from static factory methods) tool_definitions.append(dict(tool)) return tool_definitions @@ -318,11 +313,11 @@ def to_assistant_tools( def from_assistant_tools( assistant_tools: list[Any] | None, -) -> list[ToolProtocol]: - """Convert OpenAI Assistant tools to Agent Framework format. +) -> list[dict[str, Any]]: + """Convert OpenAI Assistant tools to dict-based format. This converts hosted tools (code_interpreter, file_search) from an OpenAI - Assistant definition back to Agent Framework tool instances. + Assistant definition back to dict-based tool definitions. Note: Function tools are skipped - user must provide implementations separately. @@ -330,12 +325,12 @@ def from_assistant_tools( assistant_tools: Tools from OpenAI Assistant object (assistant.tools). Returns: - List of Agent Framework tool instances for hosted tools. + List of dict-based tool definitions for hosted tools. """ if not assistant_tools: return [] - tools: list[ToolProtocol] = [] + tools: list[dict[str, Any]] = [] for tool in assistant_tools: if hasattr(tool, "type"): @@ -346,9 +341,9 @@ def from_assistant_tools( tool_type = None if tool_type == "code_interpreter": - tools.append(HostedCodeInterpreterTool()) + tools.append({"type": "code_interpreter"}) elif tool_type == "file_search": - tools.append(HostedFileSearchTool()) + tools.append({"type": "file_search"}) # Skip function tools - user must provide implementations return tools diff --git a/python/packages/core/agent_framework/redis/__init__.py b/python/packages/core/agent_framework/redis/__init__.py index 85594715cb..9f96b3455f 100644 --- a/python/packages/core/agent_framework/redis/__init__.py +++ b/python/packages/core/agent_framework/redis/__init__.py @@ -5,7 +5,7 @@ from typing import Any IMPORT_PATH = "agent_framework_redis" PACKAGE_NAME = "agent-framework-redis" -_IMPORTS = ["__version__", "RedisProvider", "RedisChatMessageStore"] +_IMPORTS = ["__version__", "RedisContextProvider", "RedisHistoryProvider"] def __getattr__(name: str) -> Any: diff --git a/python/packages/core/agent_framework/redis/__init__.pyi b/python/packages/core/agent_framework/redis/__init__.pyi index 6cce35db76..fc62badb76 100644 --- a/python/packages/core/agent_framework/redis/__init__.pyi +++ b/python/packages/core/agent_framework/redis/__init__.pyi @@ -1,13 +1,13 @@ # Copyright (c) Microsoft. All rights reserved. from agent_framework_redis import ( - RedisChatMessageStore, - RedisProvider, + RedisContextProvider, + RedisHistoryProvider, __version__, ) __all__ = [ - "RedisChatMessageStore", - "RedisProvider", + "RedisContextProvider", + "RedisHistoryProvider", "__version__", ] diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 726c1cdcb4..bb3bb394b9 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -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", @@ -34,6 +34,7 @@ dependencies = [ # connectors and functions "openai>=1.99.0", "azure-identity>=1,<2", + "azure-ai-projects >= 2.0.0b3", "mcp[ws]>=1.24.0,<2", "packaging>=24.1", ] diff --git a/python/packages/core/tests/azure/conftest.py b/python/packages/core/tests/azure/conftest.py index a9c03cd664..9d8ce0cebb 100644 --- a/python/packages/core/tests/azure/conftest.py +++ b/python/packages/core/tests/azure/conftest.py @@ -3,7 +3,7 @@ from typing import Any from pytest import fixture -from agent_framework import ChatMessage +from agent_framework import Message # region: Connector Settings fixtures @@ -58,5 +58,5 @@ def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dic @fixture(scope="function") -def chat_history() -> list[ChatMessage]: +def chat_history() -> list[Message]: return [] 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 9c95bed1c1..eff19b27e6 100644 --- a/python/packages/core/tests/azure/test_azure_assistants_client.py +++ b/python/packages/core/tests/azure/test_azure_assistants_client.py @@ -9,17 +9,17 @@ from azure.identity import AzureCliCredential from pydantic import Field from agent_framework import ( + Agent, AgentResponse, AgentResponseUpdate, - AgentThread, - ChatAgent, - ChatClientProtocol, - ChatMessage, + AgentSession, ChatResponse, ChatResponseUpdate, - HostedCodeInterpreterTool, + Message, + SupportsChatGetResponse, tool, ) +from agent_framework._settings import SecretString from agent_framework.azure import AzureOpenAIAssistantsClient from agent_framework.exceptions import ServiceInitializationError @@ -83,19 +83,19 @@ def mock_async_azure_openai() -> MagicMock: def test_azure_assistants_client_init_with_client(mock_async_azure_openai: MagicMock) -> None: """Test AzureOpenAIAssistantsClient initialization with existing client.""" - chat_client = create_test_azure_assistants_client( + client = create_test_azure_assistants_client( mock_async_azure_openai, deployment_name="test_chat_deployment", assistant_id="existing-assistant-id", thread_id="test-thread-id", ) - assert chat_client.client is mock_async_azure_openai - assert chat_client.model_id == "test_chat_deployment" - assert chat_client.assistant_id == "existing-assistant-id" - assert chat_client.thread_id == "test-thread-id" - assert not chat_client._should_delete_assistant # type: ignore - assert isinstance(chat_client, ChatClientProtocol) + assert client.client is mock_async_azure_openai + assert client.model_id == "test_chat_deployment" + assert client.assistant_id == "existing-assistant-id" + assert client.thread_id == "test-thread-id" + assert not client._should_delete_assistant # type: ignore + assert isinstance(client, SupportsChatGetResponse) def test_azure_assistants_client_init_auto_create_client( @@ -103,7 +103,7 @@ def test_azure_assistants_client_init_auto_create_client( mock_async_azure_openai: MagicMock, ) -> None: """Test AzureOpenAIAssistantsClient initialization with auto-created client.""" - chat_client = AzureOpenAIAssistantsClient( + client = AzureOpenAIAssistantsClient( deployment_name=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], assistant_name="TestAssistant", api_key=azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"], @@ -111,11 +111,11 @@ def test_azure_assistants_client_init_auto_create_client( async_client=mock_async_azure_openai, ) - assert chat_client.client is mock_async_azure_openai - assert chat_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] - assert chat_client.assistant_id is None - assert chat_client.assistant_name == "TestAssistant" - assert not chat_client._should_delete_assistant # type: ignore + assert client.client is mock_async_azure_openai + assert client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] + assert client.assistant_id is None + assert client.assistant_name == "TestAssistant" + assert not client._should_delete_assistant # type: ignore def test_azure_assistants_client_init_validation_fail() -> None: @@ -138,32 +138,32 @@ def test_azure_assistants_client_init_with_default_headers(azure_openai_unit_tes """Test AzureOpenAIAssistantsClient initialization with default headers.""" default_headers = {"X-Unit-Test": "test-guid"} - chat_client = AzureOpenAIAssistantsClient( + client = AzureOpenAIAssistantsClient( deployment_name="test_chat_deployment", api_key=azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"], endpoint=azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"], default_headers=default_headers, ) - assert chat_client.model_id == "test_chat_deployment" - assert isinstance(chat_client, ChatClientProtocol) + assert client.model_id == "test_chat_deployment" + assert isinstance(client, SupportsChatGetResponse) # Assert that the default header we added is present in the client's default headers for key, value in default_headers.items(): - assert key in chat_client.client.default_headers - assert chat_client.client.default_headers[key] == value + assert key in client.client.default_headers + assert client.client.default_headers[key] == value async def test_azure_assistants_client_get_assistant_id_or_create_existing_assistant( mock_async_azure_openai: MagicMock, ) -> None: """Test _get_assistant_id_or_create when assistant_id is already provided.""" - chat_client = create_test_azure_assistants_client(mock_async_azure_openai, assistant_id="existing-assistant-id") + client = create_test_azure_assistants_client(mock_async_azure_openai, assistant_id="existing-assistant-id") - assistant_id = await chat_client._get_assistant_id_or_create() # type: ignore + assistant_id = await client._get_assistant_id_or_create() # type: ignore assert assistant_id == "existing-assistant-id" - assert not chat_client._should_delete_assistant # type: ignore + assert not client._should_delete_assistant # type: ignore mock_async_azure_openai.beta.assistants.create.assert_not_called() @@ -171,14 +171,14 @@ async def test_azure_assistants_client_get_assistant_id_or_create_create_new( mock_async_azure_openai: MagicMock, ) -> None: """Test _get_assistant_id_or_create when creating a new assistant.""" - chat_client = create_test_azure_assistants_client( + client = create_test_azure_assistants_client( mock_async_azure_openai, deployment_name="test_chat_deployment", assistant_name="TestAssistant" ) - assistant_id = await chat_client._get_assistant_id_or_create() # type: ignore + assistant_id = await client._get_assistant_id_or_create() # type: ignore assert assistant_id == "test-assistant-id" - assert chat_client._should_delete_assistant # type: ignore + assert client._should_delete_assistant # type: ignore mock_async_azure_openai.beta.assistants.create.assert_called_once() @@ -186,38 +186,38 @@ async def test_azure_assistants_client_aclose_should_not_delete( mock_async_azure_openai: MagicMock, ) -> None: """Test close when assistant should not be deleted.""" - chat_client = create_test_azure_assistants_client( + client = create_test_azure_assistants_client( mock_async_azure_openai, assistant_id="assistant-to-keep", should_delete_assistant=False ) - await chat_client.close() # type: ignore + await client.close() # type: ignore # Verify assistant deletion was not called mock_async_azure_openai.beta.assistants.delete.assert_not_called() - assert not chat_client._should_delete_assistant # type: ignore + assert not client._should_delete_assistant # type: ignore async def test_azure_assistants_client_aclose_should_delete(mock_async_azure_openai: MagicMock) -> None: """Test close method calls cleanup.""" - chat_client = create_test_azure_assistants_client( + client = create_test_azure_assistants_client( mock_async_azure_openai, assistant_id="assistant-to-delete", should_delete_assistant=True ) - await chat_client.close() + await client.close() # Verify assistant deletion was called mock_async_azure_openai.beta.assistants.delete.assert_called_once_with("assistant-to-delete") - assert not chat_client._should_delete_assistant # type: ignore + assert not client._should_delete_assistant # type: ignore async def test_azure_assistants_client_async_context_manager(mock_async_azure_openai: MagicMock) -> None: """Test async context manager functionality.""" - chat_client = create_test_azure_assistants_client( + client = create_test_azure_assistants_client( mock_async_azure_openai, assistant_id="assistant-to-delete", should_delete_assistant=True ) # Test context manager - async with chat_client: + async with client: pass # Just test that we can enter and exit # Verify cleanup was called on exit @@ -229,7 +229,7 @@ def test_azure_assistants_client_serialize(azure_openai_unit_test_env: dict[str, default_headers = {"X-Unit-Test": "test-guid"} # Test basic initialization and to_dict - chat_client = AzureOpenAIAssistantsClient( + client = AzureOpenAIAssistantsClient( deployment_name="test_chat_deployment", assistant_id="test-assistant-id", assistant_name="TestAssistant", @@ -239,7 +239,7 @@ def test_azure_assistants_client_serialize(azure_openai_unit_test_env: dict[str, default_headers=default_headers, ) - dumped_settings = chat_client.to_dict() + dumped_settings = client.to_dict() assert dumped_settings["model_id"] == "test_chat_deployment" assert dumped_settings["assistant_id"] == "test-assistant-id" @@ -267,17 +267,17 @@ def get_weather( async def test_azure_assistants_client_get_response() -> None: """Test Azure Assistants Client response.""" async with AzureOpenAIAssistantsClient(credential=AzureCliCredential()) as azure_assistants_client: - assert isinstance(azure_assistants_client, ChatClientProtocol) + assert isinstance(azure_assistants_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] + messages: list[Message] = [] messages.append( - ChatMessage( + Message( role="user", text="The weather in Seattle is currently sunny with a high of 25°C. " "It's a beautiful day for outdoor activities.", ) ) - messages.append(ChatMessage(role="user", text="What's the weather like today?")) + messages.append(Message(role="user", text="What's the weather like today?")) # Test that the client can be used to get a response response = await azure_assistants_client.get_response(messages=messages) @@ -292,10 +292,10 @@ async def test_azure_assistants_client_get_response() -> None: async def test_azure_assistants_client_get_response_tools() -> None: """Test Azure Assistants Client response with tools.""" async with AzureOpenAIAssistantsClient(credential=AzureCliCredential()) as azure_assistants_client: - assert isinstance(azure_assistants_client, ChatClientProtocol) + assert isinstance(azure_assistants_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] - messages.append(ChatMessage(role="user", text="What's the weather like in Seattle?")) + messages: list[Message] = [] + messages.append(Message(role="user", text="What's the weather like in Seattle?")) # Test that the client can be used to get a response response = await azure_assistants_client.get_response( @@ -313,17 +313,17 @@ async def test_azure_assistants_client_get_response_tools() -> None: async def test_azure_assistants_client_streaming() -> None: """Test Azure Assistants Client streaming response.""" async with AzureOpenAIAssistantsClient(credential=AzureCliCredential()) as azure_assistants_client: - assert isinstance(azure_assistants_client, ChatClientProtocol) + assert isinstance(azure_assistants_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] + messages: list[Message] = [] messages.append( - ChatMessage( + Message( role="user", text="The weather in Seattle is currently sunny with a high of 25°C. " "It's a beautiful day for outdoor activities.", ) ) - messages.append(ChatMessage(role="user", text="What's the weather like today?")) + messages.append(Message(role="user", text="What's the weather like today?")) # Test that the client can be used to get a response response = azure_assistants_client.get_response(messages=messages, stream=True) @@ -344,10 +344,10 @@ async def test_azure_assistants_client_streaming() -> None: async def test_azure_assistants_client_streaming_tools() -> None: """Test Azure Assistants Client streaming response with tools.""" async with AzureOpenAIAssistantsClient(credential=AzureCliCredential()) as azure_assistants_client: - assert isinstance(azure_assistants_client, ChatClientProtocol) + assert isinstance(azure_assistants_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] - messages.append(ChatMessage(role="user", text="What's the weather like in Seattle?")) + messages: list[Message] = [] + messages.append(Message(role="user", text="What's the weather like in Seattle?")) # Test that the client can be used to get a response response = azure_assistants_client.get_response( @@ -373,7 +373,7 @@ async def test_azure_assistants_client_with_existing_assistant() -> None: # First create an assistant to use in the test async with AzureOpenAIAssistantsClient(credential=AzureCliCredential()) as temp_client: # Get the assistant ID by triggering assistant creation - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] await temp_client.get_response(messages=messages) assistant_id = temp_client.assistant_id @@ -381,10 +381,10 @@ async def test_azure_assistants_client_with_existing_assistant() -> None: async with AzureOpenAIAssistantsClient( assistant_id=assistant_id, credential=AzureCliCredential() ) as azure_assistants_client: - assert isinstance(azure_assistants_client, ChatClientProtocol) + assert isinstance(azure_assistants_client, SupportsChatGetResponse) assert azure_assistants_client.assistant_id == assistant_id - messages = [ChatMessage(role="user", text="What can you do?")] + messages = [Message(role="user", text="What can you do?")] # Test that the client can be used to get a response response = await azure_assistants_client.get_response(messages=messages) @@ -397,9 +397,9 @@ async def test_azure_assistants_client_with_existing_assistant() -> None: @pytest.mark.flaky @skip_if_azure_integration_tests_disabled async def test_azure_assistants_agent_basic_run(): - """Test ChatAgent basic run functionality with AzureOpenAIAssistantsClient.""" - async with ChatAgent( - chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), + """Test Agent basic run functionality with AzureOpenAIAssistantsClient.""" + async with Agent( + client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), ) as agent: # Run a simple query response = await agent.run("Hello! Please respond with 'Hello World' exactly.") @@ -414,9 +414,9 @@ async def test_azure_assistants_agent_basic_run(): @pytest.mark.flaky @skip_if_azure_integration_tests_disabled async def test_azure_assistants_agent_basic_run_streaming(): - """Test ChatAgent basic streaming functionality with AzureOpenAIAssistantsClient.""" - async with ChatAgent( - chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), + """Test Agent basic streaming functionality with AzureOpenAIAssistantsClient.""" + async with Agent( + client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), ) as agent: # Run streaming query full_message: str = "" @@ -433,70 +433,70 @@ async def test_azure_assistants_agent_basic_run_streaming(): @pytest.mark.flaky @skip_if_azure_integration_tests_disabled -async def test_azure_assistants_agent_thread_persistence(): - """Test ChatAgent thread persistence across runs with AzureOpenAIAssistantsClient.""" - async with ChatAgent( - chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), +async def test_azure_assistants_agent_session_persistence(): + """Test Agent session persistence across runs with AzureOpenAIAssistantsClient.""" + async with Agent( + client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), instructions="You are a helpful assistant with good memory.", ) as agent: - # Create a new thread that will be reused - thread = agent.get_new_thread() + # Create a new session that will be reused + session = agent.create_session() # First message - establish context first_response = await agent.run( - "Remember this number: 42. What number did I just tell you to remember?", thread=thread + "Remember this number: 42. What number did I just tell you to remember?", session=session ) assert isinstance(first_response, AgentResponse) assert "42" in first_response.text # Second message - test conversation memory second_response = await agent.run( - "What number did I tell you to remember in my previous message?", thread=thread + "What number did I tell you to remember in my previous message?", session=session ) assert isinstance(second_response, AgentResponse) assert "42" in second_response.text - # Verify thread has been populated with conversation ID - assert thread.service_thread_id is not None + # Verify session has been populated with conversation ID + assert session.service_session_id is not None @pytest.mark.flaky @skip_if_azure_integration_tests_disabled -async def test_azure_assistants_agent_existing_thread_id(): - """Test ChatAgent with existing thread ID to continue conversations across agent instances.""" - # First, create a conversation and capture the thread ID - existing_thread_id = None +async def test_azure_assistants_agent_existing_session_id(): + """Test Agent with existing session ID to continue conversations across agent instances.""" + # First, create a conversation and capture the session ID + existing_session_id = None - async with ChatAgent( - chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), + async with Agent( + client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), instructions="You are a helpful weather agent.", tools=[get_weather], ) as agent: - # Start a conversation and get the thread ID - thread = agent.get_new_thread() - response1 = await agent.run("What's the weather in Paris?", thread=thread) + # Start a conversation and get the session ID + session = agent.create_session() + response1 = await agent.run("What's the weather in Paris?", session=session) # Validate first response assert isinstance(response1, AgentResponse) assert response1.text is not None assert any(word in response1.text.lower() for word in ["weather", "paris"]) - # The thread ID is set after the first response - existing_thread_id = thread.service_thread_id - assert existing_thread_id is not None + # The session ID is set after the first response + existing_session_id = session.service_session_id + assert existing_session_id is not None - # Now continue with the same thread ID in a new agent instance + # Now continue with the same session ID in a new agent instance - async with ChatAgent( - chat_client=AzureOpenAIAssistantsClient(thread_id=existing_thread_id, credential=AzureCliCredential()), + async with Agent( + client=AzureOpenAIAssistantsClient(thread_id=existing_session_id, credential=AzureCliCredential()), instructions="You are a helpful weather agent.", tools=[get_weather], ) as agent: - # Create a thread with the existing ID - thread = AgentThread(service_thread_id=existing_thread_id) + # Create a session with the existing ID + session = AgentSession(service_session_id=existing_session_id) # Ask about the previous conversation - response2 = await agent.run("What was the last city I asked about?", thread=thread) + response2 = await agent.run("What was the last city I asked about?", session=session) # Validate that the agent remembers the previous conversation assert isinstance(response2, AgentResponse) @@ -508,12 +508,12 @@ async def test_azure_assistants_agent_existing_thread_id(): @pytest.mark.flaky @skip_if_azure_integration_tests_disabled async def test_azure_assistants_agent_code_interpreter(): - """Test ChatAgent with code interpreter through AzureOpenAIAssistantsClient.""" + """Test Agent with code interpreter through AzureOpenAIAssistantsClient.""" - async with ChatAgent( - chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), + async with Agent( + client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), instructions="You are a helpful assistant that can write and execute Python code.", - tools=[HostedCodeInterpreterTool()], + tools=[AzureOpenAIAssistantsClient.get_code_interpreter_tool()], ) as agent: # Request code execution response = await agent.run("Write Python code to calculate the factorial of 5 and show the result.") @@ -530,8 +530,8 @@ async def test_azure_assistants_agent_code_interpreter(): async def test_azure_assistants_client_agent_level_tool_persistence(): """Test that agent-level tools persist across multiple runs with Azure Assistants Client.""" - async with ChatAgent( - chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), + async with Agent( + client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), instructions="You are a helpful assistant that uses available tools.", tools=[get_weather], # Agent-level tool ) as agent: @@ -557,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", @@ -580,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() @@ -593,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"): @@ -612,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", @@ -646,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", @@ -676,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" @@ -705,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 f434b55fd1..b9a279d478 100644 --- a/python/packages/core/tests/azure/test_azure_chat_client.py +++ b/python/packages/core/tests/azure/test_azure_chat_client.py @@ -17,13 +17,13 @@ from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDe from openai.types.chat.chat_completion_message import ChatCompletionMessage from agent_framework import ( + Agent, AgentResponse, AgentResponseUpdate, - ChatAgent, - ChatClientProtocol, - ChatMessage, ChatResponse, ChatResponseUpdate, + Message, + SupportsChatGetResponse, tool, ) from agent_framework._telemetry import USER_AGENT_KEY @@ -52,7 +52,7 @@ def test_init(azure_openai_unit_test_env: dict[str, str]) -> None: assert azure_chat_client.client is not None assert isinstance(azure_chat_client.client, AsyncAzureOpenAI) assert azure_chat_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] - assert isinstance(azure_chat_client, ChatClientProtocol) + assert isinstance(azure_chat_client, SupportsChatGetResponse) def test_init_client(azure_openai_unit_test_env: dict[str, str]) -> None: @@ -75,7 +75,7 @@ def test_init_base_url(azure_openai_unit_test_env: dict[str, str]) -> None: assert azure_chat_client.client is not None assert isinstance(azure_chat_client.client, AsyncAzureOpenAI) assert azure_chat_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] - assert isinstance(azure_chat_client, ChatClientProtocol) + assert isinstance(azure_chat_client, SupportsChatGetResponse) for key, value in default_headers.items(): assert key in azure_chat_client.client.default_headers assert azure_chat_client.client.default_headers[key] == value @@ -88,7 +88,7 @@ def test_init_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: assert azure_chat_client.client is not None assert isinstance(azure_chat_client.client, AsyncAzureOpenAI) assert azure_chat_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] - assert isinstance(azure_chat_client, ChatClientProtocol) + assert isinstance(azure_chat_client, SupportsChatGetResponse) @pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]], indirect=True) @@ -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) @@ -178,11 +181,11 @@ def mock_streaming_chat_completion_response() -> AsyncStream[ChatCompletionChunk async def test_cmc( mock_create: AsyncMock, azure_openai_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], mock_chat_completion_response: ChatCompletion, ) -> None: mock_create.return_value = mock_chat_completion_response - chat_history.append(ChatMessage(text="hello world", role="user")) + chat_history.append(Message(text="hello world", role="user")) azure_chat_client = AzureOpenAIChatClient() await azure_chat_client.get_response( @@ -199,12 +202,12 @@ async def test_cmc( async def test_cmc_with_logit_bias( mock_create: AsyncMock, azure_openai_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], mock_chat_completion_response: ChatCompletion, ) -> None: mock_create.return_value = mock_chat_completion_response prompt = "hello world" - chat_history.append(ChatMessage(text=prompt, role="user")) + chat_history.append(Message(text=prompt, role="user")) token_bias: dict[str | int, float] = {"1": -100} @@ -224,12 +227,12 @@ async def test_cmc_with_logit_bias( async def test_cmc_with_stop( mock_create: AsyncMock, azure_openai_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], mock_chat_completion_response: ChatCompletion, ) -> None: mock_create.return_value = mock_chat_completion_response prompt = "hello world" - chat_history.append(ChatMessage(text=prompt, role="user")) + chat_history.append(Message(text=prompt, role="user")) stop = ["!"] @@ -249,7 +252,7 @@ async def test_cmc_with_stop( async def test_azure_on_your_data( mock_create: AsyncMock, azure_openai_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], mock_chat_completion_response: ChatCompletion, ) -> None: mock_chat_completion_response.choices = [ @@ -277,9 +280,9 @@ async def test_azure_on_your_data( mock_create.return_value = mock_chat_completion_response prompt = "hello world" messages_in = chat_history - chat_history.append(ChatMessage(text=prompt, role="user")) - messages_out: list[ChatMessage] = [] - messages_out.append(ChatMessage(text=prompt, role="user")) + chat_history.append(Message(text=prompt, role="user")) + messages_out: list[Message] = [] + messages_out.append(Message(text=prompt, role="user")) expected_data_settings = { "data_sources": [ @@ -319,7 +322,7 @@ async def test_azure_on_your_data( async def test_azure_on_your_data_string( mock_create: AsyncMock, azure_openai_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], mock_chat_completion_response: ChatCompletion, ) -> None: mock_chat_completion_response.choices = [ @@ -347,9 +350,9 @@ async def test_azure_on_your_data_string( mock_create.return_value = mock_chat_completion_response prompt = "hello world" messages_in = chat_history - messages_in.append(ChatMessage(text=prompt, role="user")) - messages_out: list[ChatMessage] = [] - messages_out.append(ChatMessage(text=prompt, role="user")) + messages_in.append(Message(text=prompt, role="user")) + messages_out: list[Message] = [] + messages_out.append(Message(text=prompt, role="user")) expected_data_settings = { "data_sources": [ @@ -389,7 +392,7 @@ async def test_azure_on_your_data_string( async def test_azure_on_your_data_fail( mock_create: AsyncMock, azure_openai_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], mock_chat_completion_response: ChatCompletion, ) -> None: mock_chat_completion_response.choices = [ @@ -406,9 +409,9 @@ async def test_azure_on_your_data_fail( mock_create.return_value = mock_chat_completion_response prompt = "hello world" messages_in = chat_history - messages_in.append(ChatMessage(text=prompt, role="user")) - messages_out: list[ChatMessage] = [] - messages_out.append(ChatMessage(text=prompt, role="user")) + messages_in.append(Message(text=prompt, role="user")) + messages_out: list[Message] = [] + messages_out.append(Message(text=prompt, role="user")) expected_data_settings = { "data_sources": [ @@ -459,10 +462,10 @@ CONTENT_FILTERED_ERROR_FULL_MESSAGE = ( async def test_content_filtering_raises_correct_exception( mock_create: AsyncMock, azure_openai_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], ) -> None: prompt = "some prompt that would trigger the content filtering" - chat_history.append(ChatMessage(text=prompt, role="user")) + chat_history.append(Message(text=prompt, role="user")) test_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") assert test_endpoint is not None @@ -504,10 +507,10 @@ async def test_content_filtering_raises_correct_exception( async def test_content_filtering_without_response_code_raises_with_default_code( mock_create: AsyncMock, azure_openai_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], ) -> None: prompt = "some prompt that would trigger the content filtering" - chat_history.append(ChatMessage(text=prompt, role="user")) + chat_history.append(Message(text=prompt, role="user")) test_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") assert test_endpoint is not None @@ -543,10 +546,10 @@ async def test_content_filtering_without_response_code_raises_with_default_code( async def test_bad_request_non_content_filter( mock_create: AsyncMock, azure_openai_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], ) -> None: prompt = "some prompt that would trigger the content filtering" - chat_history.append(ChatMessage(text=prompt, role="user")) + chat_history.append(Message(text=prompt, role="user")) test_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") assert test_endpoint is not None @@ -566,11 +569,11 @@ async def test_bad_request_non_content_filter( async def test_get_streaming( mock_create: AsyncMock, azure_openai_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], mock_streaming_chat_completion_response: AsyncStream[ChatCompletionChunk], ) -> None: mock_create.return_value = mock_streaming_chat_completion_response - chat_history.append(ChatMessage(text="hello world", role="user")) + chat_history.append(Message(text="hello world", role="user")) azure_chat_client = AzureOpenAIChatClient() async for msg in azure_chat_client.get_response( @@ -595,7 +598,7 @@ async def test_get_streaming( async def test_streaming_with_none_delta( mock_create: AsyncMock, azure_openai_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], ) -> None: """Test streaming handles None delta from async content filtering.""" # First chunk has None delta (simulates async filtering) @@ -619,7 +622,7 @@ async def test_streaming_with_none_delta( stream.__aiter__.return_value = [chunk_with_none_delta, chunk_with_content] mock_create.return_value = stream - chat_history.append(ChatMessage(text="hello world", role="user")) + chat_history.append(Message(text="hello world", role="user")) azure_chat_client = AzureOpenAIChatClient() results: list[ChatResponseUpdate] = [] @@ -653,11 +656,11 @@ def get_weather(location: str) -> str: async def test_azure_openai_chat_client_response() -> None: """Test Azure OpenAI chat completion responses.""" azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - assert isinstance(azure_chat_client, ChatClientProtocol) + assert isinstance(azure_chat_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] + messages: list[Message] = [] messages.append( - ChatMessage( + Message( role="user", text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. " "Bonded by their love for the natural world and shared curiosity, they uncovered a " @@ -665,7 +668,7 @@ async def test_azure_openai_chat_client_response() -> None: "of climate change.", ) ) - messages.append(ChatMessage(role="user", text="who are Emily and David?")) + messages.append(Message(role="user", text="who are Emily and David?")) # Test that the client can be used to get a response response = await azure_chat_client.get_response(messages=messages) @@ -683,10 +686,10 @@ async def test_azure_openai_chat_client_response() -> None: async def test_azure_openai_chat_client_response_tools() -> None: """Test AzureOpenAI chat completion responses.""" azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - assert isinstance(azure_chat_client, ChatClientProtocol) + assert isinstance(azure_chat_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] - messages.append(ChatMessage(role="user", text="who are Emily and David?")) + messages: list[Message] = [] + messages.append(Message(role="user", text="who are Emily and David?")) # Test that the client can be used to get a response response = await azure_chat_client.get_response( @@ -704,11 +707,11 @@ async def test_azure_openai_chat_client_response_tools() -> None: async def test_azure_openai_chat_client_streaming() -> None: """Test Azure OpenAI chat completion responses.""" azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - assert isinstance(azure_chat_client, ChatClientProtocol) + assert isinstance(azure_chat_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] + messages: list[Message] = [] messages.append( - ChatMessage( + Message( role="user", text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. " "Bonded by their love for the natural world and shared curiosity, they uncovered a " @@ -716,7 +719,7 @@ async def test_azure_openai_chat_client_streaming() -> None: "of climate change.", ) ) - messages.append(ChatMessage(role="user", text="who are Emily and David?")) + messages.append(Message(role="user", text="who are Emily and David?")) # Test that the client can be used to get a response response = azure_chat_client.get_response(messages=messages, stream=True) @@ -739,10 +742,10 @@ async def test_azure_openai_chat_client_streaming() -> None: async def test_azure_openai_chat_client_streaming_tools() -> None: """Test AzureOpenAI chat completion responses.""" azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - assert isinstance(azure_chat_client, ChatClientProtocol) + assert isinstance(azure_chat_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] - messages.append(ChatMessage(role="user", text="who are Emily and David?")) + messages: list[Message] = [] + messages.append(Message(role="user", text="who are Emily and David?")) # Test that the client can be used to get a response response = azure_chat_client.get_response( @@ -765,8 +768,8 @@ async def test_azure_openai_chat_client_streaming_tools() -> None: @skip_if_azure_integration_tests_disabled async def test_azure_openai_chat_client_agent_basic_run(): """Test Azure OpenAI chat client agent basic run functionality with AzureOpenAIChatClient.""" - async with ChatAgent( - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), + async with Agent( + client=AzureOpenAIChatClient(credential=AzureCliCredential()), ) as agent: # Test basic run response = await agent.run("Please respond with exactly: 'This is a response test.'") @@ -781,8 +784,8 @@ async def test_azure_openai_chat_client_agent_basic_run(): @skip_if_azure_integration_tests_disabled async def test_azure_openai_chat_client_agent_basic_run_streaming(): """Test Azure OpenAI chat client agent basic streaming functionality with AzureOpenAIChatClient.""" - async with ChatAgent( - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), + async with Agent( + client=AzureOpenAIChatClient(credential=AzureCliCredential()), ) as agent: # Test streaming run full_text = "" @@ -797,23 +800,23 @@ async def test_azure_openai_chat_client_agent_basic_run_streaming(): @pytest.mark.flaky @skip_if_azure_integration_tests_disabled -async def test_azure_openai_chat_client_agent_thread_persistence(): - """Test Azure OpenAI chat client agent thread persistence across runs with AzureOpenAIChatClient.""" - async with ChatAgent( - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), +async def test_azure_openai_chat_client_agent_session_persistence(): + """Test Azure OpenAI chat client agent session persistence across runs with AzureOpenAIChatClient.""" + async with Agent( + client=AzureOpenAIChatClient(credential=AzureCliCredential()), instructions="You are a helpful assistant with good memory.", ) as agent: - # Create a new thread that will be reused - thread = agent.get_new_thread() + # Create a new session that will be reused + session = agent.create_session() # First interaction - response1 = await agent.run("My name is Alice. Remember this.", thread=thread) + response1 = await agent.run("My name is Alice. Remember this.", session=session) assert isinstance(response1, AgentResponse) assert response1.text is not None # Second interaction - test memory - response2 = await agent.run("What is my name?", thread=thread) + response2 = await agent.run("What is my name?", session=session) assert isinstance(response2, AgentResponse) assert response2.text is not None @@ -822,33 +825,33 @@ async def test_azure_openai_chat_client_agent_thread_persistence(): @pytest.mark.flaky @skip_if_azure_integration_tests_disabled -async def test_azure_openai_chat_client_agent_existing_thread(): - """Test Azure OpenAI chat client agent with existing thread to continue conversations across agent instances.""" - # First conversation - capture the thread - preserved_thread = None +async def test_azure_openai_chat_client_agent_existing_session(): + """Test Azure OpenAI chat client agent with existing session to continue conversations across agent instances.""" + # First conversation - capture the session + preserved_session = None - async with ChatAgent( - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), + async with Agent( + client=AzureOpenAIChatClient(credential=AzureCliCredential()), instructions="You are a helpful assistant with good memory.", ) as first_agent: - # Start a conversation and capture the thread - thread = first_agent.get_new_thread() - first_response = await first_agent.run("My name is Alice. Remember this.", thread=thread) + # Start a conversation and capture the session + session = first_agent.create_session() + first_response = await first_agent.run("My name is Alice. Remember this.", session=session) assert isinstance(first_response, AgentResponse) assert first_response.text is not None - # Preserve the thread for reuse - preserved_thread = thread + # Preserve the session for reuse + preserved_session = session - # Second conversation - reuse the thread in a new agent instance - if preserved_thread: - async with ChatAgent( - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), + # Second conversation - reuse the session in a new agent instance + if preserved_session: + async with Agent( + client=AzureOpenAIChatClient(credential=AzureCliCredential()), instructions="You are a helpful assistant with good memory.", ) as second_agent: - # Reuse the preserved thread - second_response = await second_agent.run("What is my name?", thread=preserved_thread) + # Reuse the preserved session + second_response = await second_agent.run("What is my name?", session=preserved_session) assert isinstance(second_response, AgentResponse) assert second_response.text is not None @@ -860,8 +863,8 @@ async def test_azure_openai_chat_client_agent_existing_thread(): async def test_azure_chat_client_agent_level_tool_persistence(): """Test that agent-level tools persist across multiple runs with Azure Chat Client.""" - async with ChatAgent( - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), + async with Agent( + client=AzureOpenAIChatClient(credential=AzureCliCredential()), instructions="You are a helpful assistant that uses available tools.", tools=[get_weather], # Agent-level tool ) as agent: diff --git a/python/packages/core/tests/azure/test_azure_responses_client.py b/python/packages/core/tests/azure/test_azure_responses_client.py index e8e9e9e089..ef8a7df479 100644 --- a/python/packages/core/tests/azure/test_azure_responses_client.py +++ b/python/packages/core/tests/azure/test_azure_responses_client.py @@ -3,6 +3,7 @@ import json import os from typing import Annotated, Any +from unittest.mock import MagicMock import pytest from azure.identity import AzureCliCredential @@ -10,16 +11,12 @@ from pydantic import BaseModel from pytest import param from agent_framework import ( + Agent, AgentResponse, - ChatAgent, - ChatClientProtocol, - ChatMessage, ChatResponse, Content, - HostedCodeInterpreterTool, - HostedFileSearchTool, - HostedMCPTool, - HostedWebSearchTool, + Message, + SupportsChatGetResponse, tool, ) from agent_framework.azure import AzureOpenAIResponsesClient @@ -76,7 +73,7 @@ def test_init(azure_openai_unit_test_env: dict[str, str]) -> None: azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) assert azure_responses_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] - assert isinstance(azure_responses_client, ChatClientProtocol) + assert isinstance(azure_responses_client, SupportsChatGetResponse) def test_init_validation_fail() -> None: @@ -91,7 +88,7 @@ def test_init_model_id_constructor(azure_openai_unit_test_env: dict[str, str]) - azure_responses_client = AzureOpenAIResponsesClient(deployment_name=model_id) assert azure_responses_client.model_id == model_id - assert isinstance(azure_responses_client, ChatClientProtocol) + assert isinstance(azure_responses_client, SupportsChatGetResponse) def test_init_with_default_header(azure_openai_unit_test_env: dict[str, str]) -> None: @@ -103,7 +100,7 @@ def test_init_with_default_header(azure_openai_unit_test_env: dict[str, str]) -> ) assert azure_responses_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] - assert isinstance(azure_responses_client, ChatClientProtocol) + assert isinstance(azure_responses_client, SupportsChatGetResponse) # Assert that the default header we added is present in the client's default headers for key, value in default_headers.items(): @@ -119,6 +116,119 @@ def test_init_with_empty_model_id(azure_openai_unit_test_env: dict[str, str]) -> ) +def test_init_with_project_client(azure_openai_unit_test_env: dict[str, str]) -> None: + """Test initialization with an existing AIProjectClient.""" + from unittest.mock import patch + + from openai import AsyncOpenAI + + # Create a mock AIProjectClient that returns a mock AsyncOpenAI client + mock_openai_client = MagicMock(spec=AsyncOpenAI) + mock_openai_client.default_headers = {} + + mock_project_client = MagicMock() + mock_project_client.get_openai_client.return_value = mock_openai_client + + with patch( + "agent_framework.azure._responses_client.AzureOpenAIResponsesClient._create_client_from_project", + return_value=mock_openai_client, + ): + azure_responses_client = AzureOpenAIResponsesClient( + project_client=mock_project_client, + deployment_name="gpt-4o", + ) + + assert azure_responses_client.model_id == "gpt-4o" + assert azure_responses_client.client is mock_openai_client + assert isinstance(azure_responses_client, SupportsChatGetResponse) + + +def test_init_with_project_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: + """Test initialization with a project endpoint and credential.""" + from unittest.mock import patch + + from openai import AsyncOpenAI + + mock_openai_client = MagicMock(spec=AsyncOpenAI) + mock_openai_client.default_headers = {} + + with patch( + "agent_framework.azure._responses_client.AzureOpenAIResponsesClient._create_client_from_project", + return_value=mock_openai_client, + ): + azure_responses_client = AzureOpenAIResponsesClient( + project_endpoint="https://test-project.services.ai.azure.com", + deployment_name="gpt-4o", + credential=AzureCliCredential(), + ) + + assert azure_responses_client.model_id == "gpt-4o" + assert azure_responses_client.client is mock_openai_client + assert isinstance(azure_responses_client, SupportsChatGetResponse) + + +def test_create_client_from_project_with_project_client() -> None: + """Test _create_client_from_project with an existing project client.""" + from openai import AsyncOpenAI + + mock_openai_client = MagicMock(spec=AsyncOpenAI) + mock_project_client = MagicMock() + mock_project_client.get_openai_client.return_value = mock_openai_client + + result = AzureOpenAIResponsesClient._create_client_from_project( + project_client=mock_project_client, + project_endpoint=None, + credential=None, + ) + + assert result is mock_openai_client + mock_project_client.get_openai_client.assert_called_once() + + +def test_create_client_from_project_with_endpoint() -> None: + """Test _create_client_from_project with a project endpoint.""" + from unittest.mock import patch + + from openai import AsyncOpenAI + + mock_openai_client = MagicMock(spec=AsyncOpenAI) + mock_credential = MagicMock() + + with patch("agent_framework.azure._responses_client.AIProjectClient") as MockAIProjectClient: + mock_instance = MockAIProjectClient.return_value + mock_instance.get_openai_client.return_value = mock_openai_client + + result = AzureOpenAIResponsesClient._create_client_from_project( + project_client=None, + project_endpoint="https://test-project.services.ai.azure.com", + credential=mock_credential, + ) + + assert result is mock_openai_client + MockAIProjectClient.assert_called_once() + mock_instance.get_openai_client.assert_called_once() + + +def test_create_client_from_project_missing_endpoint() -> None: + """Test _create_client_from_project raises error when endpoint is missing.""" + with pytest.raises(ServiceInitializationError, match="project endpoint is required"): + AzureOpenAIResponsesClient._create_client_from_project( + project_client=None, + project_endpoint=None, + credential=MagicMock(), + ) + + +def test_create_client_from_project_missing_credential() -> None: + """Test _create_client_from_project raises error when credential is missing.""" + with pytest.raises(ServiceInitializationError, match="credential is required"): + AzureOpenAIResponsesClient._create_client_from_project( + project_client=None, + project_endpoint="https://test-project.services.ai.azure.com", + credential=None, + ) + + def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: default_headers = {"X-Unit-Test": "test-guid"} @@ -221,14 +331,14 @@ async def test_integration_options( # Prepare test message if option_name == "tools" or option_name == "tool_choice": # Use weather-related prompt for tool tests - messages = [ChatMessage(role="user", text="What is the weather in Seattle?")] + messages = [Message(role="user", text="What is the weather in Seattle?")] elif option_name == "response_format": # Use prompt that works well with structured output - messages = [ChatMessage(role="user", text="The weather in Seattle is sunny")] - messages.append(ChatMessage(role="user", text="What is the weather in Seattle?")) + messages = [Message(role="user", text="The weather in Seattle is sunny")] + messages.append(Message(role="user", text="What is the weather in Seattle?")) else: # Generic prompt for simple options - messages = [ChatMessage(role="user", text="Say 'Hello World' briefly.")] + messages = [Message(role="user", text="Say 'Hello World' briefly.")] # Build options dict options: dict[str, Any] = {option_name: option_value} @@ -289,7 +399,7 @@ async def test_integration_web_search() -> None: "messages": "Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", "options": { "tool_choice": "auto", - "tools": [HostedWebSearchTool()], + "tools": [AzureOpenAIResponsesClient.get_web_search_tool()], }, "stream": streaming, } @@ -305,17 +415,13 @@ async def test_integration_web_search() -> None: assert "Zoey" in response.text # Test that the client will use the web search tool with location - additional_properties = { - "user_location": { - "country": "US", - "city": "Seattle", - } - } content = { "messages": "What is the current weather? Do not ask for my current location.", "options": { "tool_choice": "auto", - "tools": [HostedWebSearchTool(additional_properties=additional_properties)], + "tools": [ + AzureOpenAIResponsesClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"}) + ], }, "stream": streaming, } @@ -336,12 +442,17 @@ async def test_integration_client_file_search() -> None: # Test that the client will use the file search tool response = await azure_responses_client.get_response( messages=[ - ChatMessage( + Message( role="user", text="What is the weather today? Do a file search to find the answer.", ) ], - options={"tools": [HostedFileSearchTool(inputs=vector_store)], "tool_choice": "auto"}, + options={ + "tools": [ + AzureOpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id]) + ], + "tool_choice": "auto", + }, ) assert "sunny" in response.text.lower() @@ -360,13 +471,18 @@ async def test_integration_client_file_search_streaming() -> None: try: response_stream = azure_responses_client.get_response( messages=[ - ChatMessage( + Message( role="user", text="What is the weather today? Do a file search to find the answer.", ) ], stream=True, - options={"tools": [HostedFileSearchTool(inputs=vector_store)], "tool_choice": "auto"}, + options={ + "tools": [ + AzureOpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id]) + ], + "tool_choice": "auto", + }, ) full_response = await response_stream.get_final_response() @@ -379,23 +495,23 @@ async def test_integration_client_file_search_streaming() -> None: @pytest.mark.flaky @skip_if_azure_integration_tests_disabled async def test_integration_client_agent_hosted_mcp_tool() -> None: - """Integration test for HostedMCPTool with Azure Response Agent using Microsoft Learn MCP.""" + """Integration test for MCP tool with Azure Response Agent using Microsoft Learn MCP.""" client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) response = await client.get_response( "How to create an Azure storage account using az cli?", options={ # this needs to be high enough to handle the full MCP tool response. "max_tokens": 5000, - "tools": HostedMCPTool( + "tools": AzureOpenAIResponsesClient.get_mcp_tool( name="Microsoft Learn MCP", url="https://learn.microsoft.com/api/mcp", - description="A Microsoft Learn MCP server for documentation questions", - approval_mode="never_require", ), }, ) assert isinstance(response, ChatResponse) - assert response.text + # MCP server may return empty response intermittently - skip test rather than fail + if not response.text: + pytest.skip("MCP server returned empty response - service-side issue") # Should contain Azure-related content since it's asking about Azure CLI assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"]) @@ -403,13 +519,13 @@ async def test_integration_client_agent_hosted_mcp_tool() -> None: @pytest.mark.flaky @skip_if_azure_integration_tests_disabled async def test_integration_client_agent_hosted_code_interpreter_tool(): - """Test Azure Responses Client agent with HostedCodeInterpreterTool through AzureOpenAIResponsesClient.""" + """Test Azure Responses Client agent with code interpreter tool.""" client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) response = await client.get_response( "Calculate the sum of numbers from 1 to 10 using Python code.", options={ - "tools": [HostedCodeInterpreterTool()], + "tools": [AzureOpenAIResponsesClient.get_code_interpreter_tool()], }, ) # Should contain calculation result (sum of 1-10 = 55) or code execution content @@ -421,33 +537,33 @@ async def test_integration_client_agent_hosted_code_interpreter_tool(): @pytest.mark.flaky @skip_if_azure_integration_tests_disabled -async def test_integration_client_agent_existing_thread(): - """Test Azure Responses Client agent with existing thread to continue conversations across agent instances.""" - # First conversation - capture the thread - preserved_thread = None +async def test_integration_client_agent_existing_session(): + """Test Azure Responses Client agent with existing session to continue conversations across agent instances.""" + # First conversation - capture the session + preserved_session = None - async with ChatAgent( - chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + async with Agent( + client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), instructions="You are a helpful assistant with good memory.", ) as first_agent: - # Start a conversation and capture the thread - thread = first_agent.get_new_thread() - first_response = await first_agent.run("My hobby is photography. Remember this.", thread=thread, store=True) + # Start a conversation and capture the session + session = first_agent.create_session() + first_response = await first_agent.run("My hobby is photography. Remember this.", session=session, store=True) assert isinstance(first_response, AgentResponse) assert first_response.text is not None - # Preserve the thread for reuse - preserved_thread = thread + # Preserve the session for reuse + preserved_session = session - # Second conversation - reuse the thread in a new agent instance - if preserved_thread: - async with ChatAgent( - chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + # Second conversation - reuse the session in a new agent instance + if preserved_session: + async with Agent( + client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), instructions="You are a helpful assistant with good memory.", ) as second_agent: - # Reuse the preserved thread - second_response = await second_agent.run("What is my hobby?", thread=preserved_thread) + # Reuse the preserved session + second_response = await second_agent.run("What is my hobby?", session=preserved_session) assert isinstance(second_response, AgentResponse) assert second_response.text is not None diff --git a/python/packages/core/tests/core/conftest.py b/python/packages/core/tests/core/conftest.py index 7f987ca226..2d1eec2d9a 100644 --- a/python/packages/core/tests/core/conftest.py +++ b/python/packages/core/tests/core/conftest.py @@ -8,26 +8,25 @@ from typing import Any, Generic from unittest.mock import patch from uuid import uuid4 -from pydantic import BaseModel from pytest import fixture from agent_framework import ( AgentResponse, AgentResponseUpdate, - AgentThread, + AgentSession, BaseChatClient, - ChatMessage, ChatMiddlewareLayer, ChatResponse, ChatResponseUpdate, Content, FunctionInvocationLayer, + FunctionTool, + Message, ResponseStream, SupportsAgentRun, - ToolProtocol, tool, ) -from agent_framework._clients import TOptions_co +from agent_framework._clients import OptionsCoT from agent_framework.observability import ChatTelemetryLayer if sys.version_info >= (3, 12): @@ -40,7 +39,7 @@ logger = logging.getLogger(__name__) @fixture(scope="function") -def chat_history() -> list[ChatMessage]: +def chat_history() -> list[Message]: return [] @@ -48,26 +47,20 @@ def chat_history() -> list[ChatMessage]: @fixture -def ai_tool() -> ToolProtocol: - """Returns a generic ToolProtocol.""" +def ai_tool() -> FunctionTool: + """Returns a generic FunctionTool.""" - class GenericTool(BaseModel): - name: str - description: str - additional_properties: dict[str, Any] | None = None + @tool + def generic_tool(name: str) -> str: + """A generic tool that echoes the name.""" + return f"Hello, {name}" - def parameters(self) -> dict[str, Any]: - """Return the parameters of the tool as a JSON schema.""" - return { - "name": {"type": "string"}, - } - - return GenericTool(name="generic_tool", description="A generic tool") + return generic_tool @fixture -def tool_tool() -> ToolProtocol: - """Returns a executable ToolProtocol.""" +def tool_tool() -> FunctionTool: + """Returns a executable FunctionTool.""" @tool(approval_mode="never_require") def simple_function(x: int, y: int) -> int: @@ -90,7 +83,7 @@ class MockChatClient: def get_response( self, - messages: str | ChatMessage | list[str] | list[ChatMessage], + messages: str | Message | list[str] | list[Message], *, stream: bool = False, options: dict[str, Any] | None = None, @@ -105,14 +98,14 @@ class MockChatClient: self.call_count += 1 if self.responses: return self.responses.pop(0) - return ChatResponse(messages=ChatMessage(role="assistant", text="test response")) + return ChatResponse(messages=Message(role="assistant", text="test response")) return _get() def _get_streaming_response( self, *, - messages: str | ChatMessage | list[str] | list[ChatMessage], + messages: str | Message | list[str] | list[Message], options: dict[str, Any], **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse]: @@ -135,11 +128,11 @@ class MockChatClient: class MockBaseChatClient( - ChatMiddlewareLayer[TOptions_co], - FunctionInvocationLayer[TOptions_co], - ChatTelemetryLayer[TOptions_co], - BaseChatClient[TOptions_co], - Generic[TOptions_co], + ChatMiddlewareLayer[OptionsCoT], + FunctionInvocationLayer[OptionsCoT], + ChatTelemetryLayer[OptionsCoT], + BaseChatClient[OptionsCoT], + Generic[OptionsCoT], ): """Mock implementation of a full-featured ChatClient.""" @@ -153,7 +146,7 @@ class MockBaseChatClient( def _inner_get_response( self, *, - messages: MutableSequence[ChatMessage], + messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any, @@ -180,7 +173,7 @@ class MockBaseChatClient( async def _get_non_streaming_response( self, *, - messages: MutableSequence[ChatMessage], + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any, ) -> ChatResponse: @@ -188,13 +181,13 @@ class MockBaseChatClient( logger.debug(f"Running base chat client inner, with: {messages=}, {options=}, {kwargs=}") self.call_count += 1 if not self.run_responses: - return ChatResponse(messages=ChatMessage(role="assistant", text=f"test response - {messages[-1].text}")) + return ChatResponse(messages=Message(role="assistant", text=f"test response - {messages[-1].text}")) response = self.run_responses.pop(0) if options.get("tool_choice") == "none": return ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", text="I broke out of the function invocation loop...", ), @@ -206,7 +199,7 @@ class MockBaseChatClient( def _get_streaming_response( self, *, - messages: MutableSequence[ChatMessage], + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse]: @@ -251,7 +244,7 @@ def max_iterations(request: Any) -> int: @fixture -def chat_client(enable_function_calling: bool, max_iterations: int) -> MockChatClient: +def client(enable_function_calling: bool, max_iterations: int) -> MockChatClient: if enable_function_calling: with patch("agent_framework._tools.DEFAULT_MAX_ITERATIONS", max_iterations): return type("FunctionInvokingMockChatClient", (FunctionInvocationLayer, MockChatClient), {})() @@ -261,14 +254,14 @@ def chat_client(enable_function_calling: bool, max_iterations: int) -> MockChatC @fixture def chat_client_base(enable_function_calling: bool, max_iterations: int) -> MockBaseChatClient: with patch("agent_framework._tools.DEFAULT_MAX_ITERATIONS", max_iterations): - chat_client = MockBaseChatClient() + client = MockBaseChatClient() if not enable_function_calling: - chat_client.function_invocation_configuration["enabled"] = False - return chat_client + client.function_invocation_configuration["enabled"] = False + return client # region Agents -class MockAgentThread(AgentThread): +class MockAgentSession(AgentSession): pass @@ -289,43 +282,43 @@ class MockAgent(SupportsAgentRun): def run( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, - thread: AgentThread | None = None, + session: AgentSession | None = None, stream: bool = False, **kwargs: Any, ) -> Awaitable[AgentResponse] | AsyncIterable[AgentResponseUpdate]: if stream: - return self._run_stream_impl(messages=messages, thread=thread, **kwargs) - return self._run_impl(messages=messages, thread=thread, **kwargs) + return self._run_stream_impl(messages=messages, session=session, **kwargs) + return self._run_impl(messages=messages, session=session, **kwargs) async def _run_impl( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> AgentResponse: - logger.debug(f"Running mock agent, with: {messages=}, {thread=}, {kwargs=}") - return AgentResponse(messages=[ChatMessage(role="assistant", contents=[Content.from_text("Response")])]) + logger.debug(f"Running mock agent, with: {messages=}, {session=}, {kwargs=}") + return AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Response")])]) async def _run_stream_impl( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseUpdate]: - logger.debug(f"Running mock agent stream, with: {messages=}, {thread=}, {kwargs=}") + logger.debug(f"Running mock agent stream, with: {messages=}, {session=}, {kwargs=}") yield AgentResponseUpdate(contents=[Content.from_text("Response")]) - def get_new_thread(self) -> AgentThread: - return MockAgentThread() + def create_session(self) -> AgentSession: + return MockAgentSession() @fixture -def agent_thread() -> AgentThread: - return MockAgentThread() +def agent_session() -> AgentSession: + return MockAgentSession() @fixture diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index cbd8ea0469..6d776a8b43 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import contextlib -from collections.abc import AsyncIterable, MutableSequence, Sequence +from collections.abc import AsyncIterable, MutableSequence from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 @@ -10,30 +10,27 @@ import pytest from pytest import raises from agent_framework import ( + Agent, AgentResponse, AgentResponseUpdate, - AgentThread, - ChatAgent, - ChatClientProtocol, - ChatMessage, - ChatMessageStore, + AgentSession, + BaseContextProvider, ChatOptions, ChatResponse, + ChatResponseUpdate, Content, - Context, - ContextProvider, - HostedCodeInterpreterTool, + FunctionTool, + Message, SupportsAgentRun, - ToolProtocol, + SupportsChatGetResponse, tool, ) from agent_framework._agents import _merge_options, _sanitize_agent_name from agent_framework._mcp import MCPTool -from agent_framework.exceptions import AgentExecutionException, AgentInitializationError -def test_agent_thread_type(agent_thread: AgentThread) -> None: - assert isinstance(agent_thread, AgentThread) +def test_agent_session_type(agent_session: AgentSession) -> None: + assert isinstance(agent_session, AgentSession) def test_agent_type(agent: SupportsAgentRun) -> None: @@ -55,116 +52,224 @@ async def test_agent_run_streaming(agent: SupportsAgentRun) -> None: assert updates[0].text == "Response" -def test_chat_client_agent_type(chat_client: ChatClientProtocol) -> None: - chat_client_agent = ChatAgent(chat_client=chat_client) +def test_chat_client_agent_type(client: SupportsChatGetResponse) -> None: + chat_client_agent = Agent(client=client) assert isinstance(chat_client_agent, SupportsAgentRun) -async def test_chat_client_agent_init(chat_client: ChatClientProtocol) -> None: +async def test_chat_client_agent_init(client: SupportsChatGetResponse) -> None: agent_id = str(uuid4()) - agent = ChatAgent(chat_client=chat_client, id=agent_id, description="Test") + agent = Agent(client=client, id=agent_id, description="Test") assert agent.id == agent_id assert agent.name is None assert agent.description == "Test" -async def test_chat_client_agent_init_with_name(chat_client: ChatClientProtocol) -> None: +async def test_chat_client_agent_init_with_name(client: SupportsChatGetResponse) -> None: agent_id = str(uuid4()) - agent = ChatAgent(chat_client=chat_client, id=agent_id, name="Test Agent", description="Test") + agent = Agent(client=client, id=agent_id, name="Test Agent", description="Test") assert agent.id == agent_id assert agent.name == "Test Agent" assert agent.description == "Test" -async def test_chat_client_agent_run(chat_client: ChatClientProtocol) -> None: - agent = ChatAgent(chat_client=chat_client) +async def test_chat_client_agent_run(client: SupportsChatGetResponse) -> None: + agent = Agent(client=client) result = await agent.run("Hello") assert result.text == "test response" -async def test_chat_client_agent_run_streaming(chat_client: ChatClientProtocol) -> None: - agent = ChatAgent(chat_client=chat_client) +async def test_chat_client_agent_run_streaming(client: SupportsChatGetResponse) -> None: + agent = Agent(client=client) result = await AgentResponse.from_update_generator(agent.run("Hello", stream=True)) assert result.text == "test streaming response another update" -async def test_chat_client_agent_get_new_thread(chat_client: ChatClientProtocol) -> None: - agent = ChatAgent(chat_client=chat_client) - thread = agent.get_new_thread() +async def test_chat_client_agent_create_session(client: SupportsChatGetResponse) -> None: + agent = Agent(client=client) + session = agent.create_session() - assert isinstance(thread, AgentThread) + assert isinstance(session, AgentSession) -async def test_chat_client_agent_prepare_thread_and_messages(chat_client: ChatClientProtocol) -> None: - agent = ChatAgent(chat_client=chat_client) - message = ChatMessage(role="user", text="Hello") - thread = AgentThread(message_store=ChatMessageStore(messages=[message])) +async def test_chat_client_agent_prepare_session_and_messages(client: SupportsChatGetResponse) -> None: + from agent_framework._sessions import InMemoryHistoryProvider - _, _, result_messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage] - thread=thread, - input_messages=[ChatMessage(role="user", text="Test")], + agent = Agent(client=client, context_providers=[InMemoryHistoryProvider("memory")]) + message = Message(role="user", text="Hello") + session = AgentSession() + session.state["memory"] = {"messages": [message]} + + session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage] + session=session, + input_messages=[Message(role="user", text="Test")], ) + result_messages = session_context.get_messages(include_input=True) assert len(result_messages) == 2 - assert result_messages[0] == message + assert result_messages[0].text == "Hello" assert result_messages[1].text == "Test" -async def test_prepare_thread_does_not_mutate_agent_chat_options(chat_client: ChatClientProtocol) -> None: - tool = HostedCodeInterpreterTool() - agent = ChatAgent(chat_client=chat_client, tools=[tool]) +async def test_prepare_session_does_not_mutate_agent_chat_options(client: SupportsChatGetResponse) -> None: + tool = {"type": "code_interpreter"} + agent = Agent(client=client, tools=[tool]) assert agent.default_options.get("tools") is not None base_tools = agent.default_options["tools"] - thread = agent.get_new_thread() + session = agent.create_session() - _, prepared_chat_options, _ = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage] - thread=thread, - input_messages=[ChatMessage(role="user", text="Test")], + _, prepared_chat_options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage] + session=session, + input_messages=[Message(role="user", text="Test")], ) assert prepared_chat_options.get("tools") is not None assert base_tools is not prepared_chat_options["tools"] - prepared_chat_options["tools"].append(HostedCodeInterpreterTool()) # type: ignore[arg-type] + prepared_chat_options["tools"].append({"type": "code_interpreter"}) # type: ignore[arg-type] assert len(agent.default_options["tools"]) == 1 -async def test_chat_client_agent_update_thread_id(chat_client_base: ChatClientProtocol) -> None: +async def test_chat_client_agent_run_with_session(chat_client_base: SupportsChatGetResponse) -> None: mock_response = ChatResponse( - messages=[ChatMessage(role="assistant", contents=[Content.from_text("test response")])], + messages=[Message(role="assistant", contents=[Content.from_text("test response")])], conversation_id="123", ) chat_client_base.run_responses = [mock_response] - agent = ChatAgent( - chat_client=chat_client_base, - tools=HostedCodeInterpreterTool(), + agent = Agent( + client=chat_client_base, + tools={"type": "code_interpreter"}, ) - thread = agent.get_new_thread() + session = agent.get_session(service_session_id="123") - result = await agent.run("Hello", thread=thread) + result = await agent.run("Hello", session=session) assert result.text == "test response" - assert thread.service_thread_id == "123" + assert session.service_session_id == "123" -async def test_chat_client_agent_update_thread_messages(chat_client: ChatClientProtocol) -> None: - agent = ChatAgent(chat_client=chat_client) - thread = agent.get_new_thread() +async def test_chat_client_agent_updates_existing_session_id_non_streaming( + chat_client_base: SupportsChatGetResponse, +) -> None: + chat_client_base.run_responses = [ + ChatResponse( + messages=[Message(role="assistant", contents=[Content.from_text("test response")])], + conversation_id="resp_new_123", + ) + ] - result = await agent.run("Hello", thread=thread) + agent = Agent(client=chat_client_base) + session = agent.get_session(service_session_id="resp_old_123") + + await agent.run("Hello", session=session) + assert session.service_session_id == "resp_new_123" + + +async def test_chat_client_agent_update_session_id_streaming_uses_conversation_id( + chat_client_base: SupportsChatGetResponse, +) -> None: + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[Content.from_text("stream part 1")], + role="assistant", + response_id="resp_stream_123", + conversation_id="conv_stream_456", + ), + ChatResponseUpdate( + contents=[Content.from_text(" stream part 2")], + role="assistant", + response_id="resp_stream_123", + conversation_id="conv_stream_456", + finish_reason="stop", + ), + ] + ] + + agent = Agent(client=chat_client_base) + session = agent.create_session() + + stream = agent.run("Hello", session=session, stream=True) + async for _ in stream: + pass + result = await stream.get_final_response() + assert result.text == "stream part 1 stream part 2" + assert session.service_session_id == "conv_stream_456" + + +async def test_chat_client_agent_updates_existing_session_id_streaming( + chat_client_base: SupportsChatGetResponse, +) -> None: + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[Content.from_text("stream part 1")], + role="assistant", + response_id="resp_stream_123", + conversation_id="resp_new_456", + ), + ChatResponseUpdate( + contents=[Content.from_text(" stream part 2")], + role="assistant", + response_id="resp_stream_123", + conversation_id="resp_new_456", + finish_reason="stop", + ), + ] + ] + + agent = Agent(client=chat_client_base) + session = agent.get_session(service_session_id="resp_old_456") + + stream = agent.run("Hello", session=session, stream=True) + async for _ in stream: + pass + await stream.get_final_response() + assert session.service_session_id == "resp_new_456" + + +async def test_chat_client_agent_update_session_id_streaming_does_not_use_response_id( + chat_client_base: SupportsChatGetResponse, +) -> None: + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[Content.from_text("stream response without conversation id")], + role="assistant", + response_id="resp_only_123", + finish_reason="stop", + ), + ] + ] + + agent = Agent(client=chat_client_base) + session = agent.create_session() + + stream = agent.run("Hello", session=session, stream=True) + async for _ in stream: + pass + result = await stream.get_final_response() + assert result.text == "stream response without conversation id" + assert session.service_session_id is None + + +async def test_chat_client_agent_update_session_messages(client: SupportsChatGetResponse) -> None: + agent = Agent(client=client) + session = agent.create_session() + + result = await agent.run("Hello", session=session) assert result.text == "test response" - assert thread.service_thread_id is None - assert thread.message_store is not None + assert session.service_session_id is None - chat_messages: list[ChatMessage] = await thread.message_store.list_messages() + chat_messages: list[Message] = session.state.get("memory", {}).get("messages", []) assert chat_messages is not None assert len(chat_messages) == 2 @@ -172,42 +277,42 @@ async def test_chat_client_agent_update_thread_messages(chat_client: ChatClientP assert chat_messages[1].text == "test response" -async def test_chat_client_agent_update_thread_conversation_id_missing(chat_client: ChatClientProtocol) -> None: - agent = ChatAgent(chat_client=chat_client) - thread = AgentThread(service_thread_id="123") +async def test_chat_client_agent_update_session_conversation_id_missing(client: SupportsChatGetResponse) -> None: + agent = Agent(client=client) + session = agent.get_session(service_session_id="123") - with raises(AgentExecutionException, match="Service did not return a valid conversation id"): - await agent._update_thread_with_type_and_conversation_id(thread, None) # type: ignore[reportPrivateUsage] + # With the session-based API, service_session_id is managed directly on the session + assert session.service_session_id == "123" -async def test_chat_client_agent_default_author_name(chat_client: ChatClientProtocol) -> None: +async def test_chat_client_agent_default_author_name(client: SupportsChatGetResponse) -> None: # Name is not specified here, so default name should be used - agent = ChatAgent(chat_client=chat_client) + agent = Agent(client=client) result = await agent.run("Hello") assert result.text == "test response" assert result.messages[0].author_name == "UnnamedAgent" -async def test_chat_client_agent_author_name_as_agent_name(chat_client: ChatClientProtocol) -> None: +async def test_chat_client_agent_author_name_as_agent_name(client: SupportsChatGetResponse) -> None: # Name is specified here, so it should be used as author name - agent = ChatAgent(chat_client=chat_client, name="TestAgent") + agent = Agent(client=client, name="TestAgent") result = await agent.run("Hello") assert result.text == "test response" assert result.messages[0].author_name == "TestAgent" -async def test_chat_client_agent_author_name_is_used_from_response(chat_client_base: ChatClientProtocol) -> None: +async def test_chat_client_agent_author_name_is_used_from_response(chat_client_base: SupportsChatGetResponse) -> None: chat_client_base.run_responses = [ ChatResponse( messages=[ - ChatMessage(role="assistant", contents=[Content.from_text("test response")], author_name="TestAuthor") + Message(role="assistant", contents=[Content.from_text("test response")], author_name="TestAuthor") ] ) ] - agent = ChatAgent(chat_client=chat_client_base, tools=HostedCodeInterpreterTool()) + agent = Agent(client=chat_client_base, tools={"type": "code_interpreter"}) result = await agent.run("Hello") assert result.text == "test response" @@ -215,91 +320,80 @@ async def test_chat_client_agent_author_name_is_used_from_response(chat_client_b # Mock context provider for testing -class MockContextProvider(ContextProvider): - def __init__(self, messages: list[ChatMessage] | None = None) -> None: +class MockContextProvider(BaseContextProvider): + def __init__(self, messages: list[Message] | None = None) -> None: + super().__init__(source_id="mock") self.context_messages = messages - self.thread_created_called = False - self.invoked_called = False - self.invoking_called = False - self.thread_created_thread_id = None - self.invoked_thread_id = None - self.new_messages: list[ChatMessage] = [] + self.before_run_called = False + self.after_run_called = False + self.new_messages: list[Message] = [] + self.last_service_session_id: str | None = None - async def thread_created(self, thread_id: str | None) -> None: - self.thread_created_called = True - self.thread_created_thread_id = thread_id + async def before_run(self, *, agent: Any, session: Any, context: Any, state: Any) -> None: + self.before_run_called = True + if self.context_messages: + context.extend_messages(self, self.context_messages) - async def invoked( - self, - request_messages: ChatMessage | Sequence[ChatMessage], - response_messages: ChatMessage | Sequence[ChatMessage] | None = None, - invoke_exception: Any = None, - **kwargs: Any, - ) -> None: - self.invoked_called = True - if isinstance(request_messages, ChatMessage): - self.new_messages.append(request_messages) - else: - self.new_messages.extend(request_messages) - if isinstance(response_messages, ChatMessage): - self.new_messages.append(response_messages) - else: - self.new_messages.extend(response_messages) - - async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context: - self.invoking_called = True - return Context(messages=self.context_messages) + async def after_run(self, *, agent: Any, session: Any, context: Any, state: Any) -> None: + self.after_run_called = True + if session: + self.last_service_session_id = session.service_session_id + if context.response: + self.new_messages.extend(context.input_messages) + self.new_messages.extend(context.response.messages) -async def test_chat_agent_context_providers_model_invoking(chat_client: ChatClientProtocol) -> None: - """Test that context providers' invoking is called during agent run.""" - mock_provider = MockContextProvider(messages=[ChatMessage(role="system", text="Test context instructions")]) - agent = ChatAgent(chat_client=chat_client, context_provider=mock_provider) +async def test_chat_agent_context_providers_model_before_run(client: SupportsChatGetResponse) -> None: + """Test that context providers' before_run is called during agent run.""" + mock_provider = MockContextProvider(messages=[Message(role="system", text="Test context instructions")]) + agent = Agent(client=client, context_providers=[mock_provider]) await agent.run("Hello") - assert mock_provider.invoking_called + assert mock_provider.before_run_called -async def test_chat_agent_context_providers_thread_created(chat_client_base: ChatClientProtocol) -> None: - """Test that context providers' thread_created is called during agent run.""" +async def test_chat_agent_context_providers_after_run(chat_client_base: SupportsChatGetResponse) -> None: + """Test that context providers' after_run is called during agent run.""" mock_provider = MockContextProvider() chat_client_base.run_responses = [ ChatResponse( - messages=[ChatMessage(role="assistant", contents=[Content.from_text("test response")])], + messages=[Message(role="assistant", contents=[Content.from_text("test response")])], conversation_id="test-thread-id", ) ] - agent = ChatAgent(chat_client=chat_client_base, context_provider=mock_provider) + agent = Agent(client=chat_client_base, context_providers=[mock_provider]) - await agent.run("Hello") + session = agent.get_session(service_session_id="test-thread-id") + await agent.run("Hello", session=session) - assert mock_provider.thread_created_called - assert mock_provider.thread_created_thread_id == "test-thread-id" + assert mock_provider.after_run_called + assert mock_provider.last_service_session_id == "test-thread-id" -async def test_chat_agent_context_providers_messages_adding(chat_client: ChatClientProtocol) -> None: - """Test that context providers' invoked is called during agent run.""" +async def test_chat_agent_context_providers_messages_adding(client: SupportsChatGetResponse) -> None: + """Test that context providers' after_run is called during agent run.""" mock_provider = MockContextProvider() - agent = ChatAgent(chat_client=chat_client, context_provider=mock_provider) + agent = Agent(client=client, context_providers=[mock_provider]) await agent.run("Hello") - assert mock_provider.invoked_called + assert mock_provider.after_run_called # Should be called with both input and response messages assert len(mock_provider.new_messages) >= 2 -async def test_chat_agent_context_instructions_in_messages(chat_client: ChatClientProtocol) -> None: +async def test_chat_agent_context_instructions_in_messages(client: SupportsChatGetResponse) -> None: """Test that AI context instructions are included in messages.""" - mock_provider = MockContextProvider(messages=[ChatMessage(role="system", text="Context-specific instructions")]) - agent = ChatAgent(chat_client=chat_client, instructions="Agent instructions", context_provider=mock_provider) + mock_provider = MockContextProvider(messages=[Message(role="system", text="Context-specific instructions")]) + agent = Agent(client=client, instructions="Agent instructions", context_providers=[mock_provider]) - # We need to test the _prepare_thread_and_messages method directly - _, _, messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage] - thread=None, input_messages=[ChatMessage(role="user", text="Hello")] + # We need to test the _prepare_session_and_messages method directly + session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage] + session=None, input_messages=[Message(role="user", text="Hello")] ) + messages = session_context.get_messages(include_input=True) # Should have context instructions, and user message assert len(messages) == 2 @@ -307,17 +401,18 @@ async def test_chat_agent_context_instructions_in_messages(chat_client: ChatClie assert messages[0].text == "Context-specific instructions" assert messages[1].role == "user" assert messages[1].text == "Hello" - # instructions system message is added by a chat_client + # instructions system message is added by a client -async def test_chat_agent_no_context_instructions(chat_client: ChatClientProtocol) -> None: +async def test_chat_agent_no_context_instructions(client: SupportsChatGetResponse) -> None: """Test behavior when AI context has no instructions.""" mock_provider = MockContextProvider() - agent = ChatAgent(chat_client=chat_client, instructions="Agent instructions", context_provider=mock_provider) + agent = Agent(client=client, instructions="Agent instructions", context_providers=[mock_provider]) - _, _, messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage] - thread=None, input_messages=[ChatMessage(role="user", text="Hello")] + session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage] + session=None, input_messages=[Message(role="user", text="Hello")] ) + messages = session_context.get_messages(include_input=True) # Should have agent instructions and user message only assert len(messages) == 1 @@ -325,10 +420,10 @@ async def test_chat_agent_no_context_instructions(chat_client: ChatClientProtoco assert messages[0].text == "Hello" -async def test_chat_agent_run_stream_context_providers(chat_client: ChatClientProtocol) -> None: +async def test_chat_agent_run_stream_context_providers(client: SupportsChatGetResponse) -> None: """Test that context providers work with run method.""" - mock_provider = MockContextProvider(messages=[ChatMessage(role="system", text="Stream context instructions")]) - agent = ChatAgent(chat_client=chat_client, context_provider=mock_provider) + mock_provider = MockContextProvider(messages=[Message(role="system", text="Stream context instructions")]) + agent = Agent(client=client, context_providers=[mock_provider]) # Collect all stream updates and get final response stream = agent.run("Hello", stream=True) @@ -339,36 +434,34 @@ async def test_chat_agent_run_stream_context_providers(chat_client: ChatClientPr await stream.get_final_response() # Verify context provider was called - assert mock_provider.invoking_called - # no conversation id is created, so no need to thread_create to be called. - assert not mock_provider.thread_created_called - assert mock_provider.invoked_called + assert mock_provider.before_run_called + assert mock_provider.after_run_called -async def test_chat_agent_context_providers_with_thread_service_id(chat_client_base: ChatClientProtocol) -> None: - """Test context providers with service-managed thread.""" +async def test_chat_agent_context_providers_with_service_session_id(chat_client_base: SupportsChatGetResponse) -> None: + """Test context providers with service-managed session.""" mock_provider = MockContextProvider() chat_client_base.run_responses = [ ChatResponse( - messages=[ChatMessage(role="assistant", contents=[Content.from_text("test response")])], + messages=[Message(role="assistant", contents=[Content.from_text("test response")])], conversation_id="service-thread-123", ) ] - agent = ChatAgent(chat_client=chat_client_base, context_provider=mock_provider) + agent = Agent(client=chat_client_base, context_providers=[mock_provider]) - # Use existing service-managed thread - thread = agent.get_new_thread(service_thread_id="existing-thread-id") - await agent.run("Hello", thread=thread) + # Use existing service-managed session + session = agent.get_session(service_session_id="existing-thread-id") + await agent.run("Hello", session=session) - # invoked should be called with the service thread ID from response - assert mock_provider.invoked_called + # after_run should be called + assert mock_provider.after_run_called # Tests for as_tool method -async def test_chat_agent_as_tool_basic(chat_client: ChatClientProtocol) -> None: +async def test_chat_agent_as_tool_basic(client: SupportsChatGetResponse) -> None: """Test basic as_tool functionality.""" - agent = ChatAgent(chat_client=chat_client, name="TestAgent", description="Test agent for as_tool") + agent = Agent(client=client, name="TestAgent", description="Test agent for as_tool") tool = agent.as_tool() @@ -378,9 +471,9 @@ async def test_chat_agent_as_tool_basic(chat_client: ChatClientProtocol) -> None assert hasattr(tool, "input_model") -async def test_chat_agent_as_tool_custom_parameters(chat_client: ChatClientProtocol) -> None: +async def test_chat_agent_as_tool_custom_parameters(client: SupportsChatGetResponse) -> None: """Test as_tool with custom parameters.""" - agent = ChatAgent(chat_client=chat_client, name="TestAgent", description="Original description") + agent = Agent(client=client, name="TestAgent", description="Original description") tool = agent.as_tool( name="CustomTool", @@ -398,10 +491,10 @@ async def test_chat_agent_as_tool_custom_parameters(chat_client: ChatClientProto assert schema["properties"]["query"]["description"] == "Custom input description" -async def test_chat_agent_as_tool_defaults(chat_client: ChatClientProtocol) -> None: +async def test_chat_agent_as_tool_defaults(client: SupportsChatGetResponse) -> None: """Test as_tool with default parameters.""" - agent = ChatAgent( - chat_client=chat_client, + agent = Agent( + client=client, name="TestAgent", # No description provided ) @@ -417,18 +510,18 @@ async def test_chat_agent_as_tool_defaults(chat_client: ChatClientProtocol) -> N assert "Task for TestAgent" in schema["properties"]["task"]["description"] -async def test_chat_agent_as_tool_no_name(chat_client: ChatClientProtocol) -> None: +async def test_chat_agent_as_tool_no_name(client: SupportsChatGetResponse) -> None: """Test as_tool when agent has no name (should raise ValueError).""" - agent = ChatAgent(chat_client=chat_client) # No name provided + agent = Agent(client=client) # No name provided # Should raise ValueError since agent has no name with raises(ValueError, match="Agent tool name cannot be None"): agent.as_tool() -async def test_chat_agent_as_tool_function_execution(chat_client: ChatClientProtocol) -> None: +async def test_chat_agent_as_tool_function_execution(client: SupportsChatGetResponse) -> None: """Test that the generated FunctionTool can be executed.""" - agent = ChatAgent(chat_client=chat_client, name="TestAgent", description="Test agent") + agent = Agent(client=client, name="TestAgent", description="Test agent") tool = agent.as_tool() @@ -440,9 +533,9 @@ async def test_chat_agent_as_tool_function_execution(chat_client: ChatClientProt assert result == "test response" # From mock chat client -async def test_chat_agent_as_tool_with_stream_callback(chat_client: ChatClientProtocol) -> None: +async def test_chat_agent_as_tool_with_stream_callback(client: SupportsChatGetResponse) -> None: """Test as_tool with stream callback functionality.""" - agent = ChatAgent(chat_client=chat_client, name="StreamingAgent") + agent = Agent(client=client, name="StreamingAgent") # Collect streaming updates collected_updates: list[AgentResponseUpdate] = [] @@ -463,9 +556,9 @@ async def test_chat_agent_as_tool_with_stream_callback(chat_client: ChatClientPr assert result == expected_text -async def test_chat_agent_as_tool_with_custom_arg_name(chat_client: ChatClientProtocol) -> None: +async def test_chat_agent_as_tool_with_custom_arg_name(client: SupportsChatGetResponse) -> None: """Test as_tool with custom argument name.""" - agent = ChatAgent(chat_client=chat_client, name="CustomArgAgent") + agent = Agent(client=client, name="CustomArgAgent") tool = agent.as_tool(arg_name="prompt", arg_description="Custom prompt input") @@ -474,9 +567,9 @@ async def test_chat_agent_as_tool_with_custom_arg_name(chat_client: ChatClientPr assert result == "test response" -async def test_chat_agent_as_tool_with_async_stream_callback(chat_client: ChatClientProtocol) -> None: +async def test_chat_agent_as_tool_with_async_stream_callback(client: SupportsChatGetResponse) -> None: """Test as_tool with async stream callback functionality.""" - agent = ChatAgent(chat_client=chat_client, name="AsyncStreamingAgent") + agent = Agent(client=client, name="AsyncStreamingAgent") # Collect streaming updates using an async callback collected_updates: list[AgentResponseUpdate] = [] @@ -497,7 +590,7 @@ async def test_chat_agent_as_tool_with_async_stream_callback(chat_client: ChatCl assert result == expected_text -async def test_chat_agent_as_tool_name_sanitization(chat_client: ChatClientProtocol) -> None: +async def test_chat_agent_as_tool_name_sanitization(client: SupportsChatGetResponse) -> None: """Test as_tool name sanitization.""" test_cases = [ ("Invoice & Billing Agent", "Invoice_Billing_Agent"), @@ -510,14 +603,14 @@ async def test_chat_agent_as_tool_name_sanitization(chat_client: ChatClientProto ] for agent_name, expected_tool_name in test_cases: - agent = ChatAgent(chat_client=chat_client, name=agent_name, description="Test agent") + agent = Agent(client=client, name=agent_name, description="Test agent") tool = agent.as_tool() assert tool.name == expected_tool_name, f"Expected {expected_tool_name}, got {tool.name} for input {agent_name}" -async def test_chat_agent_as_mcp_server_basic(chat_client: ChatClientProtocol) -> None: +async def test_chat_agent_as_mcp_server_basic(client: SupportsChatGetResponse) -> None: """Test basic as_mcp_server functionality.""" - agent = ChatAgent(chat_client=chat_client, name="TestAgent", description="Test agent for MCP") + agent = Agent(client=client, name="TestAgent", description="Test agent for MCP") # Create MCP server with default parameters server = agent.as_mcp_server() @@ -528,9 +621,9 @@ async def test_chat_agent_as_mcp_server_basic(chat_client: ChatClientProtocol) - assert hasattr(server, "version") -async def test_chat_agent_run_with_mcp_tools(chat_client: ChatClientProtocol) -> None: +async def test_chat_agent_run_with_mcp_tools(client: SupportsChatGetResponse) -> None: """Test run method with MCP tools to cover MCP tool handling code.""" - agent = ChatAgent(chat_client=chat_client, name="TestAgent", description="Test agent") + agent = Agent(client=client, name="TestAgent", description="Test agent") # Create a mock MCP tool mock_mcp_tool = MagicMock(spec=MCPTool) @@ -547,7 +640,7 @@ async def test_chat_agent_run_with_mcp_tools(chat_client: ChatClientProtocol) -> await agent.run(messages="Test message", tools=[mock_mcp_tool]) -async def test_chat_agent_with_local_mcp_tools(chat_client: ChatClientProtocol) -> None: +async def test_chat_agent_with_local_mcp_tools(client: SupportsChatGetResponse) -> None: """Test agent initialization with local MCP tools.""" # Create a mock MCP tool mock_mcp_tool = MagicMock(spec=MCPTool) @@ -557,47 +650,45 @@ async def test_chat_agent_with_local_mcp_tools(chat_client: ChatClientProtocol) # Test agent with MCP tools in constructor with contextlib.suppress(Exception): - agent = ChatAgent(chat_client=chat_client, name="TestAgent", description="Test agent", tools=[mock_mcp_tool]) + agent = Agent(client=client, name="TestAgent", description="Test agent", tools=[mock_mcp_tool]) # Test async context manager with MCP tools async with agent: pass -async def test_agent_tool_receives_thread_in_kwargs(chat_client_base: Any) -> None: - """Verify tool execution receives 'thread' inside **kwargs when function is called by client.""" +async def test_agent_tool_receives_session_in_kwargs(chat_client_base: Any) -> None: + """Verify tool execution receives 'session' inside **kwargs when function is called by client.""" captured: dict[str, Any] = {} - @tool(name="echo_thread_info", approval_mode="never_require") - def echo_thread_info(text: str, **kwargs: Any) -> str: # type: ignore[reportUnknownParameterType] - thread = kwargs.get("thread") - captured["has_thread"] = thread is not None - captured["has_message_store"] = thread.message_store is not None if isinstance(thread, AgentThread) else False + @tool(name="echo_session_info", approval_mode="never_require") + def echo_session_info(text: str, **kwargs: Any) -> str: # type: ignore[reportUnknownParameterType] + session = kwargs.get("session") + captured["has_session"] = session is not None + captured["has_state"] = session.state is not None if isinstance(session, AgentSession) else False return f"echo: {text}" # Make the base client emit a function call for our tool chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ - Content.from_function_call(call_id="1", name="echo_thread_info", arguments='{"text": "hello"}') + Content.from_function_call(call_id="1", name="echo_session_info", arguments='{"text": "hello"}') ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] - agent = ChatAgent( - chat_client=chat_client_base, tools=[echo_thread_info], chat_message_store_factory=ChatMessageStore - ) - thread = agent.get_new_thread() + agent = Agent(client=chat_client_base, tools=[echo_session_info]) + session = agent.create_session() - result = await agent.run("hello", thread=thread, options={"additional_function_arguments": {"thread": thread}}) + result = await agent.run("hello", session=session, options={"additional_function_arguments": {"session": session}}) assert result.text == "done" - assert captured.get("has_thread") is True - assert captured.get("has_message_store") is True + assert captured.get("has_session") is True + assert captured.get("has_state") is True async def test_chat_agent_tool_choice_run_level_overrides_agent_level(chat_client_base: Any, tool_tool: Any) -> None: @@ -609,7 +700,7 @@ async def test_chat_agent_tool_choice_run_level_overrides_agent_level(chat_clien original_inner = chat_client_base._inner_get_response async def capturing_inner( - *, messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + *, messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> ChatResponse: captured_options.append(options) return await original_inner(messages=messages, options=options, **kwargs) @@ -617,8 +708,8 @@ async def test_chat_agent_tool_choice_run_level_overrides_agent_level(chat_clien chat_client_base._inner_get_response = capturing_inner # Create agent with agent-level tool_choice="auto" and a tool (tools required for tool_choice to be meaningful) - agent = ChatAgent( - chat_client=chat_client_base, + agent = Agent( + client=chat_client_base, tools=[tool_tool], options={"tool_choice": "auto"}, ) @@ -640,7 +731,7 @@ async def test_chat_agent_tool_choice_agent_level_used_when_run_level_not_specif original_inner = chat_client_base._inner_get_response async def capturing_inner( - *, messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + *, messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> ChatResponse: captured_options.append(options) return await original_inner(messages=messages, options=options, **kwargs) @@ -648,8 +739,8 @@ async def test_chat_agent_tool_choice_agent_level_used_when_run_level_not_specif chat_client_base._inner_get_response = capturing_inner # Create agent with agent-level tool_choice="required" and a tool - agent = ChatAgent( - chat_client=chat_client_base, + agent = Agent( + client=chat_client_base, tools=[tool_tool], default_options={"tool_choice": "required"}, ) @@ -671,7 +762,7 @@ async def test_chat_agent_tool_choice_none_at_run_preserves_agent_level(chat_cli original_inner = chat_client_base._inner_get_response async def capturing_inner( - *, messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + *, messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> ChatResponse: captured_options.append(options) return await original_inner(messages=messages, options=options, **kwargs) @@ -679,8 +770,8 @@ async def test_chat_agent_tool_choice_none_at_run_preserves_agent_level(chat_cli chat_client_base._inner_get_response = capturing_inner # Create agent with agent-level tool_choice="auto" and a tool - agent = ChatAgent( - chat_client=chat_client_base, + agent = Agent( + client=chat_client_base, tools=[tool_tool], default_options={"tool_choice": "auto"}, ) @@ -804,102 +895,82 @@ def test_sanitize_agent_name_replaces_invalid_chars(): # endregion -# region Test SupportsAgentRun.get_new_thread and deserialize_thread +# region Test SupportsAgentRun.create_session @pytest.mark.asyncio -async def test_agent_get_new_thread(chat_client_base: ChatClientProtocol, tool_tool: ToolProtocol): - """Test that get_new_thread returns a new AgentThread.""" - agent = ChatAgent(chat_client=chat_client_base, tools=[tool_tool]) +async def test_agent_create_session(chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool): + """Test that create_session returns a new AgentSession.""" + agent = Agent(client=chat_client_base, tools=[tool_tool]) - thread = agent.get_new_thread() + session = agent.create_session() - assert thread is not None - assert isinstance(thread, AgentThread) + assert session is not None + assert isinstance(session, AgentSession) @pytest.mark.asyncio -async def test_agent_get_new_thread_with_context_provider( - chat_client_base: ChatClientProtocol, tool_tool: ToolProtocol +async def test_agent_create_session_with_context_providers( + chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool ): - """Test that get_new_thread passes context_provider to the thread.""" + """Test that create_session works when context_providers are set on the agent.""" - class TestContextProvider(ContextProvider): - async def invoking(self, messages, **kwargs): - return Context() + class TestContextProvider(BaseContextProvider): + def __init__(self): + super().__init__(source_id="test") provider = TestContextProvider() - agent = ChatAgent(chat_client=chat_client_base, tools=[tool_tool], context_provider=provider) + agent = Agent(client=chat_client_base, tools=[tool_tool], context_providers=[provider]) - thread = agent.get_new_thread() + session = agent.create_session() - assert thread is not None - assert thread.context_provider is provider + assert session is not None + assert agent.context_providers[0] is provider @pytest.mark.asyncio -async def test_agent_get_new_thread_with_service_thread_id( - chat_client_base: ChatClientProtocol, tool_tool: ToolProtocol +async def test_agent_get_session_with_service_session_id( + chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool ): - """Test that get_new_thread passes kwargs like service_thread_id to the thread.""" - agent = ChatAgent(chat_client=chat_client_base, tools=[tool_tool]) + """Test that get_session creates a session with service_session_id.""" + agent = Agent(client=chat_client_base, tools=[tool_tool]) - thread = agent.get_new_thread(service_thread_id="test-thread-123") + session = agent.get_session(service_session_id="test-thread-123") - assert thread is not None - assert thread.service_thread_id == "test-thread-123" + assert session is not None + assert session.service_session_id == "test-thread-123" -@pytest.mark.asyncio -async def test_agent_deserialize_thread(chat_client_base: ChatClientProtocol, tool_tool: ToolProtocol): - """Test deserialize_thread restores a thread from serialized state.""" - agent = ChatAgent(chat_client=chat_client_base, tools=[tool_tool]) - - # Create serialized thread state with messages +def test_agent_session_from_dict(chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool): + """Test AgentSession.from_dict restores a session from serialized state.""" + # Create serialized session state serialized_state = { - "service_thread_id": None, - "chat_message_store_state": { - "messages": [{"role": "user", "text": "Hello"}], - }, + "type": "session", + "session_id": "test-session", + "service_session_id": None, + "state": {}, } - thread = await agent.deserialize_thread(serialized_state) + session = AgentSession.from_dict(serialized_state) - assert thread is not None - assert isinstance(thread, AgentThread) - assert thread.message_store is not None - messages = await thread.message_store.list_messages() - assert len(messages) == 1 - assert messages[0].text == "Hello" + assert session is not None + assert isinstance(session, AgentSession) + assert session.session_id == "test-session" # endregion -# region Test ChatAgent initialization edge cases - - -@pytest.mark.asyncio -async def test_chat_agent_raises_with_both_conversation_id_and_store(): - """Test ChatAgent raises error with both conversation_id and chat_message_store_factory.""" - mock_client = MagicMock() - mock_store_factory = MagicMock() - - with pytest.raises(AgentInitializationError, match="Cannot specify both"): - ChatAgent( - chat_client=mock_client, - default_options={"conversation_id": "test_id"}, - chat_message_store_factory=mock_store_factory, - ) +# region Test Agent initialization edge cases def test_chat_agent_calls_update_agent_name_on_client(): - """Test that ChatAgent calls _update_agent_name_and_description on client if available.""" + """Test that Agent calls _update_agent_name_and_description on client if available.""" mock_client = MagicMock() mock_client._update_agent_name_and_description = MagicMock() - ChatAgent( - chat_client=mock_client, + Agent( + client=mock_client, name="TestAgent", description="Test description", ) @@ -909,7 +980,7 @@ def test_chat_agent_calls_update_agent_name_on_client(): @pytest.mark.asyncio -async def test_chat_agent_context_provider_adds_tools_when_agent_has_none(chat_client_base: ChatClientProtocol): +async def test_chat_agent_context_provider_adds_tools_when_agent_has_none(chat_client_base: SupportsChatGetResponse): """Test that context provider tools are used when agent has no default tools.""" @tool @@ -917,19 +988,22 @@ async def test_chat_agent_context_provider_adds_tools_when_agent_has_none(chat_c """A tool provided by context.""" return text - class ToolContextProvider(ContextProvider): - async def invoking(self, messages, **kwargs): - return Context(tools=[context_tool]) + class ToolContextProvider(BaseContextProvider): + def __init__(self): + super().__init__(source_id="tool-context") + + async def before_run(self, *, agent, session, context, state): + context.extend_tools("tool-context", [context_tool]) provider = ToolContextProvider() - agent = ChatAgent(chat_client=chat_client_base, context_provider=provider) + agent = Agent(client=chat_client_base, context_providers=[provider]) # Agent starts with empty tools list assert agent.default_options.get("tools") == [] # Run the agent and verify context tools are added - _, options, _ = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage] - thread=None, input_messages=[ChatMessage(role="user", text="Hello")] + _, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage] + session=None, input_messages=[Message(role="user", text="Hello")] ) # The context tools should now be in the options @@ -938,43 +1012,81 @@ async def test_chat_agent_context_provider_adds_tools_when_agent_has_none(chat_c @pytest.mark.asyncio -async def test_chat_agent_context_provider_adds_instructions_when_agent_has_none(chat_client_base: ChatClientProtocol): +async def test_chat_agent_context_provider_adds_instructions_when_agent_has_none( + chat_client_base: SupportsChatGetResponse, +): """Test that context provider instructions are used when agent has no default instructions.""" - class InstructionContextProvider(ContextProvider): - async def invoking(self, messages, **kwargs): - return Context(instructions="Context-provided instructions") + class InstructionContextProvider(BaseContextProvider): + def __init__(self): + super().__init__(source_id="instruction-context") + + async def before_run(self, *, agent, session, context, state): + context.extend_instructions("instruction-context", "Context-provided instructions") provider = InstructionContextProvider() - agent = ChatAgent(chat_client=chat_client_base, context_provider=provider) + agent = Agent(client=chat_client_base, context_providers=[provider]) # Verify agent has no default instructions assert agent.default_options.get("instructions") is None # Run the agent and verify context instructions are available - _, options, _ = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage] - thread=None, input_messages=[ChatMessage(role="user", text="Hello")] + _, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage] + session=None, input_messages=[Message(role="user", text="Hello")] ) # The context instructions should now be in the options assert options.get("instructions") == "Context-provided instructions" -@pytest.mark.asyncio -async def test_chat_agent_raises_on_conversation_id_mismatch(chat_client_base: ChatClientProtocol): - """Test that ChatAgent raises when thread and agent have different conversation IDs.""" - agent = ChatAgent( - chat_client=chat_client_base, - default_options={"conversation_id": "agent-conversation-id"}, - ) +# region STORES_BY_DEFAULT tests - # Create a thread with a different service_thread_id - thread = AgentThread(service_thread_id="different-thread-id") - with pytest.raises(AgentExecutionException, match="conversation_id set on the agent is different"): - await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage] - thread=thread, input_messages=[ChatMessage(role="user", text="Hello")] - ) +async def test_stores_by_default_skips_inmemory_injection(client: SupportsChatGetResponse) -> None: + """Client with STORES_BY_DEFAULT=True should not auto-inject InMemoryHistoryProvider.""" + from agent_framework._sessions import InMemoryHistoryProvider + + # Simulate a client that stores by default + client.STORES_BY_DEFAULT = True # type: ignore[attr-defined] + + agent = Agent(client=client) + session = agent.create_session() + + await agent.run("Hello", session=session) + + # No InMemoryHistoryProvider should have been injected + assert not any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers) + + +async def test_stores_by_default_false_injects_inmemory(client: SupportsChatGetResponse) -> None: + """Client with STORES_BY_DEFAULT=False (default) should auto-inject InMemoryHistoryProvider.""" + from agent_framework._sessions import InMemoryHistoryProvider + + agent = Agent(client=client) + session = agent.create_session() + + await agent.run("Hello", session=session) + + # InMemoryHistoryProvider should have been injected + assert any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers) + + +async def test_stores_by_default_with_store_false_injects_inmemory(client: SupportsChatGetResponse) -> None: + """Client with STORES_BY_DEFAULT=True but store=False should still inject InMemoryHistoryProvider.""" + from agent_framework._sessions import InMemoryHistoryProvider + + client.STORES_BY_DEFAULT = True # type: ignore[attr-defined] + + agent = Agent(client=client) + session = agent.create_session() + + await agent.run("Hello", session=session, options={"store": False}) + + # User explicitly disabled server storage, so InMemoryHistoryProvider should be injected + assert any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers) + + +# endregion # endregion diff --git a/python/packages/core/tests/core/test_as_tool_kwargs_propagation.py b/python/packages/core/tests/core/test_as_tool_kwargs_propagation.py index 4672b10e77..da8e907c40 100644 --- a/python/packages/core/tests/core/test_as_tool_kwargs_propagation.py +++ b/python/packages/core/tests/core/test_as_tool_kwargs_propagation.py @@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable from typing import Any -from agent_framework import ChatAgent, ChatMessage, ChatResponse, Content, agent_middleware +from agent_framework import Agent, ChatResponse, Content, Message, agent_middleware from agent_framework._middleware import AgentContext from .conftest import MockChatClient @@ -14,26 +14,24 @@ from .conftest import MockChatClient class TestAsToolKwargsPropagation: """Test cases for kwargs propagation through as_tool() delegation.""" - async def test_as_tool_forwards_runtime_kwargs(self, chat_client: MockChatClient) -> None: + async def test_as_tool_forwards_runtime_kwargs(self, client: MockChatClient) -> None: """Test that runtime kwargs are forwarded through as_tool() to sub-agent.""" captured_kwargs: dict[str, Any] = {} @agent_middleware - async def capture_middleware( - context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def capture_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: # Capture kwargs passed to the sub-agent captured_kwargs.update(context.kwargs) - await call_next(context) + await call_next() # Setup mock response - chat_client.responses = [ - ChatResponse(messages=[ChatMessage(role="assistant", text="Response from sub-agent")]), + client.responses = [ + ChatResponse(messages=[Message(role="assistant", text="Response from sub-agent")]), ] # Create sub-agent with middleware - sub_agent = ChatAgent( - chat_client=chat_client, + sub_agent = Agent( + client=client, name="sub_agent", middleware=[capture_middleware], ) @@ -57,24 +55,22 @@ class TestAsToolKwargsPropagation: assert "session_id" in captured_kwargs assert captured_kwargs["session_id"] == "session-789" - async def test_as_tool_excludes_arg_name_from_forwarded_kwargs(self, chat_client: MockChatClient) -> None: + async def test_as_tool_excludes_arg_name_from_forwarded_kwargs(self, client: MockChatClient) -> None: """Test that the arg_name parameter is not forwarded as a kwarg.""" captured_kwargs: dict[str, Any] = {} @agent_middleware - async def capture_middleware( - context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def capture_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: captured_kwargs.update(context.kwargs) - await call_next(context) + await call_next() # Setup mock response - chat_client.responses = [ - ChatResponse(messages=[ChatMessage(role="assistant", text="Response from sub-agent")]), + client.responses = [ + ChatResponse(messages=[Message(role="assistant", text="Response from sub-agent")]), ] - sub_agent = ChatAgent( - chat_client=chat_client, + sub_agent = Agent( + client=client, name="sub_agent", middleware=[capture_middleware], ) @@ -94,23 +90,21 @@ class TestAsToolKwargsPropagation: assert "api_token" in captured_kwargs assert captured_kwargs["api_token"] == "token-123" - async def test_as_tool_nested_delegation_propagates_kwargs(self, chat_client: MockChatClient) -> None: + async def test_as_tool_nested_delegation_propagates_kwargs(self, client: MockChatClient) -> None: """Test that kwargs propagate through multiple levels of delegation (A → B → C).""" captured_kwargs_list: list[dict[str, Any]] = [] @agent_middleware - async def capture_middleware( - context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def capture_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: # Capture kwargs at each level captured_kwargs_list.append(dict(context.kwargs)) - await call_next(context) + await call_next() # Setup mock responses to trigger nested tool invocation: B calls tool C, then completes. - chat_client.responses = [ + client.responses = [ ChatResponse( messages=[ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call( @@ -122,20 +116,20 @@ class TestAsToolKwargsPropagation: ) ] ), - ChatResponse(messages=[ChatMessage(role="assistant", text="Response from agent_c")]), - ChatResponse(messages=[ChatMessage(role="assistant", text="Response from agent_b")]), + ChatResponse(messages=[Message(role="assistant", text="Response from agent_c")]), + ChatResponse(messages=[Message(role="assistant", text="Response from agent_b")]), ] # Create agent C (bottom level) - agent_c = ChatAgent( - chat_client=chat_client, + agent_c = Agent( + client=client, name="agent_c", middleware=[capture_middleware], ) # Create agent B (middle level) - delegates to C - agent_b = ChatAgent( - chat_client=chat_client, + agent_b = Agent( + client=client, name="agent_b", tools=[agent_c.as_tool(name="call_c")], middleware=[capture_middleware], @@ -157,26 +151,24 @@ class TestAsToolKwargsPropagation: assert captured_kwargs_list[0].get("trace_id") == "trace-abc-123" assert captured_kwargs_list[0].get("tenant_id") == "tenant-xyz" - async def test_as_tool_streaming_mode_forwards_kwargs(self, chat_client: MockChatClient) -> None: + async def test_as_tool_streaming_mode_forwards_kwargs(self, client: MockChatClient) -> None: """Test that kwargs are forwarded in streaming mode.""" captured_kwargs: dict[str, Any] = {} @agent_middleware - async def capture_middleware( - context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def capture_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: captured_kwargs.update(context.kwargs) - await call_next(context) + await call_next() # Setup mock streaming responses from agent_framework import ChatResponseUpdate - chat_client.streaming_responses = [ + client.streaming_responses = [ [ChatResponseUpdate(contents=[Content.from_text(text="Streaming response")], role="assistant")], ] - sub_agent = ChatAgent( - chat_client=chat_client, + sub_agent = Agent( + client=client, name="sub_agent", middleware=[capture_middleware], ) @@ -199,15 +191,15 @@ class TestAsToolKwargsPropagation: assert captured_kwargs["api_key"] == "streaming-key-999" assert len(captured_updates) == 1 - async def test_as_tool_empty_kwargs_still_works(self, chat_client: MockChatClient) -> None: + async def test_as_tool_empty_kwargs_still_works(self, client: MockChatClient) -> None: """Test that as_tool works correctly when no extra kwargs are provided.""" # Setup mock response - chat_client.responses = [ - ChatResponse(messages=[ChatMessage(role="assistant", text="Response from agent")]), + client.responses = [ + ChatResponse(messages=[Message(role="assistant", text="Response from agent")]), ] - sub_agent = ChatAgent( - chat_client=chat_client, + sub_agent = Agent( + client=client, name="sub_agent", ) @@ -219,24 +211,22 @@ class TestAsToolKwargsPropagation: # Verify tool executed successfully assert result is not None - async def test_as_tool_kwargs_with_chat_options(self, chat_client: MockChatClient) -> None: + async def test_as_tool_kwargs_with_chat_options(self, client: MockChatClient) -> None: """Test that kwargs including chat_options are properly forwarded.""" captured_kwargs: dict[str, Any] = {} @agent_middleware - async def capture_middleware( - context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def capture_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: captured_kwargs.update(context.kwargs) - await call_next(context) + await call_next() # Setup mock response - chat_client.responses = [ - ChatResponse(messages=[ChatMessage(role="assistant", text="Response with options")]), + client.responses = [ + ChatResponse(messages=[Message(role="assistant", text="Response with options")]), ] - sub_agent = ChatAgent( - chat_client=chat_client, + sub_agent = Agent( + client=client, name="sub_agent", middleware=[capture_middleware], ) @@ -259,32 +249,30 @@ class TestAsToolKwargsPropagation: assert "custom_param" in captured_kwargs assert captured_kwargs["custom_param"] == "custom_value" - async def test_as_tool_kwargs_isolated_per_invocation(self, chat_client: MockChatClient) -> None: + async def test_as_tool_kwargs_isolated_per_invocation(self, client: MockChatClient) -> None: """Test that kwargs are isolated per invocation and don't leak between calls.""" first_call_kwargs: dict[str, Any] = {} second_call_kwargs: dict[str, Any] = {} call_count = 0 @agent_middleware - async def capture_middleware( - context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def capture_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: nonlocal call_count call_count += 1 if call_count == 1: first_call_kwargs.update(context.kwargs) elif call_count == 2: second_call_kwargs.update(context.kwargs) - await call_next(context) + await call_next() # Setup mock responses for both calls - chat_client.responses = [ - ChatResponse(messages=[ChatMessage(role="assistant", text="First response")]), - ChatResponse(messages=[ChatMessage(role="assistant", text="Second response")]), + client.responses = [ + ChatResponse(messages=[Message(role="assistant", text="First response")]), + ChatResponse(messages=[Message(role="assistant", text="Second response")]), ] - sub_agent = ChatAgent( - chat_client=chat_client, + sub_agent = Agent( + client=client, name="sub_agent", middleware=[capture_middleware], ) @@ -313,24 +301,22 @@ class TestAsToolKwargsPropagation: assert second_call_kwargs.get("session_id") == "session-2" assert second_call_kwargs.get("api_token") == "token-2" - async def test_as_tool_excludes_conversation_id_from_forwarded_kwargs(self, chat_client: MockChatClient) -> None: + async def test_as_tool_excludes_conversation_id_from_forwarded_kwargs(self, client: MockChatClient) -> None: """Test that conversation_id is not forwarded to sub-agent.""" captured_kwargs: dict[str, Any] = {} @agent_middleware - async def capture_middleware( - context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def capture_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: captured_kwargs.update(context.kwargs) - await call_next(context) + await call_next() # Setup mock response - chat_client.responses = [ - ChatResponse(messages=[ChatMessage(role="assistant", text="Response from sub-agent")]), + client.responses = [ + ChatResponse(messages=[Message(role="assistant", text="Response from sub-agent")]), ] - sub_agent = ChatAgent( - chat_client=chat_client, + sub_agent = Agent( + client=client, name="sub_agent", middleware=[capture_middleware], ) diff --git a/python/packages/core/tests/core/test_clients.py b/python/packages/core/tests/core/test_clients.py index e0c3da64da..0f87828baa 100644 --- a/python/packages/core/tests/core/test_clients.py +++ b/python/packages/core/tests/core/test_clients.py @@ -5,49 +5,54 @@ from unittest.mock import patch from agent_framework import ( BaseChatClient, - ChatClientProtocol, - ChatMessage, ChatResponse, + Message, + SupportsChatGetResponse, + SupportsCodeInterpreterTool, + SupportsFileSearchTool, + SupportsImageGenerationTool, + SupportsMCPTool, + SupportsWebSearchTool, ) -def test_chat_client_type(chat_client: ChatClientProtocol): - assert isinstance(chat_client, ChatClientProtocol) +def test_chat_client_type(client: SupportsChatGetResponse): + assert isinstance(client, SupportsChatGetResponse) -async def test_chat_client_get_response(chat_client: ChatClientProtocol): - response = await chat_client.get_response(ChatMessage(role="user", text="Hello")) +async def test_chat_client_get_response(client: SupportsChatGetResponse): + response = await client.get_response(Message(role="user", text="Hello")) assert response.text == "test response" assert response.messages[0].role == "assistant" -async def test_chat_client_get_response_streaming(chat_client: ChatClientProtocol): - async for update in chat_client.get_response(ChatMessage(role="user", text="Hello"), stream=True): +async def test_chat_client_get_response_streaming(client: SupportsChatGetResponse): + async for update in client.get_response(Message(role="user", text="Hello"), stream=True): assert update.text == "test streaming response " or update.text == "another update" assert update.role == "assistant" -def test_base_client(chat_client_base: ChatClientProtocol): +def test_base_client(chat_client_base: SupportsChatGetResponse): assert isinstance(chat_client_base, BaseChatClient) - assert isinstance(chat_client_base, ChatClientProtocol) + assert isinstance(chat_client_base, SupportsChatGetResponse) -async def test_base_client_get_response(chat_client_base: ChatClientProtocol): - response = await chat_client_base.get_response(ChatMessage(role="user", text="Hello")) +async def test_base_client_get_response(chat_client_base: SupportsChatGetResponse): + response = await chat_client_base.get_response(Message(role="user", text="Hello")) assert response.messages[0].role == "assistant" assert response.messages[0].text == "test response - Hello" -async def test_base_client_get_response_streaming(chat_client_base: ChatClientProtocol): - async for update in chat_client_base.get_response(ChatMessage(role="user", text="Hello"), stream=True): +async def test_base_client_get_response_streaming(chat_client_base: SupportsChatGetResponse): + async for update in chat_client_base.get_response(Message(role="user", text="Hello"), stream=True): assert update.text == "update - Hello" or update.text == "another update" -async def test_chat_client_instructions_handling(chat_client_base: ChatClientProtocol): +async def test_chat_client_instructions_handling(chat_client_base: SupportsChatGetResponse): instructions = "You are a helpful assistant." async def fake_inner_get_response(**kwargs): - return ChatResponse(messages=[ChatMessage(role="assistant", text="ok")]) + return ChatResponse(messages=[Message(role="assistant", text="ok")]) with patch.object( chat_client_base, @@ -65,7 +70,7 @@ async def test_chat_client_instructions_handling(chat_client_base: ChatClientPro from agent_framework._types import prepend_instructions_to_messages appended_messages = prepend_instructions_to_messages( - [ChatMessage(role="user", text="hello")], + [Message(role="user", text="hello")], instructions, ) assert len(appended_messages) == 2 @@ -73,3 +78,66 @@ async def test_chat_client_instructions_handling(chat_client_base: ChatClientPro assert appended_messages[0].text == "You are a helpful assistant." assert appended_messages[1].role == "user" assert appended_messages[1].text == "hello" + + +# region Tool Support Protocol Tests + + +def test_openai_responses_client_supports_all_tool_protocols(): + """Test that OpenAIResponsesClient supports all hosted tool protocols.""" + from agent_framework.openai import OpenAIResponsesClient + + assert isinstance(OpenAIResponsesClient, SupportsCodeInterpreterTool) + assert isinstance(OpenAIResponsesClient, SupportsWebSearchTool) + assert isinstance(OpenAIResponsesClient, SupportsImageGenerationTool) + assert isinstance(OpenAIResponsesClient, SupportsMCPTool) + assert isinstance(OpenAIResponsesClient, SupportsFileSearchTool) + + +def test_openai_chat_client_supports_web_search_only(): + """Test that OpenAIChatClient only supports web search tool.""" + from agent_framework.openai import OpenAIChatClient + + assert not isinstance(OpenAIChatClient, SupportsCodeInterpreterTool) + assert isinstance(OpenAIChatClient, SupportsWebSearchTool) + assert not isinstance(OpenAIChatClient, SupportsImageGenerationTool) + assert not isinstance(OpenAIChatClient, SupportsMCPTool) + assert not isinstance(OpenAIChatClient, SupportsFileSearchTool) + + +def test_openai_assistants_client_supports_code_interpreter_and_file_search(): + """Test that OpenAIAssistantsClient supports code interpreter and file search.""" + from agent_framework.openai import OpenAIAssistantsClient + + assert isinstance(OpenAIAssistantsClient, SupportsCodeInterpreterTool) + assert not isinstance(OpenAIAssistantsClient, SupportsWebSearchTool) + assert not isinstance(OpenAIAssistantsClient, SupportsImageGenerationTool) + assert not isinstance(OpenAIAssistantsClient, SupportsMCPTool) + assert isinstance(OpenAIAssistantsClient, SupportsFileSearchTool) + + +def test_protocol_isinstance_with_client_instance(): + """Test that protocol isinstance works with client instances.""" + from agent_framework.openai import OpenAIResponsesClient + + # Create mock client instance (won't connect to API) + client = OpenAIResponsesClient.__new__(OpenAIResponsesClient) + + assert isinstance(client, SupportsCodeInterpreterTool) + assert isinstance(client, SupportsWebSearchTool) + + +def test_protocol_tool_methods_return_dict(): + """Test that static tool methods return dict[str, Any].""" + from agent_framework.openai import OpenAIResponsesClient + + code_tool = OpenAIResponsesClient.get_code_interpreter_tool() + assert isinstance(code_tool, dict) + assert code_tool.get("type") == "code_interpreter" + + web_tool = OpenAIResponsesClient.get_web_search_tool() + assert isinstance(web_tool, dict) + assert web_tool.get("type") == "web_search" + + +# endregion diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 946bb89724..9e498cae76 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -7,18 +7,18 @@ from typing import Any import pytest from agent_framework import ( - ChatAgent, - ChatClientProtocol, - ChatMessage, + Agent, ChatResponse, ChatResponseUpdate, Content, + Message, + SupportsChatGetResponse, tool, ) from agent_framework._middleware import FunctionInvocationContext, FunctionMiddleware, MiddlewareTermination -async def test_base_client_with_function_calling(chat_client_base: ChatClientProtocol): +async def test_base_client_with_function_calling(chat_client_base: SupportsChatGetResponse): exec_counter = 0 @tool(name="test_function", approval_mode="never_require") @@ -29,14 +29,14 @@ async def test_base_client_with_function_calling(chat_client_base: ChatClientPro chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="test_function", arguments='{"arg1": "value1"}') ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [ai_func]}) assert exec_counter == 1 @@ -55,7 +55,7 @@ async def test_base_client_with_function_calling(chat_client_base: ChatClientPro @pytest.mark.parametrize("max_iterations", [3]) -async def test_base_client_with_function_calling_resets(chat_client_base: ChatClientProtocol): +async def test_base_client_with_function_calling_resets(chat_client_base: SupportsChatGetResponse): exec_counter = 0 @tool(name="test_function", approval_mode="never_require") @@ -66,7 +66,7 @@ async def test_base_client_with_function_calling_resets(chat_client_base: ChatCl chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="test_function", arguments='{"arg1": "value1"}') @@ -74,14 +74,14 @@ async def test_base_client_with_function_calling_resets(chat_client_base: ChatCl ) ), ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="2", name="test_function", arguments='{"arg1": "value1"}') ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [ai_func]}) assert exec_counter == 2 @@ -97,7 +97,7 @@ async def test_base_client_with_function_calling_resets(chat_client_base: ChatCl assert response.messages[3].contents[0].type == "function_result" -async def test_base_client_with_streaming_function_calling(chat_client_base: ChatClientProtocol): +async def test_base_client_with_streaming_function_calling(chat_client_base: SupportsChatGetResponse): exec_counter = 0 @tool(name="test_function", approval_mode="never_require") @@ -137,7 +137,7 @@ async def test_base_client_with_streaming_function_calling(chat_client_base: Cha assert exec_counter == 1 -async def test_function_invocation_inside_aiohttp_server(chat_client_base: ChatClientProtocol): +async def test_function_invocation_inside_aiohttp_server(chat_client_base: SupportsChatGetResponse): import aiohttp from aiohttp import web @@ -151,7 +151,7 @@ async def test_function_invocation_inside_aiohttp_server(chat_client_base: ChatC chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call( @@ -162,14 +162,14 @@ async def test_function_invocation_inside_aiohttp_server(chat_client_base: ChatC ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] - agent = ChatAgent(chat_client=chat_client_base, tools=[ai_func]) + agent = Agent(client=chat_client_base, tools=[ai_func]) async def handler(request: web.Request) -> web.Response: - thread = agent.get_new_thread() - result = await agent.run("Fix issue", thread=thread) + session = agent.create_session() + result = await agent.run("Fix issue", session=session) return web.Response(text=result.text or "") app = web.Application() @@ -190,7 +190,7 @@ async def test_function_invocation_inside_aiohttp_server(chat_client_base: ChatC assert exec_counter == 1 -async def test_function_invocation_in_threaded_aiohttp_app(chat_client_base: ChatClientProtocol): +async def test_function_invocation_in_threaded_aiohttp_app(chat_client_base: SupportsChatGetResponse): import asyncio import threading from queue import Queue @@ -208,7 +208,7 @@ async def test_function_invocation_in_threaded_aiohttp_app(chat_client_base: Cha chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call( @@ -219,10 +219,10 @@ async def test_function_invocation_in_threaded_aiohttp_app(chat_client_base: Cha ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] - agent = ChatAgent(chat_client=chat_client_base, tools=[ai_func]) + agent = Agent(client=chat_client_base, tools=[ai_func]) ready_event = threading.Event() port_queue: Queue[int] = Queue() @@ -230,8 +230,8 @@ async def test_function_invocation_in_threaded_aiohttp_app(chat_client_base: Cha async def init_app() -> web.Application: async def handler(request: web.Request) -> web.Response: - thread = agent.get_new_thread() - result = await agent.run("Fix issue", thread=thread) + session = agent.create_session() + result = await agent.run("Fix issue", session=session) return web.Response(text=result.text or "") app = web.Application() @@ -297,7 +297,7 @@ async def test_function_invocation_in_threaded_aiohttp_app(chat_client_base: Cha ) @pytest.mark.parametrize("streaming", [False, True], ids=["non-streaming", "streaming"]) async def test_function_invocation_scenarios( - chat_client_base: ChatClientProtocol, + chat_client_base: SupportsChatGetResponse, streaming: bool, thread_type: str | None, approval_required: bool | str, @@ -339,11 +339,11 @@ async def test_function_invocation_scenarios( # Single function call content func_call = Content.from_function_call(call_id="1", name=function_name, arguments='{"arg1": "value1"}') - completion = ChatMessage(role="assistant", text="done") + completion = Message(role="assistant", text="done") - chat_client_base.run_responses = [ - ChatResponse(messages=ChatMessage(role="assistant", contents=[func_call])) - ] + ([] if approval_required else [ChatResponse(messages=completion)]) + chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=[func_call]))] + ( + [] if approval_required else [ChatResponse(messages=completion)] + ) chat_client_base.streaming_responses = [ [ @@ -371,7 +371,7 @@ async def test_function_invocation_scenarios( Content.from_function_call(call_id="2", name="approval_func", arguments='{"arg1": "value2"}'), ] - chat_client_base.run_responses = [ChatResponse(messages=ChatMessage(role="assistant", contents=func_calls))] + chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=func_calls))] chat_client_base.streaming_responses = [ [ @@ -468,7 +468,7 @@ async def test_function_invocation_scenarios( assert exec_counter == 0 # Neither function executed yet -async def test_rejected_approval(chat_client_base: ChatClientProtocol): +async def test_rejected_approval(chat_client_base: SupportsChatGetResponse): """Test that rejecting an approval alongside an approved one is handled correctly.""" exec_counter_approved = 0 @@ -489,7 +489,7 @@ async def test_rejected_approval(chat_client_base: ChatClientProtocol): # Setup: two function calls that require approval chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="approved_func", arguments='{"arg1": "value1"}'), @@ -497,7 +497,7 @@ async def test_rejected_approval(chat_client_base: ChatClientProtocol): ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] # Get the response with approval requests @@ -527,7 +527,7 @@ async def test_rejected_approval(chat_client_base: ChatClientProtocol): ) # Continue conversation with one approved and one rejected - all_messages = response.messages + [ChatMessage(role="user", contents=[approved_response, rejected_response])] + all_messages = response.messages + [Message(role="user", contents=[approved_response, rejected_response])] # Call get_response which will process the approvals await chat_client_base.get_response( @@ -564,7 +564,7 @@ async def test_rejected_approval(chat_client_base: ChatClientProtocol): assert msg.role == "tool", f"Message with FunctionResultContent must have role='tool', got '{msg.role}'" -async def test_approval_requests_in_assistant_message(chat_client_base: ChatClientProtocol): +async def test_approval_requests_in_assistant_message(chat_client_base: SupportsChatGetResponse): """Approval requests should be added to the assistant message that contains the function call.""" exec_counter = 0 @@ -576,7 +576,7 @@ async def test_approval_requests_in_assistant_message(chat_client_base: ChatClie chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="test_func", arguments='{"arg1": "value1"}'), @@ -598,7 +598,7 @@ async def test_approval_requests_in_assistant_message(chat_client_base: ChatClie assert exec_counter == 0 -async def test_persisted_approval_messages_replay_correctly(chat_client_base: ChatClientProtocol): +async def test_persisted_approval_messages_replay_correctly(chat_client_base: SupportsChatGetResponse): """Approval flow should work when messages are persisted and sent back (thread scenario).""" exec_counter = 0 @@ -611,14 +611,14 @@ async def test_persisted_approval_messages_replay_correctly(chat_client_base: Ch chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="test_func", arguments='{"arg1": "value1"}'), ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] # Get approval request @@ -628,7 +628,7 @@ async def test_persisted_approval_messages_replay_correctly(chat_client_base: Ch # Store messages (like a thread would) persisted_messages = [ - ChatMessage(role="user", text="hello"), + Message(role="user", text="hello"), *response1.messages, ] @@ -639,7 +639,7 @@ async def test_persisted_approval_messages_replay_correctly(chat_client_base: Ch function_call=approval_req.function_call, approved=True, ) - persisted_messages.append(ChatMessage(role="user", contents=[approval_response])) + persisted_messages.append(Message(role="user", contents=[approval_response])) # Continue with all persisted messages response2 = await chat_client_base.get_response( @@ -651,7 +651,7 @@ async def test_persisted_approval_messages_replay_correctly(chat_client_base: Ch assert exec_counter == 1 -async def test_no_duplicate_function_calls_after_approval_processing(chat_client_base: ChatClientProtocol): +async def test_no_duplicate_function_calls_after_approval_processing(chat_client_base: SupportsChatGetResponse): """Processing approval should not create duplicate function calls in messages.""" @tool(name="test_func", approval_mode="always_require") @@ -660,14 +660,14 @@ async def test_no_duplicate_function_calls_after_approval_processing(chat_client chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="test_func", arguments='{"arg1": "value1"}'), ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] response1 = await chat_client_base.get_response( @@ -681,7 +681,7 @@ async def test_no_duplicate_function_calls_after_approval_processing(chat_client approved=True, ) - all_messages = response1.messages + [ChatMessage(role="user", contents=[approval_response])] + all_messages = response1.messages + [Message(role="user", contents=[approval_response])] await chat_client_base.get_response(all_messages, options={"tool_choice": "auto", "tools": [func_with_approval]}) # Count function calls with the same call_id @@ -695,7 +695,7 @@ async def test_no_duplicate_function_calls_after_approval_processing(chat_client assert function_call_count == 1 -async def test_rejection_result_uses_function_call_id(chat_client_base: ChatClientProtocol): +async def test_rejection_result_uses_function_call_id(chat_client_base: SupportsChatGetResponse): """Rejection error result should use the function call's call_id, not the approval's id.""" @tool(name="test_func", approval_mode="always_require") @@ -704,14 +704,14 @@ async def test_rejection_result_uses_function_call_id(chat_client_base: ChatClie chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="call_123", name="test_func", arguments='{"arg1": "value1"}'), ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] response1 = await chat_client_base.get_response( @@ -725,7 +725,7 @@ async def test_rejection_result_uses_function_call_id(chat_client_base: ChatClie approved=False, ) - all_messages = response1.messages + [ChatMessage(role="user", contents=[rejection_response])] + all_messages = response1.messages + [Message(role="user", contents=[rejection_response])] await chat_client_base.get_response(all_messages, options={"tool_choice": "auto", "tools": [func_with_approval]}) # Find the rejection result @@ -741,7 +741,7 @@ async def test_rejection_result_uses_function_call_id(chat_client_base: ChatClie @pytest.mark.skip(reason="Failsafe behavior with max_iterations needs investigation in unified API") @pytest.mark.skip(reason="Failsafe behavior with max_iterations needs investigation in unified API") -async def test_max_iterations_limit(chat_client_base: ChatClientProtocol): +async def test_max_iterations_limit(chat_client_base: SupportsChatGetResponse): """Test that MAX_ITERATIONS in additional_properties limits function call loops.""" exec_counter = 0 @@ -754,7 +754,7 @@ async def test_max_iterations_limit(chat_client_base: ChatClientProtocol): # Set up multiple function call responses to create a loop chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="test_function", arguments='{"arg1": "value1"}') @@ -762,7 +762,7 @@ async def test_max_iterations_limit(chat_client_base: ChatClientProtocol): ) ), ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="2", name="test_function", arguments='{"arg1": "value2"}') @@ -770,7 +770,7 @@ async def test_max_iterations_limit(chat_client_base: ChatClientProtocol): ) ), # Failsafe response when tool_choice is set to "none" - ChatResponse(messages=ChatMessage(role="assistant", text="giving up on tools")), + ChatResponse(messages=Message(role="assistant", text="giving up on tools")), ] # Set max_iterations to 1 in additional_properties @@ -786,7 +786,7 @@ async def test_max_iterations_limit(chat_client_base: ChatClientProtocol): assert response.messages[-1].text == "I broke out of the function invocation loop..." # Failsafe response -async def test_function_invocation_config_enabled_false(chat_client_base: ChatClientProtocol): +async def test_function_invocation_config_enabled_false(chat_client_base: SupportsChatGetResponse): """Test that setting enabled=False disables function invocation.""" exec_counter = 0 @@ -797,7 +797,7 @@ async def test_function_invocation_config_enabled_false(chat_client_base: ChatCl return f"Processed {arg1}" chat_client_base.run_responses = [ - ChatResponse(messages=ChatMessage(role="assistant", text="response without function calling")), + ChatResponse(messages=Message(role="assistant", text="response without function calling")), ] # Disable function invocation @@ -812,7 +812,7 @@ async def test_function_invocation_config_enabled_false(chat_client_base: ChatCl @pytest.mark.skip(reason="Error handling and failsafe behavior needs investigation in unified API") -async def test_function_invocation_config_max_consecutive_errors(chat_client_base: ChatClientProtocol): +async def test_function_invocation_config_max_consecutive_errors(chat_client_base: SupportsChatGetResponse): """Test that max_consecutive_errors_per_request limits error retries.""" @tool(name="error_function", approval_mode="never_require") @@ -822,7 +822,7 @@ async def test_function_invocation_config_max_consecutive_errors(chat_client_bas # Set up multiple function call responses that will all error chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="error_function", arguments='{"arg1": "value1"}') @@ -830,7 +830,7 @@ async def test_function_invocation_config_max_consecutive_errors(chat_client_bas ) ), ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="2", name="error_function", arguments='{"arg1": "value2"}') @@ -838,7 +838,7 @@ async def test_function_invocation_config_max_consecutive_errors(chat_client_bas ) ), ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="3", name="error_function", arguments='{"arg1": "value3"}') @@ -846,14 +846,14 @@ async def test_function_invocation_config_max_consecutive_errors(chat_client_bas ) ), ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="4", name="error_function", arguments='{"arg1": "value4"}') ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="final response")), + ChatResponse(messages=Message(role="assistant", text="final response")), ] # Set max_consecutive_errors to 2 @@ -879,7 +879,7 @@ async def test_function_invocation_config_max_consecutive_errors(chat_client_bas assert len(function_calls) <= 2 -async def test_function_invocation_config_terminate_on_unknown_calls_false(chat_client_base: ChatClientProtocol): +async def test_function_invocation_config_terminate_on_unknown_calls_false(chat_client_base: SupportsChatGetResponse): """Test that terminate_on_unknown_calls=False returns error message for unknown functions.""" exec_counter = 0 @@ -891,14 +891,14 @@ async def test_function_invocation_config_terminate_on_unknown_calls_false(chat_ chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="unknown_function", arguments='{"arg1": "value1"}') ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] # Set terminate_on_unknown_calls to False (default) @@ -914,7 +914,7 @@ async def test_function_invocation_config_terminate_on_unknown_calls_false(chat_ assert exec_counter == 0 # Known function not executed -async def test_function_invocation_config_terminate_on_unknown_calls_true(chat_client_base: ChatClientProtocol): +async def test_function_invocation_config_terminate_on_unknown_calls_true(chat_client_base: SupportsChatGetResponse): """Test that terminate_on_unknown_calls=True stops execution on unknown functions.""" exec_counter = 0 @@ -926,7 +926,7 @@ async def test_function_invocation_config_terminate_on_unknown_calls_true(chat_c chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="unknown_function", arguments='{"arg1": "value1"}') @@ -945,7 +945,7 @@ async def test_function_invocation_config_terminate_on_unknown_calls_true(chat_c assert exec_counter == 0 -async def test_function_invocation_config_additional_tools(chat_client_base: ChatClientProtocol): +async def test_function_invocation_config_additional_tools(chat_client_base: SupportsChatGetResponse): """Test that additional_tools are available but treated as declaration_only.""" exec_counter_visible = 0 exec_counter_hidden = 0 @@ -964,14 +964,14 @@ async def test_function_invocation_config_additional_tools(chat_client_base: Cha chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="hidden_function", arguments='{"arg1": "value1"}') ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] # Add hidden_func to additional_tools @@ -994,7 +994,7 @@ async def test_function_invocation_config_additional_tools(chat_client_base: Cha assert len(function_calls) >= 1 -async def test_function_invocation_config_include_detailed_errors_false(chat_client_base: ChatClientProtocol): +async def test_function_invocation_config_include_detailed_errors_false(chat_client_base: SupportsChatGetResponse): """Test that include_detailed_errors=False returns generic error messages.""" @tool(name="error_function", approval_mode="never_require") @@ -1003,14 +1003,14 @@ async def test_function_invocation_config_include_detailed_errors_false(chat_cli chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="error_function", arguments='{"arg1": "value1"}') ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] # Set include_detailed_errors to False (default) @@ -1028,7 +1028,7 @@ async def test_function_invocation_config_include_detailed_errors_false(chat_cli assert "Error:" in error_result.result # Generic error prefix -async def test_function_invocation_config_include_detailed_errors_true(chat_client_base: ChatClientProtocol): +async def test_function_invocation_config_include_detailed_errors_true(chat_client_base: SupportsChatGetResponse): """Test that include_detailed_errors=True returns detailed error information.""" @tool(name="error_function", approval_mode="never_require") @@ -1037,14 +1037,14 @@ async def test_function_invocation_config_include_detailed_errors_true(chat_clie chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="error_function", arguments='{"arg1": "value1"}') ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] # Set include_detailed_errors to True @@ -1098,7 +1098,7 @@ async def test_function_invocation_config_validation_max_consecutive_errors(): normalize_function_invocation_configuration({"max_consecutive_errors_per_request": -1}) -async def test_argument_validation_error_with_detailed_errors(chat_client_base: ChatClientProtocol): +async def test_argument_validation_error_with_detailed_errors(chat_client_base: SupportsChatGetResponse): """Test that argument validation errors include details when include_detailed_errors=True.""" @tool(name="typed_function", approval_mode="never_require") @@ -1107,14 +1107,14 @@ async def test_argument_validation_error_with_detailed_errors(chat_client_base: chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="typed_function", arguments='{"arg1": "not_an_int"}') ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] # Set include_detailed_errors to True @@ -1132,7 +1132,7 @@ async def test_argument_validation_error_with_detailed_errors(chat_client_base: assert "Exception:" in error_result.result # Detailed error included -async def test_argument_validation_error_without_detailed_errors(chat_client_base: ChatClientProtocol): +async def test_argument_validation_error_without_detailed_errors(chat_client_base: SupportsChatGetResponse): """Test that argument validation errors are generic when include_detailed_errors=False.""" @tool(name="typed_function", approval_mode="never_require") @@ -1141,14 +1141,14 @@ async def test_argument_validation_error_without_detailed_errors(chat_client_bas chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="typed_function", arguments='{"arg1": "not_an_int"}') ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] # Set include_detailed_errors to False (default) @@ -1166,7 +1166,7 @@ async def test_argument_validation_error_without_detailed_errors(chat_client_bas assert "Exception:" not in error_result.result # No detailed error -async def test_hosted_tool_approval_response(chat_client_base: ChatClientProtocol): +async def test_hosted_tool_approval_response(chat_client_base: SupportsChatGetResponse): """Test handling of approval responses for hosted tools (tools not in tool_map).""" @tool(name="local_function") @@ -1184,12 +1184,12 @@ async def test_hosted_tool_approval_response(chat_client_base: ChatClientProtoco ) chat_client_base.run_responses = [ - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] # Send the approval response response = await chat_client_base.get_response( - [ChatMessage(role="user", contents=[approval_response])], + [Message(role="user", contents=[approval_response])], tool_choice="auto", tools=[local_func], ) @@ -1199,7 +1199,7 @@ async def test_hosted_tool_approval_response(chat_client_base: ChatClientProtoco assert response is not None -async def test_unapproved_tool_execution_raises_exception(chat_client_base: ChatClientProtocol): +async def test_unapproved_tool_execution_raises_exception(chat_client_base: SupportsChatGetResponse): """Test that attempting to execute an unapproved tool raises ToolException.""" @tool(name="test_function", approval_mode="always_require") @@ -1208,14 +1208,14 @@ async def test_unapproved_tool_execution_raises_exception(chat_client_base: Chat chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="test_function", arguments='{"arg1": "value1"}'), ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] # Get approval request @@ -1231,7 +1231,7 @@ async def test_unapproved_tool_execution_raises_exception(chat_client_base: Chat ) # Continue conversation with rejection - all_messages = response1.messages + [ChatMessage(role="user", contents=[rejection_response])] + all_messages = response1.messages + [Message(role="user", contents=[rejection_response])] # This should handle the rejection gracefully (not raise ToolException to user) await chat_client_base.get_response(all_messages, options={"tool_choice": "auto", "tools": [test_func]}) @@ -1249,7 +1249,7 @@ async def test_unapproved_tool_execution_raises_exception(chat_client_base: Chat assert rejection_result is not None -async def test_approved_function_call_with_error_without_detailed_errors(chat_client_base: ChatClientProtocol): +async def test_approved_function_call_with_error_without_detailed_errors(chat_client_base: SupportsChatGetResponse): """Test that approved functions that raise errors return generic error messages. When include_detailed_errors=False. @@ -1265,12 +1265,12 @@ async def test_approved_function_call_with_error_without_detailed_errors(chat_cl chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[Content.from_function_call(call_id="1", name="error_func", arguments='{"arg1": "value1"}')], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] # Set include_detailed_errors to False (default) @@ -1288,7 +1288,7 @@ async def test_approved_function_call_with_error_without_detailed_errors(chat_cl approved=True, ) - all_messages = response1.messages + [ChatMessage(role="user", contents=[approval_response])] + all_messages = response1.messages + [Message(role="user", contents=[approval_response])] # Execute the approved function (which will error) await chat_client_base.get_response(all_messages, options={"tool_choice": "auto", "tools": [error_func]}) @@ -1312,7 +1312,7 @@ async def test_approved_function_call_with_error_without_detailed_errors(chat_cl assert "Specific error from approved function" not in error_result.result # Detail not included -async def test_approved_function_call_with_error_with_detailed_errors(chat_client_base: ChatClientProtocol): +async def test_approved_function_call_with_error_with_detailed_errors(chat_client_base: SupportsChatGetResponse): """Test that approved functions that raise errors return detailed error messages. When include_detailed_errors=True. @@ -1328,12 +1328,12 @@ async def test_approved_function_call_with_error_with_detailed_errors(chat_clien chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[Content.from_function_call(call_id="1", name="error_func", arguments='{"arg1": "value1"}')], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] # Set include_detailed_errors to True @@ -1351,7 +1351,7 @@ async def test_approved_function_call_with_error_with_detailed_errors(chat_clien approved=True, ) - all_messages = response1.messages + [ChatMessage(role="user", contents=[approval_response])] + all_messages = response1.messages + [Message(role="user", contents=[approval_response])] # Execute the approved function (which will error) await chat_client_base.get_response(all_messages, options={"tool_choice": "auto", "tools": [error_func]}) @@ -1376,7 +1376,7 @@ async def test_approved_function_call_with_error_with_detailed_errors(chat_clien assert "Specific error from approved function" in error_result.result # Detail included -async def test_approved_function_call_with_validation_error(chat_client_base: ChatClientProtocol): +async def test_approved_function_call_with_validation_error(chat_client_base: SupportsChatGetResponse): """Test that approved functions with validation errors are handled correctly.""" exec_counter = 0 @@ -1389,14 +1389,14 @@ async def test_approved_function_call_with_validation_error(chat_client_base: Ch chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="typed_func", arguments='{"arg1": "not_an_int"}') ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] # Set include_detailed_errors to True to see validation details @@ -1414,7 +1414,7 @@ async def test_approved_function_call_with_validation_error(chat_client_base: Ch approved=True, ) - all_messages = response1.messages + [ChatMessage(role="user", contents=[approval_response])] + all_messages = response1.messages + [Message(role="user", contents=[approval_response])] # Execute the approved function (which will fail validation) await chat_client_base.get_response(all_messages, options={"tool_choice": "auto", "tools": [typed_func]}) @@ -1437,7 +1437,7 @@ async def test_approved_function_call_with_validation_error(chat_client_base: Ch assert "Argument parsing failed" in error_result.result -async def test_approved_function_call_successful_execution(chat_client_base: ChatClientProtocol): +async def test_approved_function_call_successful_execution(chat_client_base: SupportsChatGetResponse): """Test that approved functions execute successfully when no errors occur.""" exec_counter = 0 @@ -1450,12 +1450,12 @@ async def test_approved_function_call_successful_execution(chat_client_base: Cha chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[Content.from_function_call(call_id="1", name="success_func", arguments='{"arg1": "value1"}')], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] # Get approval request @@ -1470,7 +1470,7 @@ async def test_approved_function_call_successful_execution(chat_client_base: Cha approved=True, ) - all_messages = response1.messages + [ChatMessage(role="user", contents=[approval_response])] + all_messages = response1.messages + [Message(role="user", contents=[approval_response])] # Execute the approved function await chat_client_base.get_response(all_messages, options={"tool_choice": "auto", "tools": [success_func]}) @@ -1492,7 +1492,7 @@ async def test_approved_function_call_successful_execution(chat_client_base: Cha assert success_result.result == "Success value1" -async def test_declaration_only_tool(chat_client_base: ChatClientProtocol): +async def test_declaration_only_tool(chat_client_base: SupportsChatGetResponse): """Test that declaration_only tools without implementation (func=None) are not executed.""" from agent_framework import FunctionTool @@ -1509,14 +1509,14 @@ async def test_declaration_only_tool(chat_client_base: ChatClientProtocol): chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="declaration_func", arguments='{"arg1": "value1"}') ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] response = await chat_client_base.get_response( @@ -1542,7 +1542,7 @@ async def test_declaration_only_tool(chat_client_base: ChatClientProtocol): assert len(function_results) == 0 -async def test_multiple_function_calls_parallel_execution(chat_client_base: ChatClientProtocol): +async def test_multiple_function_calls_parallel_execution(chat_client_base: SupportsChatGetResponse): """Test that multiple function calls are executed in parallel.""" import asyncio @@ -1564,7 +1564,7 @@ async def test_multiple_function_calls_parallel_execution(chat_client_base: Chat chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="func1", arguments='{"arg1": "value1"}'), @@ -1572,7 +1572,7 @@ async def test_multiple_function_calls_parallel_execution(chat_client_base: Chat ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [func1, func2]}) @@ -1588,7 +1588,7 @@ async def test_multiple_function_calls_parallel_execution(chat_client_base: Chat assert len(results) == 2 -async def test_callable_function_converted_to_tool(chat_client_base: ChatClientProtocol): +async def test_callable_function_converted_to_tool(chat_client_base: SupportsChatGetResponse): """Test that plain callable functions are converted to FunctionTool.""" exec_counter = 0 @@ -1601,14 +1601,14 @@ async def test_callable_function_converted_to_tool(chat_client_base: ChatClientP chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="plain_function", arguments='{"arg1": "value1"}') ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] # Pass plain function (will be auto-converted) @@ -1620,7 +1620,7 @@ async def test_callable_function_converted_to_tool(chat_client_base: ChatClientP assert result.result == "Plain value1" -async def test_conversation_id_handling(chat_client_base: ChatClientProtocol): +async def test_conversation_id_handling(chat_client_base: SupportsChatGetResponse): """Test that conversation_id is properly handled and messages are cleared.""" @tool(name="test_function", approval_mode="never_require") @@ -1630,7 +1630,7 @@ async def test_conversation_id_handling(chat_client_base: ChatClientProtocol): # Return a response with a conversation_id chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="test_function", arguments='{"arg1": "value1"}') @@ -1639,7 +1639,7 @@ async def test_conversation_id_handling(chat_client_base: ChatClientProtocol): conversation_id="conv_123", # Simulate service-side thread ), ChatResponse( - messages=ChatMessage(role="assistant", text="done"), + messages=Message(role="assistant", text="done"), conversation_id="conv_123", ), ] @@ -1652,7 +1652,7 @@ async def test_conversation_id_handling(chat_client_base: ChatClientProtocol): assert response.conversation_id == "conv_123" -async def test_function_result_appended_to_existing_assistant_message(chat_client_base: ChatClientProtocol): +async def test_function_result_appended_to_existing_assistant_message(chat_client_base: SupportsChatGetResponse): """Test that function results are appended to existing assistant message when appropriate.""" @tool(name="test_function", approval_mode="never_require") @@ -1661,14 +1661,14 @@ async def test_function_result_appended_to_existing_assistant_message(chat_clien chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="test_function", arguments='{"arg1": "value1"}') ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [test_func]}) @@ -1683,7 +1683,7 @@ async def test_function_result_appended_to_existing_assistant_message(chat_clien @pytest.mark.parametrize("max_iterations", [3]) -async def test_error_recovery_resets_counter(chat_client_base: ChatClientProtocol): +async def test_error_recovery_resets_counter(chat_client_base: SupportsChatGetResponse): """Test that error counter resets after a successful function call.""" call_count = 0 @@ -1698,7 +1698,7 @@ async def test_error_recovery_resets_counter(chat_client_base: ChatClientProtoco chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="sometimes_fails", arguments='{"arg1": "value1"}') @@ -1706,14 +1706,14 @@ async def test_error_recovery_resets_counter(chat_client_base: ChatClientProtoco ) ), ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="2", name="sometimes_fails", arguments='{"arg1": "value2"}') ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [sometimes_fails]}) @@ -1740,7 +1740,7 @@ async def test_error_recovery_resets_counter(chat_client_base: ChatClientProtoco # ==================== STREAMING SCENARIO TESTS ==================== -async def test_streaming_approval_request_generated(chat_client_base: ChatClientProtocol): +async def test_streaming_approval_request_generated(chat_client_base: SupportsChatGetResponse): """Test that approval requests are generated correctly in streaming mode.""" exec_counter = 0 @@ -1777,7 +1777,7 @@ async def test_streaming_approval_request_generated(chat_client_base: ChatClient @pytest.mark.skip(reason="Failsafe behavior with max_iterations needs investigation in unified API") -async def test_streaming_max_iterations_limit(chat_client_base: ChatClientProtocol): +async def test_streaming_max_iterations_limit(chat_client_base: SupportsChatGetResponse): """Test that MAX_ITERATIONS in streaming mode limits function call loops.""" exec_counter = 0 @@ -1829,7 +1829,7 @@ async def test_streaming_max_iterations_limit(chat_client_base: ChatClientProtoc assert "I broke out of the function invocation loop..." in last_text -async def test_streaming_function_invocation_config_enabled_false(chat_client_base: ChatClientProtocol): +async def test_streaming_function_invocation_config_enabled_false(chat_client_base: SupportsChatGetResponse): """Test that setting enabled=False disables function invocation in streaming mode.""" exec_counter = 0 @@ -1858,7 +1858,7 @@ async def test_streaming_function_invocation_config_enabled_false(chat_client_ba assert len(updates) > 0 -async def test_streaming_function_invocation_config_max_consecutive_errors(chat_client_base: ChatClientProtocol): +async def test_streaming_function_invocation_config_max_consecutive_errors(chat_client_base: SupportsChatGetResponse): """Test that max_consecutive_errors_per_request limits error retries in streaming mode.""" @tool(name="error_function", approval_mode="never_require") @@ -1919,7 +1919,7 @@ async def test_streaming_function_invocation_config_max_consecutive_errors(chat_ async def test_streaming_function_invocation_config_terminate_on_unknown_calls_false( - chat_client_base: ChatClientProtocol, + chat_client_base: SupportsChatGetResponse, ): """Test that terminate_on_unknown_calls=False returns error message for unknown functions in streaming mode.""" exec_counter = 0 @@ -1963,7 +1963,7 @@ async def test_streaming_function_invocation_config_terminate_on_unknown_calls_f @pytest.mark.skip(reason="Failsafe behavior needs investigation in unified API") async def test_streaming_function_invocation_config_terminate_on_unknown_calls_true( - chat_client_base: ChatClientProtocol, + chat_client_base: SupportsChatGetResponse, ): """Test that terminate_on_unknown_calls=True stops execution on unknown functions in streaming mode.""" exec_counter = 0 @@ -1996,7 +1996,9 @@ async def test_streaming_function_invocation_config_terminate_on_unknown_calls_t assert exec_counter == 0 -async def test_streaming_function_invocation_config_include_detailed_errors_true(chat_client_base: ChatClientProtocol): +async def test_streaming_function_invocation_config_include_detailed_errors_true( + chat_client_base: SupportsChatGetResponse, +): """Test that include_detailed_errors=True returns detailed error information in streaming mode.""" @tool(name="error_function", approval_mode="never_require") @@ -2035,7 +2037,7 @@ async def test_streaming_function_invocation_config_include_detailed_errors_true async def test_streaming_function_invocation_config_include_detailed_errors_false( - chat_client_base: ChatClientProtocol, + chat_client_base: SupportsChatGetResponse, ): """Test that include_detailed_errors=False returns generic error messages in streaming mode.""" @@ -2074,7 +2076,7 @@ async def test_streaming_function_invocation_config_include_detailed_errors_fals assert "Error:" in error_result.result # Generic error prefix -async def test_streaming_argument_validation_error_with_detailed_errors(chat_client_base: ChatClientProtocol): +async def test_streaming_argument_validation_error_with_detailed_errors(chat_client_base: SupportsChatGetResponse): """Test that argument validation errors include details when include_detailed_errors=True in streaming mode.""" @tool(name="typed_function", approval_mode="never_require") @@ -2112,7 +2114,7 @@ async def test_streaming_argument_validation_error_with_detailed_errors(chat_cli assert "Exception:" in error_result.result # Detailed error included -async def test_streaming_argument_validation_error_without_detailed_errors(chat_client_base: ChatClientProtocol): +async def test_streaming_argument_validation_error_without_detailed_errors(chat_client_base: SupportsChatGetResponse): """Test that argument validation errors are generic when include_detailed_errors=False in streaming mode.""" @tool(name="typed_function", approval_mode="never_require") @@ -2150,7 +2152,7 @@ async def test_streaming_argument_validation_error_without_detailed_errors(chat_ assert "Exception:" not in error_result.result # No detailed error -async def test_streaming_multiple_function_calls_parallel_execution(chat_client_base: ChatClientProtocol): +async def test_streaming_multiple_function_calls_parallel_execution(chat_client_base: SupportsChatGetResponse): """Test that multiple function calls are executed in parallel in streaming mode.""" exec_order = [] @@ -2200,7 +2202,7 @@ async def test_streaming_multiple_function_calls_parallel_execution(chat_client_ assert len(results) == 2 -async def test_streaming_approval_requests_in_assistant_message(chat_client_base: ChatClientProtocol): +async def test_streaming_approval_requests_in_assistant_message(chat_client_base: SupportsChatGetResponse): """Approval requests should be added to assistant updates in streaming mode.""" exec_counter = 0 @@ -2235,7 +2237,7 @@ async def test_streaming_approval_requests_in_assistant_message(chat_client_base assert exec_counter == 0 -async def test_streaming_error_recovery_resets_counter(chat_client_base: ChatClientProtocol): +async def test_streaming_error_recovery_resets_counter(chat_client_base: SupportsChatGetResponse): """Test that error counter resets after a successful function call in streaming mode.""" call_count = 0 @@ -2296,15 +2298,13 @@ async def test_streaming_error_recovery_resets_counter(chat_client_base: ChatCli class TerminateLoopMiddleware(FunctionMiddleware): """Middleware that raises MiddlewareTermination to exit the function calling loop.""" - async def process( - self, context: FunctionInvocationContext, next_handler: Callable[[FunctionInvocationContext], Awaitable[None]] - ) -> None: + async def process(self, context: FunctionInvocationContext, next_handler: Callable[[], Awaitable[None]]) -> None: # Set result to a simple value - the framework will wrap it in FunctionResultContent context.result = "terminated by middleware" raise MiddlewareTermination -async def test_terminate_loop_single_function_call(chat_client_base: ChatClientProtocol): +async def test_terminate_loop_single_function_call(chat_client_base: SupportsChatGetResponse): """Test that terminate_loop=True exits the function calling loop after single function call.""" exec_counter = 0 @@ -2318,14 +2318,14 @@ async def test_terminate_loop_single_function_call(chat_client_base: ChatClientP # If terminate_loop works, only the first response should be consumed chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="test_function", arguments='{"arg1": "value1"}') ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] response = await chat_client_base.get_response( @@ -2353,17 +2353,15 @@ async def test_terminate_loop_single_function_call(chat_client_base: ChatClientP class SelectiveTerminateMiddleware(FunctionMiddleware): """Only terminates for terminating_function.""" - async def process( - self, context: FunctionInvocationContext, next_handler: Callable[[FunctionInvocationContext], Awaitable[None]] - ) -> None: + async def process(self, context: FunctionInvocationContext, next_handler: Callable[[], Awaitable[None]]) -> None: if context.function.name == "terminating_function": # Set result to a simple value - the framework will wrap it in FunctionResultContent context.result = "terminated by middleware" raise MiddlewareTermination - await next_handler(context) + await next_handler() -async def test_terminate_loop_multiple_function_calls_one_terminates(chat_client_base: ChatClientProtocol): +async def test_terminate_loop_multiple_function_calls_one_terminates(chat_client_base: SupportsChatGetResponse): """Test that any(terminate_loop=True) exits loop even with multiple function calls.""" normal_call_count = 0 terminating_call_count = 0 @@ -2383,7 +2381,7 @@ async def test_terminate_loop_multiple_function_calls_one_terminates(chat_client # Queue up two responses: parallel function calls, then final text chat_client_base.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[ Content.from_function_call(call_id="1", name="normal_function", arguments='{"arg1": "value1"}'), @@ -2393,7 +2391,7 @@ async def test_terminate_loop_multiple_function_calls_one_terminates(chat_client ], ) ), - ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ChatResponse(messages=Message(role="assistant", text="done")), ] response = await chat_client_base.get_response( @@ -2420,7 +2418,7 @@ async def test_terminate_loop_multiple_function_calls_one_terminates(chat_client assert len(chat_client_base.run_responses) == 1 -async def test_terminate_loop_streaming_single_function_call(chat_client_base: ChatClientProtocol): +async def test_terminate_loop_streaming_single_function_call(chat_client_base: SupportsChatGetResponse): """Test that terminate_loop=True exits the streaming function calling loop.""" exec_counter = 0 @@ -2482,10 +2480,10 @@ async def test_conversation_id_updated_in_options_between_tool_iterations(): from agent_framework import ( BaseChatClient, - ChatMessage, ChatResponse, ChatResponseUpdate, Content, + Message, ResponseStream, tool, ) @@ -2509,7 +2507,7 @@ async def test_conversation_id_updated_in_options_between_tool_iterations(): def _inner_get_response( self, *, - messages: MutableSequence[ChatMessage], + messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any, @@ -2523,7 +2521,7 @@ async def test_conversation_id_updated_in_options_between_tool_iterations(): async def _get() -> ChatResponse: self.call_count += 1 if not self.run_responses: - return ChatResponse(messages=ChatMessage(role="assistant", text="done")) + return ChatResponse(messages=Message(role="assistant", text="done")) return self.run_responses.pop(0) return _get() @@ -2531,7 +2529,7 @@ async def test_conversation_id_updated_in_options_between_tool_iterations(): def _get_streaming_response( self, *, - messages: MutableSequence[ChatMessage], + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse]: @@ -2563,14 +2561,14 @@ async def test_conversation_id_updated_in_options_between_tool_iterations(): # Second response (after tool execution) should receive the updated conversation_id client.run_responses = [ ChatResponse( - messages=ChatMessage( + messages=Message( role="assistant", contents=[Content.from_function_call(call_id="call_1", name="test_func", arguments='{"arg1": "v1"}')], ), conversation_id="conv_after_first_call", ), ChatResponse( - messages=ChatMessage(role="assistant", text="done"), + messages=Message(role="assistant", text="done"), conversation_id="conv_after_second_call", ), ] diff --git a/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py b/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py index cbbd4b69f7..cecd466d86 100644 --- a/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py +++ b/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py @@ -7,12 +7,12 @@ from typing import Any from agent_framework import ( BaseChatClient, - ChatMessage, ChatMiddlewareLayer, ChatResponse, ChatResponseUpdate, Content, FunctionInvocationLayer, + Message, ResponseStream, tool, ) @@ -31,7 +31,7 @@ class _MockBaseChatClient(BaseChatClient[Any]): def _inner_get_response( self, *, - messages: MutableSequence[ChatMessage], + messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any, @@ -47,19 +47,19 @@ class _MockBaseChatClient(BaseChatClient[Any]): async def _get_non_streaming_response( self, *, - messages: MutableSequence[ChatMessage], + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any, ) -> ChatResponse: self.call_count += 1 if self.run_responses: return self.run_responses.pop(0) - return ChatResponse(messages=ChatMessage(role="assistant", text="default response")) + return ChatResponse(messages=Message(role="assistant", text="default response")) def _get_streaming_response( self, *, - messages: MutableSequence[ChatMessage], + messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse]: @@ -110,7 +110,7 @@ class TestKwargsPropagationToFunctionTool: # First response: function call ChatResponse( messages=[ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call( @@ -121,11 +121,11 @@ class TestKwargsPropagationToFunctionTool: ] ), # Second response: final answer - ChatResponse(messages=[ChatMessage(role="assistant", text="Done!")]), + ChatResponse(messages=[Message(role="assistant", text="Done!")]), ] result = await client.get_response( - messages=[ChatMessage(role="user", text="Test")], + messages=[Message(role="user", text="Test")], stream=False, options={ "tools": [capture_kwargs_tool], @@ -159,7 +159,7 @@ class TestKwargsPropagationToFunctionTool: client.run_responses = [ ChatResponse( messages=[ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call(call_id="call_1", name="simple_tool", arguments='{"x": 99}') @@ -167,12 +167,12 @@ class TestKwargsPropagationToFunctionTool: ) ] ), - ChatResponse(messages=[ChatMessage(role="assistant", text="Completed!")]), + ChatResponse(messages=[Message(role="assistant", text="Completed!")]), ] # Call with additional_function_arguments - the tool should work but not receive them result = await client.get_response( - messages=[ChatMessage(role="user", text="Test")], + messages=[Message(role="user", text="Test")], stream=False, options={ "tools": [simple_tool], @@ -198,7 +198,7 @@ class TestKwargsPropagationToFunctionTool: # Two function calls in one response ChatResponse( messages=[ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call( @@ -211,11 +211,11 @@ class TestKwargsPropagationToFunctionTool: ) ] ), - ChatResponse(messages=[ChatMessage(role="assistant", text="All done!")]), + ChatResponse(messages=[Message(role="assistant", text="All done!")]), ] result = await client.get_response( - messages=[ChatMessage(role="user", text="Test")], + messages=[Message(role="user", text="Test")], stream=False, options={ "tools": [tracking_tool], @@ -270,7 +270,7 @@ class TestKwargsPropagationToFunctionTool: # Collect streaming updates updates: list[ChatResponseUpdate] = [] stream = client.get_response( - messages=[ChatMessage(role="user", text="Test")], + messages=[Message(role="user", text="Test")], stream=True, options={ "tools": [streaming_capture_tool], diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 7695affb5a..38cb243412 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -13,12 +13,11 @@ from mcp.shared.exceptions import McpError from pydantic import AnyUrl, BaseModel, ValidationError from agent_framework import ( - ChatMessage, Content, MCPStdioTool, MCPStreamableHTTPTool, MCPWebsocketTool, - ToolProtocol, + Message, ) from agent_framework._mcp import ( MCPTool, @@ -26,8 +25,8 @@ from agent_framework._mcp import ( _get_input_model_from_mcp_tool, _normalize_mcp_name, _parse_content_from_mcp, - _parse_contents_from_mcp_tool_result, _parse_message_from_mcp, + _parse_tool_result_from_mcp, _prepare_content_for_mcp, _prepare_message_for_mcp, logger, @@ -61,7 +60,7 @@ def test_mcp_prompt_message_to_ai_content(): mcp_message = types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hello, world!")) ai_content = _parse_message_from_mcp(mcp_message) - assert isinstance(ai_content, ChatMessage) + assert isinstance(ai_content, Message) assert ai_content.role == "user" assert len(ai_content.contents) == 1 assert ai_content.contents[0].type == "text" @@ -69,144 +68,58 @@ def test_mcp_prompt_message_to_ai_content(): assert ai_content.raw_representation == mcp_message -def test_parse_contents_from_mcp_tool_result(): - """Test conversion from MCP tool result to AI contents.""" +def test_parse_tool_result_from_mcp(): + """Test conversion from MCP tool result to string representation.""" mcp_result = types.CallToolResult( content=[ types.TextContent(type="text", text="Result text"), - types.ImageContent(type="image", data="eHl6", mimeType="image/png"), # base64 for "xyz" - types.ImageContent(type="image", data="YWJj", mimeType="image/webp"), # base64 for "abc" + types.ImageContent(type="image", data="eHl6", mimeType="image/png"), + types.ImageContent(type="image", data="YWJj", mimeType="image/webp"), ] ) - ai_contents = _parse_contents_from_mcp_tool_result(mcp_result) + result = _parse_tool_result_from_mcp(mcp_result) - assert len(ai_contents) == 3 - assert ai_contents[0].type == "text" - assert ai_contents[0].text == "Result text" - assert ai_contents[1].type == "data" - assert ai_contents[1].uri == "data:image/png;base64,eHl6" - assert ai_contents[1].media_type == "image/png" - assert ai_contents[2].type == "data" - assert ai_contents[2].uri == "data:image/webp;base64,YWJj" - assert ai_contents[2].media_type == "image/webp" + # Multiple items produce a JSON array of strings + assert isinstance(result, str) + import json + + parsed = json.loads(result) + assert len(parsed) == 3 + assert parsed[0] == "Result text" + # Image items are JSON-encoded strings within the array + img1 = json.loads(parsed[1]) + assert img1["type"] == "image" + assert img1["data"] == "eHl6" + img2 = json.loads(parsed[2]) + assert img2["type"] == "image" + assert img2["data"] == "YWJj" -def test_mcp_call_tool_result_with_meta_error(): - """Test conversion from MCP tool result with _meta field containing isError=True.""" - # Create a mock CallToolResult with _meta field containing error information +def test_parse_tool_result_from_mcp_single_text(): + """Test conversion from MCP tool result with a single text item.""" + mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="Simple result")]) + result = _parse_tool_result_from_mcp(mcp_result) + + # Single text item returns just the text + assert result == "Simple result" + + +def test_parse_tool_result_from_mcp_meta_not_in_string(): + """Test that _meta data is not included in the string result (it's tool-level, not content-level).""" mcp_result = types.CallToolResult( content=[types.TextContent(type="text", text="Error occurred")], - _meta={"isError": True, "errorCode": "TOOL_ERROR", "errorMessage": "Tool execution failed"}, + _meta={"isError": True, "errorCode": "TOOL_ERROR"}, ) - ai_contents = _parse_contents_from_mcp_tool_result(mcp_result) - - assert len(ai_contents) == 1 - assert ai_contents[0].type == "text" - assert ai_contents[0].text == "Error occurred" - - # Check that _meta data is merged into additional_properties - assert ai_contents[0].additional_properties is not None - assert ai_contents[0].additional_properties["isError"] is True - assert ai_contents[0].additional_properties["errorCode"] == "TOOL_ERROR" - assert ai_contents[0].additional_properties["errorMessage"] == "Tool execution failed" + result = _parse_tool_result_from_mcp(mcp_result) + assert result == "Error occurred" -def test_mcp_call_tool_result_with_meta_arbitrary_data(): - """Test conversion from MCP tool result with _meta field containing arbitrary metadata. - - Note: The _meta field is optional and can contain any structure that a specific - MCP server chooses to provide. This test uses example metadata to verify that - whatever is provided gets preserved in additional_properties. - """ - mcp_result = types.CallToolResult( - content=[types.TextContent(type="text", text="Success result")], - _meta={ - "serverVersion": "2.1.0", - "executionId": "exec_abc123", - "metrics": {"responseTime": 1.25, "memoryUsed": "64MB"}, - "source": "example-mcp-server", - "customField": "arbitrary_value", - }, - ) - - ai_contents = _parse_contents_from_mcp_tool_result(mcp_result) - - assert len(ai_contents) == 1 - assert ai_contents[0].type == "text" - assert ai_contents[0].text == "Success result" - - # Check that _meta data is preserved in additional_properties - props = ai_contents[0].additional_properties - assert props is not None - assert props["serverVersion"] == "2.1.0" - assert props["executionId"] == "exec_abc123" - assert props["metrics"] == {"responseTime": 1.25, "memoryUsed": "64MB"} - assert props["source"] == "example-mcp-server" - assert props["customField"] == "arbitrary_value" - - -def test_mcp_call_tool_result_with_meta_merging_existing_properties(): - """Test that _meta data merges correctly with existing additional_properties.""" - # Create content with existing additional_properties - text_content = types.TextContent(type="text", text="Test content") - mcp_result = types.CallToolResult(content=[text_content], _meta={"newField": "newValue", "isError": False}) - - ai_contents = _parse_contents_from_mcp_tool_result(mcp_result) - - assert len(ai_contents) == 1 - content = ai_contents[0] - - # Check that _meta data is present in additional_properties - assert content.additional_properties is not None - assert content.additional_properties["newField"] == "newValue" - assert content.additional_properties["isError"] is False - - -def test_mcp_call_tool_result_with_meta_none(): - """Test that missing _meta field is handled gracefully.""" - mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="No meta test")]) - # No _meta field set - - ai_contents = _parse_contents_from_mcp_tool_result(mcp_result) - - assert len(ai_contents) == 1 - assert ai_contents[0].type == "text" - assert ai_contents[0].text == "No meta test" - - # Should handle gracefully when no _meta field exists - # additional_properties may be None or empty dict - props = ai_contents[0].additional_properties - assert props is None or props == {} - - -def test_mcp_call_tool_result_regression_successful_workflow(): - """Regression test to ensure existing successful workflows remain unchanged.""" - # Test the original successful workflow still works - mcp_result = types.CallToolResult( - content=[ - types.TextContent(type="text", text="Success message"), - types.ImageContent(type="image", data="YWJjMTIz", mimeType="image/jpeg"), # base64 for "abc123" - ] - ) - - ai_contents = _parse_contents_from_mcp_tool_result(mcp_result) - - # Verify basic conversion still works correctly - assert len(ai_contents) == 2 - - text_content = ai_contents[0] - assert text_content.type == "text" - assert text_content.text == "Success message" - - image_content = ai_contents[1] - assert image_content.type == "data" - assert image_content.uri == "data:image/jpeg;base64,YWJjMTIz" - assert image_content.media_type == "image/jpeg" - - # Should have no additional_properties when no _meta field - assert text_content.additional_properties is None or text_content.additional_properties == {} - assert image_content.additional_properties is None or image_content.additional_properties == {} +def test_parse_tool_result_from_mcp_empty_content(): + """Test that empty content produces empty string.""" + mcp_result = types.CallToolResult(content=[]) + result = _parse_tool_result_from_mcp(mcp_result) + assert result == "" def test_mcp_content_types_to_ai_content_text(): @@ -349,7 +262,7 @@ def test_ai_content_to_mcp_content_types_uri(): def test_prepare_message_for_mcp(): - message = ChatMessage( + message = Message( role="user", contents=[ Content.from_text(text="test"), @@ -744,7 +657,10 @@ def test_get_input_model_from_mcp_prompt(): async def test_local_mcp_server_initialization(): """Test MCPTool initialization.""" server = MCPTool(name="test_server") - assert isinstance(server, ToolProtocol) + # MCPTool has the same core attributes as FunctionTool + assert hasattr(server, "name") + assert hasattr(server, "description") + assert hasattr(server, "additional_properties") assert server.name == "test_server" assert server.session is None assert server.functions == [] @@ -795,7 +711,9 @@ async def test_local_mcp_server_load_functions(): return None server = TestServer(name="test_server") - assert isinstance(server, ToolProtocol) + # MCPTool has the same core attributes as FunctionTool + assert hasattr(server, "name") + assert hasattr(server, "description") async with server: await server.load_tools() assert len(server.functions) == 1 @@ -870,17 +788,7 @@ async def test_mcp_tool_call_tool_with_meta_integration(): func = server.functions[0] result = await func.invoke(param="test_value") - assert len(result) == 1 - assert result[0].type == "text" - assert result[0].text == "Tool executed with metadata" - - # Verify that _meta data is present in additional_properties - props = result[0].additional_properties - assert props is not None - assert props["executionTime"] == 1.5 - assert props["cost"] == {"usd": 0.002} - assert props["isError"] is False - assert props["toolVersion"] == "1.2.3" + assert result == "Tool executed with metadata" async def test_local_mcp_server_function_execution(): @@ -919,9 +827,7 @@ async def test_local_mcp_server_function_execution(): func = server.functions[0] result = await func.invoke(param="test_value") - assert len(result) == 1 - assert result[0].type == "text" - assert result[0].text == "Tool executed successfully" + assert result == "Tool executed successfully" async def test_local_mcp_server_function_execution_with_nested_object(): @@ -968,8 +874,7 @@ async def test_local_mcp_server_function_execution_with_nested_object(): # Call with nested object result = await func.invoke(params={"customer_id": 251}) - assert len(result) == 1 - assert result[0].type == "text" + assert result == '{"name": "John Doe", "id": 251}' # Verify the session.call_tool was called with the correct nested structure server.session.call_tool.assert_called_once() @@ -1053,11 +958,7 @@ async def test_local_mcp_server_prompt_execution(): prompt = server.functions[0] result = await prompt.invoke(arg="test_value") - assert len(result) == 1 - assert isinstance(result[0], ChatMessage) - assert result[0].role == "user" - assert len(result[0].contents) == 1 - assert result[0].contents[0].text == "Test message" + assert result == "Test message" @pytest.mark.parametrize( @@ -1245,7 +1146,8 @@ async def test_streamable_http_integration(): assert hasattr(func, "description") result = await func.invoke(query="What is Agent Framework?") - assert result[0].text is not None + assert isinstance(result, str) + assert len(result) > 0 @pytest.mark.flaky @@ -1310,11 +1212,11 @@ async def test_mcp_connection_reset_integration(): # Verify tools are still available after reconnection assert len(tool.functions) > 0 - # Both results should be valid (we don't compare content as it may vary) - if hasattr(first_result[0], "text"): - assert first_result[0].text is not None - if hasattr(second_result[0], "text"): - assert second_result[0].text is not None + # Both results should be valid strings (we don't compare content as it may vary) + assert isinstance(first_result, str) + assert len(first_result) > 0 + assert isinstance(second_result, str) + assert len(second_result) > 0 async def test_mcp_tool_message_handler_notification(): @@ -1391,7 +1293,7 @@ async def test_mcp_tool_sampling_callback_chat_client_exception(): mock_chat_client = AsyncMock() mock_chat_client.get_response.side_effect = RuntimeError("Chat client error") - tool.chat_client = mock_chat_client + tool.client = mock_chat_client # Create mock params params = Mock() @@ -1413,7 +1315,7 @@ async def test_mcp_tool_sampling_callback_chat_client_exception(): async def test_mcp_tool_sampling_callback_no_valid_content(): """Test sampling callback when response has no valid content types.""" - from agent_framework import ChatMessage + from agent_framework import Message tool = MCPStdioTool(name="test_tool", command="python") @@ -1421,7 +1323,7 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): mock_chat_client = AsyncMock() mock_response = Mock() mock_response.messages = [ - ChatMessage( + Message( role="assistant", contents=[ Content.from_uri( @@ -1434,7 +1336,7 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): mock_response.model_id = "test-model" mock_chat_client.get_response.return_value = mock_response - tool.chat_client = mock_chat_client + tool.client = mock_chat_client # Create mock params params = Mock() @@ -2686,7 +2588,7 @@ async def test_mcp_tool_filters_framework_kwargs(): chat_options={"some": "option"}, # Should be filtered tools=[Mock()], # Should be filtered tool_choice="auto", # Should be filtered - thread=Mock(), # Should be filtered + session=Mock(), # Should be filtered conversation_id="conv-123", # Should be filtered options={"metadata": "value"}, # Should be filtered ) diff --git a/python/packages/core/tests/core/test_memory.py b/python/packages/core/tests/core/test_memory.py deleted file mode 100644 index ca28a01e8c..0000000000 --- a/python/packages/core/tests/core/test_memory.py +++ /dev/null @@ -1,136 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import sys -from collections.abc import MutableSequence -from typing import Any - -from agent_framework import ChatMessage -from agent_framework._memory import Context, ContextProvider - - -class MockContextProvider(ContextProvider): - """Mock ContextProvider for testing.""" - - def __init__(self, messages: list[ChatMessage] | None = None) -> None: - self.context_messages = messages - self.thread_created_called = False - self.invoked_called = False - self.invoking_called = False - self.thread_created_thread_id = None - self.new_messages = None - self.model_invoking_messages = None - - async def thread_created(self, thread_id: str | None) -> None: - """Track thread_created calls.""" - self.thread_created_called = True - self.thread_created_thread_id = thread_id - - async def invoked( - self, - request_messages: Any, - response_messages: Any | None = None, - invoke_exception: Exception | None = None, - **kwargs: Any, - ) -> None: - """Track invoked calls.""" - self.invoked_called = True - self.new_messages = request_messages - - async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context: - """Track invoking calls and return context.""" - self.invoking_called = True - self.model_invoking_messages = messages - context = Context() - context.messages = self.context_messages - return context - - -class MinimalContextProvider(ContextProvider): - """Minimal ContextProvider that only implements the required abstract method. - - Used to test the base class default implementations of thread_created, - invoked, __aenter__, and __aexit__. - """ - - async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context: - """Return empty context.""" - return Context() - - -class TestContext: - """Tests for Context class.""" - - def test_context_default_values(self) -> None: - """Test Context has correct default values.""" - context = Context() - assert context.instructions is None - assert context.messages == [] - assert context.tools == [] - - def test_context_with_values(self) -> None: - """Test Context can be initialized with values.""" - messages = [ChatMessage(role="user", text="Test message")] - context = Context(instructions="Test instructions", messages=messages) - assert context.instructions == "Test instructions" - assert len(context.messages) == 1 - assert context.messages[0].text == "Test message" - - -class TestContextProvider: - """Tests for ContextProvider class.""" - - async def test_thread_created(self) -> None: - """Test thread_created is called.""" - provider = MockContextProvider() - await provider.thread_created("test-thread-id") - assert provider.thread_created_called - assert provider.thread_created_thread_id == "test-thread-id" - - async def test_invoked(self) -> None: - """Test invoked is called.""" - provider = MockContextProvider() - message = ChatMessage(role="user", text="Test message") - await provider.invoked(message) - assert provider.invoked_called - assert provider.new_messages == message - - async def test_invoking(self) -> None: - """Test invoking is called and returns context.""" - provider = MockContextProvider(messages=[ChatMessage(role="user", text="Context message")]) - message = ChatMessage(role="user", text="Test message") - context = await provider.invoking(message) - assert provider.invoking_called - assert provider.model_invoking_messages == message - assert context.messages is not None - assert len(context.messages) == 1 - assert context.messages[0].text == "Context message" - - async def test_base_thread_created_does_nothing(self) -> None: - """Test that base ContextProvider.thread_created does nothing by default.""" - provider = MinimalContextProvider() - await provider.thread_created("some-thread-id") - await provider.thread_created(None) - - async def test_base_invoked_does_nothing(self) -> None: - """Test that base ContextProvider.invoked does nothing by default.""" - provider = MinimalContextProvider() - message = ChatMessage(role="user", text="Test") - await provider.invoked(message) - await provider.invoked(message, response_messages=message) - await provider.invoked(message, invoke_exception=Exception("test")) - - async def test_base_aenter_returns_self(self) -> None: - """Test that base ContextProvider.__aenter__ returns self.""" - provider = MinimalContextProvider() - async with provider as p: - assert p is provider - - async def test_base_aexit_does_nothing(self) -> None: - """Test that base ContextProvider.__aexit__ handles exceptions gracefully.""" - provider = MinimalContextProvider() - await provider.__aexit__(None, None, None) - try: - raise ValueError("test error") - except ValueError: - exc_info = sys.exc_info() - await provider.__aexit__(exc_info[0], exc_info[1], exc_info[2]) diff --git a/python/packages/core/tests/core/test_middleware.py b/python/packages/core/tests/core/test_middleware.py index ae84541df4..4ac4f22f1c 100644 --- a/python/packages/core/tests/core/test_middleware.py +++ b/python/packages/core/tests/core/test_middleware.py @@ -10,10 +10,10 @@ from pydantic import BaseModel, Field from agent_framework import ( AgentResponse, AgentResponseUpdate, - ChatMessage, ChatResponse, ChatResponseUpdate, Content, + Message, ResponseStream, SupportsAgentRun, ) @@ -37,7 +37,7 @@ class TestAgentContext: def test_init_with_defaults(self, mock_agent: SupportsAgentRun) -> None: """Test AgentContext initialization with default values.""" - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages) assert context.agent is mock_agent @@ -47,7 +47,7 @@ class TestAgentContext: def test_init_with_custom_values(self, mock_agent: SupportsAgentRun) -> None: """Test AgentContext initialization with custom values.""" - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] metadata = {"key": "value"} context = AgentContext(agent=mock_agent, messages=messages, stream=True, metadata=metadata) @@ -56,17 +56,17 @@ class TestAgentContext: assert context.stream is True assert context.metadata == metadata - def test_init_with_thread(self, mock_agent: SupportsAgentRun) -> None: - """Test AgentContext initialization with thread parameter.""" - from agent_framework import AgentThread + def test_init_with_session(self, mock_agent: SupportsAgentRun) -> None: + """Test AgentContext initialization with session parameter.""" + from agent_framework import AgentSession - messages = [ChatMessage(role="user", text="test")] - thread = AgentThread() - context = AgentContext(agent=mock_agent, messages=messages, thread=thread) + messages = [Message(role="user", text="test")] + session = AgentSession() + context = AgentContext(agent=mock_agent, messages=messages, session=session) assert context.agent is mock_agent assert context.messages == messages - assert context.thread is thread + assert context.session is session assert context.stream is False assert context.metadata == {} @@ -74,7 +74,7 @@ class TestAgentContext: class TestFunctionInvocationContext: """Test cases for FunctionInvocationContext.""" - def test_init_with_defaults(self, mock_function: FunctionTool[Any, Any]) -> None: + def test_init_with_defaults(self, mock_function: FunctionTool[Any]) -> None: """Test FunctionInvocationContext initialization with default values.""" arguments = FunctionTestArgs(name="test") context = FunctionInvocationContext(function=mock_function, arguments=arguments) @@ -83,7 +83,7 @@ class TestFunctionInvocationContext: assert context.arguments == arguments assert context.metadata == {} - def test_init_with_custom_metadata(self, mock_function: FunctionTool[Any, Any]) -> None: + def test_init_with_custom_metadata(self, mock_function: FunctionTool[Any]) -> None: """Test FunctionInvocationContext initialization with custom metadata.""" arguments = FunctionTestArgs(name="test") metadata = {"key": "value"} @@ -99,11 +99,11 @@ class TestChatContext: def test_init_with_defaults(self, mock_chat_client: Any) -> None: """Test ChatContext initialization with default values.""" - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {} - context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options) + context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) - assert context.chat_client is mock_chat_client + assert context.client is mock_chat_client assert context.messages == messages assert context.options is chat_options assert context.stream is False @@ -112,19 +112,19 @@ class TestChatContext: def test_init_with_custom_values(self, mock_chat_client: Any) -> None: """Test ChatContext initialization with custom values.""" - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {"temperature": 0.5} metadata = {"key": "value"} context = ChatContext( - chat_client=mock_chat_client, + client=mock_chat_client, messages=messages, options=chat_options, stream=True, metadata=metadata, ) - assert context.chat_client is mock_chat_client + assert context.client is mock_chat_client assert context.messages == messages assert context.options is chat_options assert context.stream is True @@ -135,12 +135,12 @@ class TestAgentMiddlewarePipeline: """Test cases for AgentMiddlewarePipeline.""" class PreNextTerminateMiddleware(AgentMiddleware): - async def process(self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: raise MiddlewareTermination class PostNextTerminateMiddleware(AgentMiddleware): async def process(self, context: AgentContext, call_next: Any) -> None: - await call_next(context) + await call_next() raise MiddlewareTermination def test_init_empty(self) -> None: @@ -157,8 +157,8 @@ class TestAgentMiddlewarePipeline: def test_init_with_function_middleware(self) -> None: """Test AgentMiddlewarePipeline initialization with function-based middleware.""" - async def test_middleware(context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: - await call_next(context) + async def test_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() pipeline = AgentMiddlewarePipeline(test_middleware) assert pipeline.has_middlewares @@ -166,10 +166,10 @@ class TestAgentMiddlewarePipeline: async def test_execute_no_middleware(self, mock_agent: SupportsAgentRun) -> None: """Test pipeline execution with no middleware.""" pipeline = AgentMiddlewarePipeline() - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages) - expected_response = AgentResponse(messages=[ChatMessage(role="assistant", text="response")]) + expected_response = AgentResponse(messages=[Message(role="assistant", text="response")]) async def final_handler(ctx: AgentContext) -> AgentResponse: return expected_response @@ -185,19 +185,17 @@ class TestAgentMiddlewarePipeline: def __init__(self, name: str): self.name = name - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append(f"{self.name}_before") - await call_next(context) + await call_next() execution_order.append(f"{self.name}_after") middleware = OrderTrackingMiddleware("test") pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages) - expected_response = AgentResponse(messages=[ChatMessage(role="assistant", text="response")]) + expected_response = AgentResponse(messages=[Message(role="assistant", text="response")]) async def final_handler(ctx: AgentContext) -> AgentResponse: execution_order.append("handler") @@ -210,7 +208,7 @@ class TestAgentMiddlewarePipeline: async def test_execute_stream_no_middleware(self, mock_agent: SupportsAgentRun) -> None: """Test pipeline streaming execution with no middleware.""" pipeline = AgentMiddlewarePipeline() - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages, stream=True) async def final_handler(ctx: AgentContext) -> ResponseStream[AgentResponseUpdate, AgentResponse]: @@ -238,16 +236,14 @@ class TestAgentMiddlewarePipeline: def __init__(self, name: str): self.name = name - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append(f"{self.name}_before") - await call_next(context) + await call_next() execution_order.append(f"{self.name}_after") middleware = StreamOrderTrackingMiddleware("test") pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages, stream=True) async def final_handler(ctx: AgentContext) -> ResponseStream[AgentResponseUpdate, AgentResponse]: @@ -273,14 +269,14 @@ class TestAgentMiddlewarePipeline: """Test pipeline execution with termination before next().""" middleware = self.PreNextTerminateMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages) execution_order: list[str] = [] async def final_handler(ctx: AgentContext) -> AgentResponse: # Handler should not be executed when terminated before next() execution_order.append("handler") - return AgentResponse(messages=[ChatMessage(role="assistant", text="response")]) + return AgentResponse(messages=[Message(role="assistant", text="response")]) response = await pipeline.execute(context, final_handler) assert response is None @@ -291,13 +287,13 @@ class TestAgentMiddlewarePipeline: """Test pipeline execution with termination after next().""" middleware = self.PostNextTerminateMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages) execution_order: list[str] = [] async def final_handler(ctx: AgentContext) -> AgentResponse: execution_order.append("handler") - return AgentResponse(messages=[ChatMessage(role="assistant", text="response")]) + return AgentResponse(messages=[Message(role="assistant", text="response")]) response = await pipeline.execute(context, final_handler) assert response is not None @@ -309,7 +305,7 @@ class TestAgentMiddlewarePipeline: """Test pipeline streaming execution with termination before next().""" middleware = self.PreNextTerminateMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages, stream=True) execution_order: list[str] = [] @@ -337,7 +333,7 @@ class TestAgentMiddlewarePipeline: """Test pipeline streaming execution with termination after next().""" middleware = self.PostNextTerminateMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages, stream=True) execution_order: list[str] = [] @@ -360,60 +356,56 @@ class TestAgentMiddlewarePipeline: assert updates[1].text == "chunk2" assert execution_order == ["handler_start", "handler_end"] - async def test_execute_with_thread_in_context(self, mock_agent: SupportsAgentRun) -> None: - """Test pipeline execution properly passes thread to middleware.""" - from agent_framework import AgentThread + async def test_execute_with_session_in_context(self, mock_agent: SupportsAgentRun) -> None: + """Test pipeline execution properly passes session to middleware.""" + from agent_framework import AgentSession - captured_thread = None + captured_session = None - class ThreadCapturingMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: - nonlocal captured_thread - captured_thread = context.thread - await call_next(context) + class SessionCapturingMiddleware(AgentMiddleware): + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: + nonlocal captured_session + captured_session = context.session + await call_next() - middleware = ThreadCapturingMiddleware() + middleware = SessionCapturingMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] - thread = AgentThread() - context = AgentContext(agent=mock_agent, messages=messages, thread=thread) + messages = [Message(role="user", text="test")] + session = AgentSession() + context = AgentContext(agent=mock_agent, messages=messages, session=session) - expected_response = AgentResponse(messages=[ChatMessage(role="assistant", text="response")]) + expected_response = AgentResponse(messages=[Message(role="assistant", text="response")]) async def final_handler(ctx: AgentContext) -> AgentResponse: return expected_response result = await pipeline.execute(context, final_handler) assert result == expected_response - assert captured_thread is thread + assert captured_session is session - async def test_execute_with_no_thread_in_context(self, mock_agent: SupportsAgentRun) -> None: - """Test pipeline execution when no thread is provided.""" - captured_thread = "not_none" # Use string to distinguish from None + async def test_execute_with_no_session_in_context(self, mock_agent: SupportsAgentRun) -> None: + """Test pipeline execution when no session is provided.""" + captured_session = "not_none" # Use string to distinguish from None - class ThreadCapturingMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: - nonlocal captured_thread - captured_thread = context.thread - await call_next(context) + class SessionCapturingMiddleware(AgentMiddleware): + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: + nonlocal captured_session + captured_session = context.session + await call_next() - middleware = ThreadCapturingMiddleware() + middleware = SessionCapturingMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] - context = AgentContext(agent=mock_agent, messages=messages, thread=None) + messages = [Message(role="user", text="test")] + context = AgentContext(agent=mock_agent, messages=messages, session=None) - expected_response = AgentResponse(messages=[ChatMessage(role="assistant", text="response")]) + expected_response = AgentResponse(messages=[Message(role="assistant", text="response")]) async def final_handler(ctx: AgentContext) -> AgentResponse: return expected_response result = await pipeline.execute(context, final_handler) assert result == expected_response - assert captured_thread is None + assert captured_session is None class TestFunctionMiddlewarePipeline: @@ -425,10 +417,10 @@ class TestFunctionMiddlewarePipeline: class PostNextTerminateFunctionMiddleware(FunctionMiddleware): async def process(self, context: FunctionInvocationContext, call_next: Any) -> None: - await call_next(context) + await call_next() raise MiddlewareTermination - async def test_execute_with_pre_next_termination(self, mock_function: FunctionTool[Any, Any]) -> None: + async def test_execute_with_pre_next_termination(self, mock_function: FunctionTool[Any]) -> None: """Test pipeline execution with termination before next() raises MiddlewareTermination.""" middleware = self.PreNextTerminateFunctionMiddleware() pipeline = FunctionMiddlewarePipeline(middleware) @@ -447,7 +439,7 @@ class TestFunctionMiddlewarePipeline: # Handler should not be called when terminated before next() assert execution_order == [] - async def test_execute_with_post_next_termination(self, mock_function: FunctionTool[Any, Any]) -> None: + async def test_execute_with_post_next_termination(self, mock_function: FunctionTool[Any]) -> None: """Test pipeline execution with termination after next() raises MiddlewareTermination.""" middleware = self.PostNextTerminateFunctionMiddleware() pipeline = FunctionMiddlewarePipeline(middleware) @@ -482,15 +474,13 @@ class TestFunctionMiddlewarePipeline: def test_init_with_function_middleware(self) -> None: """Test FunctionMiddlewarePipeline initialization with function-based middleware.""" - async def test_middleware( - context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] - ) -> None: - await call_next(context) + async def test_middleware(context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() pipeline = FunctionMiddlewarePipeline(test_middleware) assert pipeline.has_middlewares - async def test_execute_no_middleware(self, mock_function: FunctionTool[Any, Any]) -> None: + async def test_execute_no_middleware(self, mock_function: FunctionTool[Any]) -> None: """Test pipeline execution with no middleware.""" pipeline = FunctionMiddlewarePipeline() arguments = FunctionTestArgs(name="test") @@ -504,7 +494,7 @@ class TestFunctionMiddlewarePipeline: result = await pipeline.execute(context, final_handler) assert result == expected_result - async def test_execute_with_middleware(self, mock_function: FunctionTool[Any, Any]) -> None: + async def test_execute_with_middleware(self, mock_function: FunctionTool[Any]) -> None: """Test pipeline execution with middleware.""" execution_order: list[str] = [] @@ -515,10 +505,10 @@ class TestFunctionMiddlewarePipeline: async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: execution_order.append(f"{self.name}_before") - await call_next(context) + await call_next() execution_order.append(f"{self.name}_after") middleware = OrderTrackingFunctionMiddleware("test") @@ -541,12 +531,12 @@ class TestChatMiddlewarePipeline: """Test cases for ChatMiddlewarePipeline.""" class PreNextTerminateChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: raise MiddlewareTermination class PostNextTerminateChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: - await call_next(context) + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() raise MiddlewareTermination def test_init_empty(self) -> None: @@ -563,8 +553,8 @@ class TestChatMiddlewarePipeline: def test_init_with_function_middleware(self) -> None: """Test ChatMiddlewarePipeline initialization with function-based middleware.""" - async def test_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: - await call_next(context) + async def test_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() pipeline = ChatMiddlewarePipeline(test_middleware) assert pipeline.has_middlewares @@ -572,11 +562,11 @@ class TestChatMiddlewarePipeline: async def test_execute_no_middleware(self, mock_chat_client: Any) -> None: """Test pipeline execution with no middleware.""" pipeline = ChatMiddlewarePipeline() - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {} - context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options) + context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) - expected_response = ChatResponse(messages=[ChatMessage(role="assistant", text="response")]) + expected_response = ChatResponse(messages=[Message(role="assistant", text="response")]) async def final_handler(ctx: ChatContext) -> ChatResponse: return expected_response @@ -592,18 +582,18 @@ class TestChatMiddlewarePipeline: def __init__(self, name: str): self.name = name - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append(f"{self.name}_before") - await call_next(context) + await call_next() execution_order.append(f"{self.name}_after") middleware = OrderTrackingChatMiddleware("test") pipeline = ChatMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {} - context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options) + context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) - expected_response = ChatResponse(messages=[ChatMessage(role="assistant", text="response")]) + expected_response = ChatResponse(messages=[Message(role="assistant", text="response")]) async def final_handler(ctx: ChatContext) -> ChatResponse: execution_order.append("handler") @@ -616,9 +606,9 @@ class TestChatMiddlewarePipeline: async def test_execute_stream_no_middleware(self, mock_chat_client: Any) -> None: """Test pipeline streaming execution with no middleware.""" pipeline = ChatMiddlewarePipeline() - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {} - context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options, stream=True) + context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options, stream=True) def final_handler(ctx: ChatContext) -> ResponseStream[ChatResponseUpdate, ChatResponse]: async def _stream() -> AsyncIterable[ChatResponseUpdate]: @@ -644,16 +634,16 @@ class TestChatMiddlewarePipeline: def __init__(self, name: str): self.name = name - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append(f"{self.name}_before") - await call_next(context) + await call_next() execution_order.append(f"{self.name}_after") middleware = StreamOrderTrackingChatMiddleware("test") pipeline = ChatMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {} - context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options, stream=True) + context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options, stream=True) def final_handler(ctx: ChatContext) -> ResponseStream[ChatResponseUpdate, ChatResponse]: async def _stream() -> AsyncIterable[ChatResponseUpdate]: @@ -678,15 +668,15 @@ class TestChatMiddlewarePipeline: """Test pipeline execution with termination before next().""" middleware = self.PreNextTerminateChatMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {} - context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options) + context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) execution_order: list[str] = [] async def final_handler(ctx: ChatContext) -> ChatResponse: # Handler should not be executed when terminated before next() execution_order.append("handler") - return ChatResponse(messages=[ChatMessage(role="assistant", text="response")]) + return ChatResponse(messages=[Message(role="assistant", text="response")]) response = await pipeline.execute(context, final_handler) assert response is None @@ -697,14 +687,14 @@ class TestChatMiddlewarePipeline: """Test pipeline execution with termination after next().""" middleware = self.PostNextTerminateChatMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {} - context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options) + context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) execution_order: list[str] = [] async def final_handler(ctx: ChatContext) -> ChatResponse: execution_order.append("handler") - return ChatResponse(messages=[ChatMessage(role="assistant", text="response")]) + return ChatResponse(messages=[Message(role="assistant", text="response")]) response = await pipeline.execute(context, final_handler) assert response is not None @@ -716,9 +706,9 @@ class TestChatMiddlewarePipeline: """Test pipeline streaming execution with termination before next().""" middleware = self.PreNextTerminateChatMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {} - context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options, stream=True) + context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options, stream=True) execution_order: list[str] = [] def final_handler(ctx: ChatContext) -> ResponseStream[ChatResponseUpdate, ChatResponse]: @@ -741,9 +731,9 @@ class TestChatMiddlewarePipeline: """Test pipeline streaming execution with termination after next().""" middleware = self.PostNextTerminateChatMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {} - context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options, stream=True) + context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options, stream=True) execution_order: list[str] = [] def final_handler(ctx: ChatContext) -> ResponseStream[ChatResponseUpdate, ChatResponse]: @@ -774,23 +764,21 @@ class TestClassBasedMiddleware: metadata_updates: list[str] = [] class MetadataAgentMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: context.metadata["before"] = True metadata_updates.append("before") - await call_next(context) + await call_next() context.metadata["after"] = True metadata_updates.append("after") middleware = MetadataAgentMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages) async def final_handler(ctx: AgentContext) -> AgentResponse: metadata_updates.append("handler") - return AgentResponse(messages=[ChatMessage(role="assistant", text="response")]) + return AgentResponse(messages=[Message(role="assistant", text="response")]) result = await pipeline.execute(context, final_handler) @@ -799,7 +787,7 @@ class TestClassBasedMiddleware: assert context.metadata["after"] is True assert metadata_updates == ["before", "handler", "after"] - async def test_function_middleware_execution(self, mock_function: FunctionTool[Any, Any]) -> None: + async def test_function_middleware_execution(self, mock_function: FunctionTool[Any]) -> None: """Test class-based function middleware execution.""" metadata_updates: list[str] = [] @@ -807,11 +795,11 @@ class TestClassBasedMiddleware: async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: context.metadata["before"] = True metadata_updates.append("before") - await call_next(context) + await call_next() context.metadata["after"] = True metadata_updates.append("after") @@ -839,21 +827,19 @@ class TestFunctionBasedMiddleware: """Test function-based agent middleware.""" execution_order: list[str] = [] - async def test_agent_middleware( - context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def test_agent_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("function_before") context.metadata["function_middleware"] = True - await call_next(context) + await call_next() execution_order.append("function_after") pipeline = AgentMiddlewarePipeline(test_agent_middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages) async def final_handler(ctx: AgentContext) -> AgentResponse: execution_order.append("handler") - return AgentResponse(messages=[ChatMessage(role="assistant", text="response")]) + return AgentResponse(messages=[Message(role="assistant", text="response")]) result = await pipeline.execute(context, final_handler) @@ -861,16 +847,16 @@ class TestFunctionBasedMiddleware: assert context.metadata["function_middleware"] is True assert execution_order == ["function_before", "handler", "function_after"] - async def test_function_function_middleware(self, mock_function: FunctionTool[Any, Any]) -> None: + async def test_function_function_middleware(self, mock_function: FunctionTool[Any]) -> None: """Test function-based function middleware.""" execution_order: list[str] = [] async def test_function_middleware( - context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] ) -> None: execution_order.append("function_before") context.metadata["function_middleware"] = True - await call_next(context) + await call_next() execution_order.append("function_after") pipeline = FunctionMiddlewarePipeline(test_function_middleware) @@ -896,34 +882,30 @@ class TestMixedMiddleware: execution_order: list[str] = [] class ClassMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("class_before") - await call_next(context) + await call_next() execution_order.append("class_after") - async def function_middleware( - context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def function_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("function_before") - await call_next(context) + await call_next() execution_order.append("function_after") pipeline = AgentMiddlewarePipeline(ClassMiddleware(), function_middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages) async def final_handler(ctx: AgentContext) -> AgentResponse: execution_order.append("handler") - return AgentResponse(messages=[ChatMessage(role="assistant", text="response")]) + return AgentResponse(messages=[Message(role="assistant", text="response")]) result = await pipeline.execute(context, final_handler) assert result is not None assert execution_order == ["class_before", "function_before", "handler", "function_after", "class_after"] - async def test_mixed_function_middleware(self, mock_function: FunctionTool[Any, Any]) -> None: + async def test_mixed_function_middleware(self, mock_function: FunctionTool[Any]) -> None: """Test mixed class and function-based function middleware.""" execution_order: list[str] = [] @@ -931,17 +913,17 @@ class TestMixedMiddleware: async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: execution_order.append("class_before") - await call_next(context) + await call_next() execution_order.append("class_after") async def function_middleware( - context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] ) -> None: execution_order.append("function_before") - await call_next(context) + await call_next() execution_order.append("function_after") pipeline = FunctionMiddlewarePipeline(ClassMiddleware(), function_middleware) @@ -962,26 +944,24 @@ class TestMixedMiddleware: execution_order: list[str] = [] class ClassChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("class_before") - await call_next(context) + await call_next() execution_order.append("class_after") - async def function_chat_middleware( - context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] - ) -> None: + async def function_chat_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("function_before") - await call_next(context) + await call_next() execution_order.append("function_after") pipeline = ChatMiddlewarePipeline(ClassChatMiddleware(), function_chat_middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {} - context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options) + context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) async def final_handler(ctx: ChatContext) -> ChatResponse: execution_order.append("handler") - return ChatResponse(messages=[ChatMessage(role="assistant", text="response")]) + return ChatResponse(messages=[Message(role="assistant", text="response")]) result = await pipeline.execute(context, final_handler) @@ -997,37 +977,31 @@ class TestMultipleMiddlewareOrdering: execution_order: list[str] = [] class FirstMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("first_before") - await call_next(context) + await call_next() execution_order.append("first_after") class SecondMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("second_before") - await call_next(context) + await call_next() execution_order.append("second_after") class ThirdMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("third_before") - await call_next(context) + await call_next() execution_order.append("third_after") middleware = [FirstMiddleware(), SecondMiddleware(), ThirdMiddleware()] pipeline = AgentMiddlewarePipeline(*middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages) async def final_handler(ctx: AgentContext) -> AgentResponse: execution_order.append("handler") - return AgentResponse(messages=[ChatMessage(role="assistant", text="response")]) + return AgentResponse(messages=[Message(role="assistant", text="response")]) result = await pipeline.execute(context, final_handler) @@ -1043,7 +1017,7 @@ class TestMultipleMiddlewareOrdering: ] assert execution_order == expected_order - async def test_function_middleware_execution_order(self, mock_function: FunctionTool[Any, Any]) -> None: + async def test_function_middleware_execution_order(self, mock_function: FunctionTool[Any]) -> None: """Test that multiple function middleware execute in registration order.""" execution_order: list[str] = [] @@ -1051,20 +1025,20 @@ class TestMultipleMiddlewareOrdering: async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: execution_order.append("first_before") - await call_next(context) + await call_next() execution_order.append("first_after") class SecondMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: execution_order.append("second_before") - await call_next(context) + await call_next() execution_order.append("second_after") middleware = [FirstMiddleware(), SecondMiddleware()] @@ -1087,32 +1061,32 @@ class TestMultipleMiddlewareOrdering: execution_order: list[str] = [] class FirstChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("first_before") - await call_next(context) + await call_next() execution_order.append("first_after") class SecondChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("second_before") - await call_next(context) + await call_next() execution_order.append("second_after") class ThirdChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("third_before") - await call_next(context) + await call_next() execution_order.append("third_after") middleware = [FirstChatMiddleware(), SecondChatMiddleware(), ThirdChatMiddleware()] pipeline = ChatMiddlewarePipeline(*middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {} - context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options) + context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) async def final_handler(ctx: ChatContext) -> ChatResponse: execution_order.append("handler") - return ChatResponse(messages=[ChatMessage(role="assistant", text="response")]) + return ChatResponse(messages=[Message(role="assistant", text="response")]) result = await pipeline.execute(context, final_handler) @@ -1136,9 +1110,7 @@ class TestContextContentValidation: """Test that agent context contains expected data.""" class ContextValidationMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: # Verify context has all expected attributes assert hasattr(context, "agent") assert hasattr(context, "messages") @@ -1156,29 +1128,29 @@ class TestContextContentValidation: # Add custom metadata context.metadata["validated"] = True - await call_next(context) + await call_next() middleware = ContextValidationMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages) async def final_handler(ctx: AgentContext) -> AgentResponse: # Verify metadata was set by middleware assert ctx.metadata.get("validated") is True - return AgentResponse(messages=[ChatMessage(role="assistant", text="response")]) + return AgentResponse(messages=[Message(role="assistant", text="response")]) result = await pipeline.execute(context, final_handler) assert result is not None - async def test_function_context_validation(self, mock_function: FunctionTool[Any, Any]) -> None: + async def test_function_context_validation(self, mock_function: FunctionTool[Any]) -> None: """Test that function context contains expected data.""" class ContextValidationMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: # Verify context has all expected attributes assert hasattr(context, "function") @@ -1194,7 +1166,7 @@ class TestContextContentValidation: # Add custom metadata context.metadata["validated"] = True - await call_next(context) + await call_next() middleware = ContextValidationMiddleware() pipeline = FunctionMiddlewarePipeline(middleware) @@ -1213,9 +1185,9 @@ class TestContextContentValidation: """Test that chat context contains expected data.""" class ChatContextValidationMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: # Verify context has all expected attributes - assert hasattr(context, "chat_client") + assert hasattr(context, "client") assert hasattr(context, "messages") assert hasattr(context, "options") assert hasattr(context, "stream") @@ -1223,7 +1195,7 @@ class TestContextContentValidation: assert hasattr(context, "result") # Verify context content - assert context.chat_client is mock_chat_client + assert context.client is mock_chat_client assert len(context.messages) == 1 assert context.messages[0].role == "user" assert context.messages[0].text == "test" @@ -1235,18 +1207,18 @@ class TestContextContentValidation: # Add custom metadata context.metadata["validated"] = True - await call_next(context) + await call_next() middleware = ChatContextValidationMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {"temperature": 0.5} - context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options) + context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) async def final_handler(ctx: ChatContext) -> ChatResponse: # Verify metadata was set by middleware assert ctx.metadata.get("validated") is True - return ChatResponse(messages=[ChatMessage(role="assistant", text="response")]) + return ChatResponse(messages=[Message(role="assistant", text="response")]) result = await pipeline.execute(context, final_handler) assert result is not None @@ -1260,22 +1232,20 @@ class TestStreamingScenarios: streaming_flags: list[bool] = [] class StreamingFlagMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: streaming_flags.append(context.stream) - await call_next(context) + await call_next() middleware = StreamingFlagMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] # Test non-streaming context = AgentContext(agent=mock_agent, messages=messages) async def final_handler(ctx: AgentContext) -> AgentResponse: streaming_flags.append(ctx.stream) - return AgentResponse(messages=[ChatMessage(role="assistant", text="response")]) + return AgentResponse(messages=[Message(role="assistant", text="response")]) await pipeline.execute(context, final_handler) @@ -1302,16 +1272,14 @@ class TestStreamingScenarios: chunks_processed: list[str] = [] class StreamProcessingMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: chunks_processed.append("before_stream") - await call_next(context) + await call_next() chunks_processed.append("after_stream") middleware = StreamProcessingMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages, stream=True) async def final_stream_handler(ctx: AgentContext) -> ResponseStream[AgentResponseUpdate, AgentResponse]: @@ -1345,26 +1313,26 @@ class TestStreamingScenarios: streaming_flags: list[bool] = [] class ChatStreamingFlagMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: streaming_flags.append(context.stream) - await call_next(context) + await call_next() middleware = ChatStreamingFlagMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {} # Test non-streaming - context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options) + context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) async def final_handler(ctx: ChatContext) -> ChatResponse: streaming_flags.append(ctx.stream) - return ChatResponse(messages=[ChatMessage(role="assistant", text="response")]) + return ChatResponse(messages=[Message(role="assistant", text="response")]) await pipeline.execute(context, final_handler) # Test streaming - context_stream = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options, stream=True) + context_stream = ChatContext(client=mock_chat_client, messages=messages, options=chat_options, stream=True) def final_stream_handler(ctx: ChatContext) -> ResponseStream[ChatResponseUpdate, ChatResponse]: async def _stream() -> AsyncIterable[ChatResponseUpdate]: @@ -1386,16 +1354,16 @@ class TestStreamingScenarios: chunks_processed: list[str] = [] class ChatStreamProcessingMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: chunks_processed.append("before_stream") - await call_next(context) + await call_next() chunks_processed.append("after_stream") middleware = ChatStreamProcessingMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {} - context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options, stream=True) + context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options, stream=True) def final_stream_handler(ctx: ChatContext) -> ResponseStream[ChatResponseUpdate, ChatResponse]: async def _stream() -> AsyncIterable[ChatResponseUpdate]: @@ -1436,24 +1404,22 @@ class FunctionTestArgs(BaseModel): class TestAgentMiddleware(AgentMiddleware): """Test implementation of AgentMiddleware.""" - async def process(self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: - await call_next(context) + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() class TestFunctionMiddleware(FunctionMiddleware): """Test implementation of FunctionMiddleware.""" - async def process( - self, context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] - ) -> None: - await call_next(context) + async def process(self, context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() class TestChatMiddleware(ChatMiddleware): """Test implementation of ChatMiddleware.""" - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: - await call_next(context) + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() class MockFunctionArgs(BaseModel): @@ -1469,15 +1435,13 @@ class TestMiddlewareExecutionControl: """Test that when agent middleware doesn't call next(), no execution happens.""" class NoNextMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: # Don't call next() - this should prevent any execution pass middleware = NoNextMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages) handler_called = False @@ -1485,7 +1449,7 @@ class TestMiddlewareExecutionControl: async def final_handler(ctx: AgentContext) -> AgentResponse: nonlocal handler_called handler_called = True - return AgentResponse(messages=[ChatMessage(role="assistant", text="should not execute")]) + return AgentResponse(messages=[Message(role="assistant", text="should not execute")]) result = await pipeline.execute(context, final_handler) @@ -1498,15 +1462,13 @@ class TestMiddlewareExecutionControl: """Test that when agent middleware doesn't call next(), no streaming execution happens.""" class NoNextStreamingMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: # Don't call next() - this should prevent any execution pass middleware = NoNextStreamingMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages, stream=True) handler_called = False @@ -1527,7 +1489,7 @@ class TestMiddlewareExecutionControl: assert not handler_called assert context.result is None - async def test_function_middleware_no_next_no_execution(self, mock_function: FunctionTool[Any, Any]) -> None: + async def test_function_middleware_no_next_no_execution(self, mock_function: FunctionTool[Any]) -> None: """Test that when function middleware doesn't call next(), no execution happens.""" class FunctionTestArgs(BaseModel): @@ -1537,7 +1499,7 @@ class TestMiddlewareExecutionControl: async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: # Don't call next() - this should prevent any execution pass @@ -1566,21 +1528,17 @@ class TestMiddlewareExecutionControl: execution_order: list[str] = [] class FirstMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("first") # Don't call next() - this should stop the pipeline class SecondMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("second") - await call_next(context) + await call_next() pipeline = AgentMiddlewarePipeline(FirstMiddleware(), SecondMiddleware()) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages) handler_called = False @@ -1588,7 +1546,7 @@ class TestMiddlewareExecutionControl: async def final_handler(ctx: AgentContext) -> AgentResponse: nonlocal handler_called handler_called = True - return AgentResponse(messages=[ChatMessage(role="assistant", text="should not execute")]) + return AgentResponse(messages=[Message(role="assistant", text="should not execute")]) result = await pipeline.execute(context, final_handler) @@ -1601,22 +1559,22 @@ class TestMiddlewareExecutionControl: """Test that when chat middleware doesn't call next(), no execution happens.""" class NoNextChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: # Don't call next() - this should prevent any execution pass middleware = NoNextChatMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {} - context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options) + context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) handler_called = False async def final_handler(ctx: ChatContext) -> ChatResponse: nonlocal handler_called handler_called = True - return ChatResponse(messages=[ChatMessage(role="assistant", text="should not execute")]) + return ChatResponse(messages=[Message(role="assistant", text="should not execute")]) result = await pipeline.execute(context, final_handler) @@ -1629,15 +1587,15 @@ class TestMiddlewareExecutionControl: """Test that when chat middleware doesn't call next(), no streaming execution happens.""" class NoNextStreamingChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: # Don't call next() - this should prevent any execution pass middleware = NoNextStreamingChatMiddleware() pipeline = ChatMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {} - context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options, stream=True) + context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options, stream=True) handler_called = False @@ -1670,26 +1628,26 @@ class TestMiddlewareExecutionControl: execution_order: list[str] = [] class FirstChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("first") # Don't call next() - this should stop the pipeline class SecondChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("second") - await call_next(context) + await call_next() pipeline = ChatMiddlewarePipeline(FirstChatMiddleware(), SecondChatMiddleware()) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] chat_options: dict[str, Any] = {} - context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options) + context = ChatContext(client=mock_chat_client, messages=messages, options=chat_options) handler_called = False async def final_handler(ctx: ChatContext) -> ChatResponse: nonlocal handler_called handler_called = True - return ChatResponse(messages=[ChatMessage(role="assistant", text="should not execute")]) + return ChatResponse(messages=[Message(role="assistant", text="should not execute")]) result = await pipeline.execute(context, final_handler) @@ -1708,9 +1666,9 @@ def mock_agent() -> SupportsAgentRun: @pytest.fixture -def mock_function() -> FunctionTool[Any, Any]: +def mock_function() -> FunctionTool[Any]: """Mock function for testing.""" - function = MagicMock(spec=FunctionTool[Any, Any]) + function = MagicMock(spec=FunctionTool[Any]) function.name = "test_function" return function @@ -1718,8 +1676,8 @@ def mock_function() -> FunctionTool[Any, Any]: @pytest.fixture def mock_chat_client() -> Any: """Mock chat client for testing.""" - from agent_framework import ChatClientProtocol + from agent_framework import SupportsChatGetResponse - client = MagicMock(spec=ChatClientProtocol) + client = MagicMock(spec=SupportsChatGetResponse) client.service_url = MagicMock(return_value="mock://test") return client diff --git a/python/packages/core/tests/core/test_middleware_context_result.py b/python/packages/core/tests/core/test_middleware_context_result.py index abdea790df..ba6bfb9c4a 100644 --- a/python/packages/core/tests/core/test_middleware_context_result.py +++ b/python/packages/core/tests/core/test_middleware_context_result.py @@ -8,11 +8,11 @@ import pytest from pydantic import BaseModel, Field from agent_framework import ( + Agent, AgentResponse, AgentResponseUpdate, - ChatAgent, - ChatMessage, Content, + Message, ResponseStream, SupportsAgentRun, ) @@ -40,19 +40,17 @@ class TestResultOverrideMiddleware: async def test_agent_middleware_response_override_non_streaming(self, mock_agent: SupportsAgentRun) -> None: """Test that agent middleware can override response for non-streaming execution.""" - override_response = AgentResponse(messages=[ChatMessage(role="assistant", text="overridden response")]) + override_response = AgentResponse(messages=[Message(role="assistant", text="overridden response")]) class ResponseOverrideMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: # Execute the pipeline first, then override the response - await call_next(context) + await call_next() context.result = override_response middleware = ResponseOverrideMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages) handler_called = False @@ -60,7 +58,7 @@ class TestResultOverrideMiddleware: async def final_handler(ctx: AgentContext) -> AgentResponse: nonlocal handler_called handler_called = True - return AgentResponse(messages=[ChatMessage(role="assistant", text="original response")]) + return AgentResponse(messages=[Message(role="assistant", text="original response")]) result = await pipeline.execute(context, final_handler) @@ -79,16 +77,14 @@ class TestResultOverrideMiddleware: yield AgentResponseUpdate(contents=[Content.from_text(text=" stream")]) class StreamResponseOverrideMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: # Execute the pipeline first, then override the response stream - await call_next(context) + await call_next() context.result = ResponseStream(override_stream()) middleware = StreamResponseOverrideMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages, stream=True) async def final_handler(ctx: AgentContext) -> ResponseStream[AgentResponseUpdate, AgentResponse]: @@ -107,7 +103,7 @@ class TestResultOverrideMiddleware: assert updates[0].text == "overridden" assert updates[1].text == " stream" - async def test_function_middleware_result_override(self, mock_function: FunctionTool[Any, Any]) -> None: + async def test_function_middleware_result_override(self, mock_function: FunctionTool[Any]) -> None: """Test that function middleware can override result.""" override_result = "overridden function result" @@ -115,10 +111,10 @@ class TestResultOverrideMiddleware: async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: # Execute the pipeline first, then override the result - await call_next(context) + await call_next() context.result = override_result middleware = ResultOverrideMiddleware() @@ -141,41 +137,39 @@ class TestResultOverrideMiddleware: assert handler_called async def test_chat_agent_middleware_response_override(self) -> None: - """Test result override functionality with ChatAgent integration.""" + """Test result override functionality with Agent integration.""" mock_chat_client = MockChatClient() class ChatAgentResponseOverrideMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: # Always call next() first to allow execution - await call_next(context) + await call_next() # Then conditionally override based on content if any("special" in msg.text for msg in context.messages if msg.text): context.result = AgentResponse( - messages=[ChatMessage(role="assistant", text="Special response from middleware!")] + messages=[Message(role="assistant", text="Special response from middleware!")] ) - # Create ChatAgent with override middleware + # Create Agent with override middleware middleware = ChatAgentResponseOverrideMiddleware() - agent = ChatAgent(chat_client=mock_chat_client, middleware=[middleware]) + agent = Agent(client=mock_chat_client, middleware=[middleware]) # Test override case - override_messages = [ChatMessage(role="user", text="Give me a special response")] + override_messages = [Message(role="user", text="Give me a special response")] override_response = await agent.run(override_messages) assert override_response.messages[0].text == "Special response from middleware!" # Verify chat client was called since middleware called next() assert mock_chat_client.call_count == 1 # Test normal case - normal_messages = [ChatMessage(role="user", text="Normal request")] + normal_messages = [Message(role="user", text="Normal request")] normal_response = await agent.run(normal_messages) assert normal_response.messages[0].text == "test response" # Verify chat client was called for normal case assert mock_chat_client.call_count == 2 async def test_chat_agent_middleware_streaming_override(self) -> None: - """Test streaming result override functionality with ChatAgent integration.""" + """Test streaming result override functionality with Agent integration.""" mock_chat_client = MockChatClient() async def custom_stream() -> AsyncIterable[AgentResponseUpdate]: @@ -184,22 +178,20 @@ class TestResultOverrideMiddleware: yield AgentResponseUpdate(contents=[Content.from_text(text=" response!")]) class ChatAgentStreamOverrideMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: # Check if we want to override BEFORE calling next to avoid creating unused streams if any("custom stream" in msg.text for msg in context.messages if msg.text): context.result = ResponseStream(custom_stream()) return # Don't call next() - we're overriding the entire result # Normal case - let the agent handle it - await call_next(context) + await call_next() - # Create ChatAgent with override middleware + # Create Agent with override middleware middleware = ChatAgentStreamOverrideMiddleware() - agent = ChatAgent(chat_client=mock_chat_client, middleware=[middleware]) + agent = Agent(client=mock_chat_client, middleware=[middleware]) # Test streaming override case - override_messages = [ChatMessage(role="user", text="Give me a custom stream")] + override_messages = [Message(role="user", text="Give me a custom stream")] override_updates: list[AgentResponseUpdate] = [] async for update in agent.run(override_messages, stream=True): override_updates.append(update) @@ -210,7 +202,7 @@ class TestResultOverrideMiddleware: assert override_updates[2].text == " response!" # Test normal streaming case - normal_messages = [ChatMessage(role="user", text="Normal streaming request")] + normal_messages = [Message(role="user", text="Normal streaming request")] normal_updates: list[AgentResponseUpdate] = [] async for update in agent.run(normal_messages, stream=True): normal_updates.append(update) @@ -223,12 +215,10 @@ class TestResultOverrideMiddleware: """Test that when agent middleware conditionally doesn't call next(), no execution happens.""" class ConditionalNoNextMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: # Only call next() if message contains "execute" if any("execute" in msg.text for msg in context.messages if msg.text): - await call_next(context) + await call_next() # Otherwise, don't call next() - no execution should happen middleware = ConditionalNoNextMiddleware() @@ -239,10 +229,10 @@ class TestResultOverrideMiddleware: async def final_handler(ctx: AgentContext) -> AgentResponse: nonlocal handler_called handler_called = True - return AgentResponse(messages=[ChatMessage(role="assistant", text="executed response")]) + return AgentResponse(messages=[Message(role="assistant", text="executed response")]) # Test case where next() is NOT called - no_execute_messages = [ChatMessage(role="user", text="Don't run this")] + no_execute_messages = [Message(role="user", text="Don't run this")] no_execute_context = AgentContext(agent=mock_agent, messages=no_execute_messages, stream=False) no_execute_result = await pipeline.execute(no_execute_context, final_handler) @@ -254,7 +244,7 @@ class TestResultOverrideMiddleware: handler_called = False # Test case where next() IS called - execute_messages = [ChatMessage(role="user", text="Please execute this")] + execute_messages = [Message(role="user", text="Please execute this")] execute_context = AgentContext(agent=mock_agent, messages=execute_messages, stream=False) execute_result = await pipeline.execute(execute_context, final_handler) @@ -262,20 +252,20 @@ class TestResultOverrideMiddleware: assert execute_result.messages[0].text == "executed response" assert handler_called - async def test_function_middleware_conditional_no_next(self, mock_function: FunctionTool[Any, Any]) -> None: + async def test_function_middleware_conditional_no_next(self, mock_function: FunctionTool[Any]) -> None: """Test that when function middleware conditionally doesn't call next(), no execution happens.""" class ConditionalNoNextFunctionMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: # Only call next() if argument name contains "execute" args = context.arguments assert isinstance(args, FunctionTestArgs) if "execute" in args.name: - await call_next(context) + await call_next() # Otherwise, don't call next() - no execution should happen middleware = ConditionalNoNextFunctionMiddleware() @@ -318,14 +308,12 @@ class TestResultObservability: observed_responses: list[AgentResponse] = [] class ObservabilityMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: # Context should be empty before next() assert context.result is None # Call next to execute - await call_next(context) + await call_next() # Context should now contain the response for observability assert context.result is not None @@ -334,11 +322,11 @@ class TestResultObservability: middleware = ObservabilityMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages, stream=False) async def final_handler(ctx: AgentContext) -> AgentResponse: - return AgentResponse(messages=[ChatMessage(role="assistant", text="executed response")]) + return AgentResponse(messages=[Message(role="assistant", text="executed response")]) result = await pipeline.execute(context, final_handler) @@ -347,7 +335,7 @@ class TestResultObservability: assert observed_responses[0].messages[0].text == "executed response" assert result == observed_responses[0] - async def test_function_middleware_result_observability(self, mock_function: FunctionTool[Any, Any]) -> None: + async def test_function_middleware_result_observability(self, mock_function: FunctionTool[Any]) -> None: """Test that middleware can observe function result after execution.""" observed_results: list[str] = [] @@ -355,13 +343,13 @@ class TestResultObservability: async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: # Context should be empty before next() assert context.result is None # Call next to execute - await call_next(context) + await call_next() # Context should now contain the result for observability assert context.result is not None @@ -386,11 +374,9 @@ class TestResultObservability: """Test that middleware can override response after observing execution.""" class PostExecutionOverrideMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: # Call next to execute first - await call_next(context) + await call_next() # Now observe and conditionally override assert context.result is not None @@ -399,16 +385,16 @@ class TestResultObservability: if "modify" in context.result.messages[0].text: # Override after observing context.result = AgentResponse( - messages=[ChatMessage(role="assistant", text="modified after execution")] + messages=[Message(role="assistant", text="modified after execution")] ) middleware = PostExecutionOverrideMiddleware() pipeline = AgentMiddlewarePipeline(middleware) - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] context = AgentContext(agent=mock_agent, messages=messages, stream=False) async def final_handler(ctx: AgentContext) -> AgentResponse: - return AgentResponse(messages=[ChatMessage(role="assistant", text="response to modify")]) + return AgentResponse(messages=[Message(role="assistant", text="response to modify")]) result = await pipeline.execute(context, final_handler) @@ -416,17 +402,17 @@ class TestResultObservability: assert result is not None assert result.messages[0].text == "modified after execution" - async def test_function_middleware_post_execution_override(self, mock_function: FunctionTool[Any, Any]) -> None: + async def test_function_middleware_post_execution_override(self, mock_function: FunctionTool[Any]) -> None: """Test that middleware can override function result after observing execution.""" class PostExecutionOverrideMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: # Call next to execute first - await call_next(context) + await call_next() # Now observe and conditionally override assert context.result is not None @@ -458,8 +444,8 @@ def mock_agent() -> SupportsAgentRun: @pytest.fixture -def mock_function() -> FunctionTool[Any, Any]: +def mock_function() -> FunctionTool[Any]: """Mock function for testing.""" - function = MagicMock(spec=FunctionTool[Any, Any]) + function = MagicMock(spec=FunctionTool[Any]) function.name = "test_function" return function diff --git a/python/packages/core/tests/core/test_middleware_with_agent.py b/python/packages/core/tests/core/test_middleware_with_agent.py index 10cc8b3011..bfdf0def1c 100644 --- a/python/packages/core/tests/core/test_middleware_with_agent.py +++ b/python/packages/core/tests/core/test_middleware_with_agent.py @@ -6,13 +6,11 @@ from typing import Any import pytest from agent_framework import ( + Agent, AgentContext, AgentMiddleware, AgentResponseUpdate, - ChatAgent, - ChatClientProtocol, ChatContext, - ChatMessage, ChatMiddleware, ChatResponse, ChatResponseUpdate, @@ -20,9 +18,11 @@ from agent_framework import ( FunctionInvocationContext, FunctionMiddleware, FunctionTool, + Message, MiddlewareException, MiddlewareTermination, MiddlewareType, + SupportsChatGetResponse, agent_middleware, chat_middleware, function_middleware, @@ -30,33 +30,31 @@ from agent_framework import ( from .conftest import MockBaseChatClient, MockChatClient -# region ChatAgent Tests +# region Agent Tests class TestChatAgentClassBasedMiddleware: - """Test cases for class-based middleware integration with ChatAgent.""" + """Test cases for class-based middleware integration with Agent.""" - async def test_class_based_agent_middleware_with_chat_agent(self, chat_client: ChatClientProtocol) -> None: - """Test class-based agent middleware with ChatAgent.""" + async def test_class_based_agent_middleware_with_chat_agent(self, client: SupportsChatGetResponse) -> None: + """Test class-based agent middleware with Agent.""" execution_order: list[str] = [] class TrackingAgentMiddleware(AgentMiddleware): def __init__(self, name: str): self.name = name - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append(f"{self.name}_before") - await call_next(context) + await call_next() execution_order.append(f"{self.name}_after") - # Create ChatAgent with middleware + # Create Agent with middleware middleware = TrackingAgentMiddleware("agent_middleware") - agent = ChatAgent(chat_client=chat_client, middleware=[middleware]) + agent = Agent(client=client, middleware=[middleware]) # Execute the agent - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await agent.run(messages) # Verify response @@ -69,24 +67,24 @@ class TestChatAgentClassBasedMiddleware: # Verify middleware execution order assert execution_order == ["agent_middleware_before", "agent_middleware_after"] - async def test_class_based_function_middleware_with_chat_agent(self, chat_client: "MockChatClient") -> None: - """Test class-based function middleware with ChatAgent.""" + async def test_class_based_function_middleware_with_chat_agent(self, client: "MockChatClient") -> None: + """Test class-based function middleware with Agent.""" class TrackingFunctionMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: - await call_next(context) + await call_next() middleware = TrackingFunctionMiddleware() - ChatAgent(chat_client=chat_client, middleware=[middleware]) + Agent(client=client, middleware=[middleware]) async def test_class_based_function_middleware_with_chat_agent_supported_client( self, chat_client_base: "MockBaseChatClient" ) -> None: - """Test class-based function middleware with ChatAgent using a full chat client.""" + """Test class-based function middleware with Agent using a full chat client.""" execution_order: list[str] = [] class TrackingFunctionMiddleware(FunctionMiddleware): @@ -96,16 +94,16 @@ class TestChatAgentClassBasedMiddleware: async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: execution_order.append(f"{self.name}_before") - await call_next(context) + await call_next() execution_order.append(f"{self.name}_after") middleware = TrackingFunctionMiddleware("function_middleware") - agent = ChatAgent(chat_client=chat_client_base, middleware=[middleware]) + agent = Agent(client=chat_client_base, middleware=[middleware]) - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await agent.run(messages) assert response is not None @@ -115,30 +113,28 @@ class TestChatAgentClassBasedMiddleware: class TestChatAgentFunctionBasedMiddleware: - """Test cases for function-based middleware integration with ChatAgent.""" + """Test cases for function-based middleware integration with Agent.""" - async def test_agent_middleware_with_pre_termination(self, chat_client: "MockChatClient") -> None: + async def test_agent_middleware_with_pre_termination(self, client: "MockChatClient") -> None: """Test that agent middleware can terminate execution before calling next().""" execution_order: list[str] = [] class PreTerminationMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("middleware_before") raise MiddlewareTermination # Code after raise is unreachable - await call_next(context) + await call_next() execution_order.append("middleware_after") - # Create ChatAgent with terminating middleware + # Create Agent with terminating middleware middleware = PreTerminationMiddleware() - agent = ChatAgent(chat_client=chat_client, middleware=[middleware]) + agent = Agent(client=client, middleware=[middleware]) # Execute the agent with multiple messages messages = [ - ChatMessage(role="user", text="message1"), - ChatMessage(role="user", text="message2"), # This should not be processed due to termination + Message(role="user", text="message1"), + Message(role="user", text="message2"), # This should not be processed due to termination ] response = await agent.run(messages) @@ -146,29 +142,27 @@ class TestChatAgentFunctionBasedMiddleware: assert response is None # Only middleware_before runs - middleware_after is unreachable after raise assert execution_order == ["middleware_before"] - assert chat_client.call_count == 0 # No calls should be made due to termination + assert client.call_count == 0 # No calls should be made due to termination - async def test_agent_middleware_with_post_termination(self, chat_client: "MockChatClient") -> None: + async def test_agent_middleware_with_post_termination(self, client: "MockChatClient") -> None: """Test that agent middleware can terminate execution after calling next().""" execution_order: list[str] = [] class PostTerminationMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("middleware_before") - await call_next(context) + await call_next() execution_order.append("middleware_after") context.terminate = True - # Create ChatAgent with terminating middleware + # Create Agent with terminating middleware middleware = PostTerminationMiddleware() - agent = ChatAgent(chat_client=chat_client, middleware=[middleware]) + agent = Agent(client=client, middleware=[middleware]) # Execute the agent with multiple messages messages = [ - ChatMessage(role="user", text="message1"), - ChatMessage(role="user", text="message2"), + Message(role="user", text="message1"), + Message(role="user", text="message2"), ] response = await agent.run(messages) @@ -183,9 +177,9 @@ class TestChatAgentFunctionBasedMiddleware: "middleware_before", "middleware_after", ] - assert chat_client.call_count == 1 + assert client.call_count == 1 - async def test_function_middleware_with_pre_termination(self, chat_client: "MockChatClient") -> None: + async def test_function_middleware_with_pre_termination(self, client: "MockChatClient") -> None: """Test that function middleware can terminate execution before calling next().""" execution_order: list[str] = [] @@ -193,17 +187,17 @@ class TestChatAgentFunctionBasedMiddleware: async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: execution_order.append("middleware_before") context.terminate = True # We call next() but since terminate=True, subsequent middleware and handler should not execute - await call_next(context) + await call_next() execution_order.append("middleware_after") - ChatAgent(chat_client=chat_client, middleware=[PreTerminationFunctionMiddleware()], tools=[]) + Agent(client=client, middleware=[PreTerminationFunctionMiddleware()], tools=[]) - async def test_function_middleware_with_post_termination(self, chat_client: "MockChatClient") -> None: + async def test_function_middleware_with_post_termination(self, client: "MockChatClient") -> None: """Test that function middleware can terminate execution after calling next().""" execution_order: list[str] = [] @@ -211,31 +205,29 @@ class TestChatAgentFunctionBasedMiddleware: async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: execution_order.append("middleware_before") - await call_next(context) + await call_next() execution_order.append("middleware_after") context.terminate = True - ChatAgent(chat_client=chat_client, middleware=[PostTerminationFunctionMiddleware()], tools=[]) + Agent(client=client, middleware=[PostTerminationFunctionMiddleware()], tools=[]) - async def test_function_based_agent_middleware_with_chat_agent(self, chat_client: "MockChatClient") -> None: - """Test function-based agent middleware with ChatAgent.""" + async def test_function_based_agent_middleware_with_chat_agent(self, client: "MockChatClient") -> None: + """Test function-based agent middleware with Agent.""" execution_order: list[str] = [] - async def tracking_agent_middleware( - context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def tracking_agent_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("agent_function_before") - await call_next(context) + await call_next() execution_order.append("agent_function_after") - # Create ChatAgent with function middleware - agent = ChatAgent(chat_client=chat_client, middleware=[tracking_agent_middleware]) + # Create Agent with function middleware + agent = Agent(client=client, middleware=[tracking_agent_middleware]) # Execute the agent - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await agent.run(messages) # Verify response @@ -243,36 +235,36 @@ class TestChatAgentFunctionBasedMiddleware: assert len(response.messages) > 0 assert response.messages[0].role == "assistant" assert response.messages[0].text == "test response" - assert chat_client.call_count == 1 + assert client.call_count == 1 # Verify middleware execution order assert execution_order == ["agent_function_before", "agent_function_after"] - async def test_function_based_function_middleware_with_chat_agent(self, chat_client: "MockChatClient") -> None: - """Test function-based function middleware with ChatAgent.""" + async def test_function_based_function_middleware_with_chat_agent(self, client: "MockChatClient") -> None: + """Test function-based function middleware with Agent.""" async def tracking_function_middleware( - context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] ) -> None: - await call_next(context) + await call_next() - ChatAgent(chat_client=chat_client, middleware=[tracking_function_middleware]) + Agent(client=client, middleware=[tracking_function_middleware]) async def test_function_based_function_middleware_with_supported_client( self, chat_client_base: "MockBaseChatClient" ) -> None: - """Test function-based function middleware with ChatAgent using a full chat client.""" + """Test function-based function middleware with Agent using a full chat client.""" execution_order: list[str] = [] async def tracking_function_middleware( - context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] ) -> None: execution_order.append("function_function_before") - await call_next(context) + await call_next() execution_order.append("function_function_after") - agent = ChatAgent(chat_client=chat_client_base, middleware=[tracking_function_middleware]) - messages = [ChatMessage(role="user", text="test message")] + agent = Agent(client=chat_client_base, middleware=[tracking_function_middleware]) + messages = [Message(role="user", text="test message")] response = await agent.run(messages) assert response is not None @@ -282,28 +274,26 @@ class TestChatAgentFunctionBasedMiddleware: class TestChatAgentStreamingMiddleware: - """Test cases for streaming middleware integration with ChatAgent.""" + """Test cases for streaming middleware integration with Agent.""" - async def test_agent_middleware_with_streaming(self, chat_client: "MockChatClient") -> None: - """Test agent middleware with streaming ChatAgent responses.""" + async def test_agent_middleware_with_streaming(self, client: "MockChatClient") -> None: + """Test agent middleware with streaming Agent responses.""" execution_order: list[str] = [] streaming_flags: list[bool] = [] class StreamingTrackingMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("middleware_before") streaming_flags.append(context.stream) - await call_next(context) + await call_next() execution_order.append("middleware_after") - # Create ChatAgent with middleware + # Create Agent with middleware middleware = StreamingTrackingMiddleware() - agent = ChatAgent(chat_client=chat_client, middleware=[middleware]) + agent = Agent(client=client, middleware=[middleware]) # Set up mock streaming responses - chat_client.streaming_responses = [ + client.streaming_responses = [ [ ChatResponseUpdate(contents=[Content.from_text(text="Streaming")], role="assistant"), ChatResponseUpdate(contents=[Content.from_text(text=" response")], role="assistant"), @@ -311,7 +301,7 @@ class TestChatAgentStreamingMiddleware: ] # Execute streaming - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] updates: list[AgentResponseUpdate] = [] async for update in agent.run(messages, stream=True): updates.append(update) @@ -320,7 +310,7 @@ class TestChatAgentStreamingMiddleware: assert len(updates) == 2 assert updates[0].text == "Streaming" assert updates[1].text == " response" - assert chat_client.call_count == 1 + assert client.call_count == 1 # Verify middleware was called and streaming flag was set correctly assert execution_order == [ @@ -329,21 +319,19 @@ class TestChatAgentStreamingMiddleware: ] assert streaming_flags == [True] # Context should indicate streaming - async def test_non_streaming_vs_streaming_flag_validation(self, chat_client: "MockChatClient") -> None: + async def test_non_streaming_vs_streaming_flag_validation(self, client: "MockChatClient") -> None: """Test that stream flag is correctly set for different execution modes.""" streaming_flags: list[bool] = [] class FlagTrackingMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: streaming_flags.append(context.stream) - await call_next(context) + await call_next() - # Create ChatAgent with middleware + # Create Agent with middleware middleware = FlagTrackingMiddleware() - agent = ChatAgent(chat_client=chat_client, middleware=[middleware]) - messages = [ChatMessage(role="user", text="test message")] + agent = Agent(client=client, middleware=[middleware]) + messages = [Message(role="user", text="test message")] # Test non-streaming execution response = await agent.run(messages) @@ -358,21 +346,19 @@ class TestChatAgentStreamingMiddleware: class TestChatAgentMultipleMiddlewareOrdering: - """Test cases for multiple middleware execution order with ChatAgent.""" + """Test cases for multiple middleware execution order with Agent.""" - async def test_multiple_agent_middleware_execution_order(self, chat_client: "MockChatClient") -> None: - """Test that multiple agent middleware execute in correct order with ChatAgent.""" + async def test_multiple_agent_middleware_execution_order(self, client: "MockChatClient") -> None: + """Test that multiple agent middleware execute in correct order with Agent.""" execution_order: list[str] = [] class OrderedMiddleware(AgentMiddleware): def __init__(self, name: str): self.name = name - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append(f"{self.name}_before") - await call_next(context) + await call_next() execution_order.append(f"{self.name}_after") # Create multiple middleware @@ -380,59 +366,55 @@ class TestChatAgentMultipleMiddlewareOrdering: middleware2 = OrderedMiddleware("second") middleware3 = OrderedMiddleware("third") - # Create ChatAgent with multiple middleware - agent = ChatAgent(chat_client=chat_client, middleware=[middleware1, middleware2, middleware3]) + # Create Agent with multiple middleware + agent = Agent(client=client, middleware=[middleware1, middleware2, middleware3]) # Execute the agent - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await agent.run(messages) # Verify response assert response is not None - assert chat_client.call_count == 1 + assert client.call_count == 1 # Verify execution order (should be nested: first wraps second wraps third) expected_order = ["first_before", "second_before", "third_before", "third_after", "second_after", "first_after"] assert execution_order == expected_order async def test_mixed_middleware_types_with_chat_agent(self, chat_client_base: "MockBaseChatClient") -> None: - """Test mixed class and function-based middleware with ChatAgent.""" + """Test mixed class and function-based middleware with Agent.""" execution_order: list[str] = [] class ClassAgentMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("class_agent_before") - await call_next(context) + await call_next() execution_order.append("class_agent_after") - async def function_agent_middleware( - context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def function_agent_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("function_agent_before") - await call_next(context) + await call_next() execution_order.append("function_agent_after") class ClassFunctionMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: execution_order.append("class_function_before") - await call_next(context) + await call_next() execution_order.append("class_function_after") async def function_function_middleware( - context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] ) -> None: execution_order.append("function_function_before") - await call_next(context) + await call_next() execution_order.append("function_function_after") - agent = ChatAgent( - chat_client=chat_client_base, + agent = Agent( + client=chat_client_base, middleware=[ ClassAgentMiddleware(), function_agent_middleware, @@ -440,36 +422,32 @@ class TestChatAgentMultipleMiddlewareOrdering: function_function_middleware, ], ) - await agent.run([ChatMessage(role="user", text="test")]) + await agent.run([Message(role="user", text="test")]) async def test_mixed_middleware_types_with_supported_client(self, chat_client_base: "MockBaseChatClient") -> None: """Test mixed class and function-based middleware with a full chat client.""" execution_order: list[str] = [] class ClassAgentMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("class_agent_before") - await call_next(context) + await call_next() execution_order.append("class_agent_after") - async def function_agent_middleware( - context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def function_agent_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("function_agent_before") - await call_next(context) + await call_next() execution_order.append("function_agent_after") async def function_function_middleware( - context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] ) -> None: execution_order.append("function_function_before") - await call_next(context) + await call_next() execution_order.append("function_function_after") - agent = ChatAgent( - chat_client=chat_client_base, + agent = Agent( + client=chat_client_base, middleware=[ ClassAgentMiddleware(), function_agent_middleware, @@ -477,7 +455,7 @@ class TestChatAgentMultipleMiddlewareOrdering: ], ) - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await agent.run(messages) assert response is not None @@ -502,16 +480,16 @@ sample_tool_function = FunctionTool( ) -# region ChatAgent Function MiddlewareTypes Tests with Tools +# region Agent Function MiddlewareTypes Tests with Tools class TestChatAgentFunctionMiddlewareWithTools: - """Test cases for function middleware integration with ChatAgent when tools are used.""" + """Test cases for function middleware integration with Agent when tools are used.""" async def test_class_based_function_middleware_with_tool_calls( self, chat_client_base: "MockBaseChatClient" ) -> None: - """Test class-based function middleware with ChatAgent when function calls are made.""" + """Test class-based function middleware with Agent when function calls are made.""" execution_order: list[str] = [] class TrackingFunctionMiddleware(FunctionMiddleware): @@ -521,16 +499,16 @@ class TestChatAgentFunctionMiddlewareWithTools: async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: execution_order.append(f"{self.name}_before") - await call_next(context) + await call_next() execution_order.append(f"{self.name}_after") # Set up mock to return a function call first, then a regular response function_call_response = ChatResponse( messages=[ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call( @@ -542,20 +520,20 @@ class TestChatAgentFunctionMiddlewareWithTools: ) ] ) - final_response = ChatResponse(messages=[ChatMessage(role="assistant", text="Final response")]) + final_response = ChatResponse(messages=[Message(role="assistant", text="Final response")]) chat_client_base.run_responses = [function_call_response, final_response] - # Create ChatAgent with function middleware and tools + # Create Agent with function middleware and tools middleware = TrackingFunctionMiddleware("function_middleware") - agent = ChatAgent( - chat_client=chat_client_base, + agent = Agent( + client=chat_client_base, middleware=[middleware], tools=[sample_tool_function], ) # Execute the agent - messages = [ChatMessage(role="user", text="Get weather for Seattle")] + messages = [Message(role="user", text="Get weather for Seattle")] response = await agent.run(messages) # Verify response @@ -579,20 +557,20 @@ class TestChatAgentFunctionMiddlewareWithTools: async def test_function_based_function_middleware_with_tool_calls( self, chat_client_base: "MockBaseChatClient" ) -> None: - """Test function-based function middleware with ChatAgent when function calls are made.""" + """Test function-based function middleware with Agent when function calls are made.""" execution_order: list[str] = [] async def tracking_function_middleware( - context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] ) -> None: execution_order.append("function_middleware_before") - await call_next(context) + await call_next() execution_order.append("function_middleware_after") # Set up mock to return a function call first, then a regular response function_call_response = ChatResponse( messages=[ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call( @@ -604,19 +582,19 @@ class TestChatAgentFunctionMiddlewareWithTools: ) ] ) - final_response = ChatResponse(messages=[ChatMessage(role="assistant", text="Final response")]) + final_response = ChatResponse(messages=[Message(role="assistant", text="Final response")]) chat_client_base.run_responses = [function_call_response, final_response] - # Create ChatAgent with function middleware and tools - agent = ChatAgent( - chat_client=chat_client_base, + # Create Agent with function middleware and tools + agent = Agent( + client=chat_client_base, middleware=[tracking_function_middleware], tools=[sample_tool_function], ) # Execute the agent - messages = [ChatMessage(role="user", text="Get weather for San Francisco")] + messages = [Message(role="user", text="Get weather for San Francisco")] response = await agent.run(messages) # Verify response @@ -640,33 +618,33 @@ class TestChatAgentFunctionMiddlewareWithTools: async def test_mixed_agent_and_function_middleware_with_tool_calls( self, chat_client_base: "MockBaseChatClient" ) -> None: - """Test both agent and function middleware with ChatAgent when function calls are made.""" + """Test both agent and function middleware with Agent when function calls are made.""" execution_order: list[str] = [] class TrackingAgentMiddleware(AgentMiddleware): async def process( self, context: AgentContext, - call_next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: execution_order.append("agent_middleware_before") - await call_next(context) + await call_next() execution_order.append("agent_middleware_after") class TrackingFunctionMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: execution_order.append("function_middleware_before") - await call_next(context) + await call_next() execution_order.append("function_middleware_after") # Set up mock to return a function call first, then a regular response function_call_response = ChatResponse( messages=[ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call( @@ -678,19 +656,19 @@ class TestChatAgentFunctionMiddlewareWithTools: ) ] ) - final_response = ChatResponse(messages=[ChatMessage(role="assistant", text="Final response")]) + final_response = ChatResponse(messages=[Message(role="assistant", text="Final response")]) chat_client_base.run_responses = [function_call_response, final_response] - # Create ChatAgent with both agent and function middleware and tools - agent = ChatAgent( - chat_client=chat_client_base, + # Create Agent with both agent and function middleware and tools + agent = Agent( + client=chat_client_base, middleware=[TrackingAgentMiddleware(), TrackingFunctionMiddleware()], tools=[sample_tool_function], ) # Execute the agent - messages = [ChatMessage(role="user", text="Get weather for New York")] + messages = [Message(role="user", text="Get weather for New York")] response = await agent.run(messages) # Verify response @@ -728,7 +706,7 @@ class TestChatAgentFunctionMiddlewareWithTools: @function_middleware async def kwargs_middleware( - context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] ) -> None: nonlocal middleware_called middleware_called = True @@ -748,12 +726,12 @@ class TestChatAgentFunctionMiddlewareWithTools: modified_kwargs["new_param"] = context.kwargs.get("new_param") modified_kwargs["custom_param"] = context.kwargs.get("custom_param") - await call_next(context) + await call_next() chat_client_base.run_responses = [ ChatResponse( messages=[ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call( @@ -763,14 +741,14 @@ class TestChatAgentFunctionMiddlewareWithTools: ) ] ), - ChatResponse(messages=[ChatMessage(role="assistant", contents=[Content.from_text("Function completed")])]), + ChatResponse(messages=[Message(role="assistant", contents=[Content.from_text("Function completed")])]), ] - # Create ChatAgent with function middleware - agent = ChatAgent(chat_client=chat_client_base, middleware=[kwargs_middleware], tools=[sample_tool_function]) + # Create Agent with function middleware + agent = Agent(client=chat_client_base, middleware=[kwargs_middleware], tools=[sample_tool_function]) # Execute the agent with custom parameters passed as kwargs - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await agent.run(messages, options={"additional_function_arguments": {"custom_param": "test_value"}}) # Verify response @@ -792,7 +770,7 @@ class TestChatAgentFunctionMiddlewareWithTools: class TestMiddlewareDynamicRebuild: - """Test cases for dynamic middleware pipeline rebuilding with ChatAgent.""" + """Test cases for dynamic middleware pipeline rebuilding with Agent.""" class TrackingAgentMiddleware(AgentMiddleware): """Test middleware that tracks execution.""" @@ -801,18 +779,18 @@ class TestMiddlewareDynamicRebuild: self.name = name self.execution_log = execution_log - async def process(self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: self.execution_log.append(f"{self.name}_start") - await call_next(context) + await call_next() self.execution_log.append(f"{self.name}_end") - async def test_middleware_dynamic_rebuild_non_streaming(self, chat_client: "MockChatClient") -> None: + async def test_middleware_dynamic_rebuild_non_streaming(self, client: "MockChatClient") -> None: """Test that middleware pipeline is rebuilt when agent.middleware collection is modified for non-streaming.""" execution_log: list[str] = [] # Create agent with initial middleware middleware1 = self.TrackingAgentMiddleware("middleware1", execution_log) - agent = ChatAgent(chat_client=chat_client, middleware=[middleware1]) + agent = Agent(client=client, middleware=[middleware1]) # First execution - should use middleware1 await agent.run("Test message 1") @@ -856,13 +834,13 @@ class TestMiddlewareDynamicRebuild: await agent.run("Test message 4") assert len(execution_log) == 0 - async def test_middleware_dynamic_rebuild_streaming(self, chat_client: "MockChatClient") -> None: + async def test_middleware_dynamic_rebuild_streaming(self, client: "MockChatClient") -> None: """Test that middleware pipeline is rebuilt for streaming when agent.middleware collection is modified.""" execution_log: list[str] = [] # Create agent with initial middleware middleware1 = self.TrackingAgentMiddleware("stream_middleware1", execution_log) - agent = ChatAgent(chat_client=chat_client, middleware=[middleware1]) + agent = Agent(client=client, middleware=[middleware1]) # First streaming execution updates: list[AgentResponseUpdate] = [] @@ -889,7 +867,7 @@ class TestMiddlewareDynamicRebuild: assert "stream_middleware2_start" in execution_log assert "stream_middleware2_end" in execution_log - async def test_middleware_order_change_detection(self, chat_client: "MockChatClient") -> None: + async def test_middleware_order_change_detection(self, client: "MockChatClient") -> None: """Test that changing the order of middleware is detected and applied.""" execution_log: list[str] = [] @@ -897,7 +875,7 @@ class TestMiddlewareDynamicRebuild: middleware2 = self.TrackingAgentMiddleware("second", execution_log) # Create agent with middleware in order [first, second] - agent = ChatAgent(chat_client=chat_client, middleware=[middleware1, middleware2]) + agent = Agent(client=client, middleware=[middleware1, middleware2]) # First execution await agent.run("Test message 1") @@ -924,17 +902,17 @@ class TestRunLevelMiddleware: self.name = name self.execution_log = execution_log - async def process(self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: self.execution_log.append(f"{self.name}_start") - await call_next(context) + await call_next() self.execution_log.append(f"{self.name}_end") - async def test_run_level_middleware_isolation(self, chat_client: "MockChatClient") -> None: + async def test_run_level_middleware_isolation(self, client: "MockChatClient") -> None: """Test that run-level middleware is isolated between multiple runs.""" execution_log: list[str] = [] # Create agent without any agent-level middleware - agent = ChatAgent(chat_client=chat_client) + agent = Agent(client=client) # Create run-level middleware run_middleware1 = self.TrackingAgentMiddleware("run1", execution_log) @@ -967,7 +945,7 @@ class TestRunLevelMiddleware: await agent.run("Test message 4", middleware=[run_middleware1, run_middleware2]) assert execution_log == ["run1_start", "run2_start", "run2_end", "run1_end"] - async def test_agent_plus_run_middleware_execution_order(self, chat_client: "MockChatClient") -> None: + async def test_agent_plus_run_middleware_execution_order(self, client: "MockChatClient") -> None: """Test that agent middleware executes first, followed by run middleware.""" execution_log: list[str] = [] metadata_log: list[str] = [] @@ -976,34 +954,30 @@ class TestRunLevelMiddleware: def __init__(self, name: str): self.name = name - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_log.append(f"{self.name}_start") # Set metadata to pass information to run middleware context.metadata[f"{self.name}_key"] = f"{self.name}_value" - await call_next(context) + await call_next() execution_log.append(f"{self.name}_end") class MetadataRunMiddleware(AgentMiddleware): def __init__(self, name: str): self.name = name - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_log.append(f"{self.name}_start") # Read metadata set by agent middleware for key, value in context.metadata.items(): metadata_log.append(f"{self.name}_reads_{key}:{value}") # Set run-level metadata context.metadata[f"{self.name}_key"] = f"{self.name}_value" - await call_next(context) + await call_next() execution_log.append(f"{self.name}_end") # Create agent with agent-level middleware agent_middleware = MetadataAgentMiddleware("agent") - agent = ChatAgent(chat_client=chat_client, middleware=[agent_middleware]) + agent = Agent(client=client, middleware=[agent_middleware]) # Create run-level middleware run_middleware = MetadataRunMiddleware("run") @@ -1018,12 +992,12 @@ class TestRunLevelMiddleware: # Verify that run middleware can read agent middleware metadata assert "run_reads_agent_key:agent_value" in metadata_log - async def test_run_level_middleware_non_streaming(self, chat_client: "MockChatClient") -> None: + async def test_run_level_middleware_non_streaming(self, client: "MockChatClient") -> None: """Test run-level middleware with non-streaming execution.""" execution_log: list[str] = [] # Create agent without agent-level middleware - agent = ChatAgent(chat_client=chat_client) + agent = Agent(client=client) # Create run-level middleware run_middleware = self.TrackingAgentMiddleware("run_nonstream", execution_log) @@ -1040,7 +1014,7 @@ class TestRunLevelMiddleware: # Verify middleware was executed assert execution_log == ["run_nonstream_start", "run_nonstream_end"] - async def test_run_level_middleware_streaming(self, chat_client: "MockChatClient") -> None: + async def test_run_level_middleware_streaming(self, client: "MockChatClient") -> None: """Test run-level middleware with streaming execution.""" execution_log: list[str] = [] streaming_flags: list[bool] = [] @@ -1049,19 +1023,17 @@ class TestRunLevelMiddleware: def __init__(self, name: str): self.name = name - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_log.append(f"{self.name}_start") streaming_flags.append(context.stream) - await call_next(context) + await call_next() execution_log.append(f"{self.name}_end") # Create agent without agent-level middleware - agent = ChatAgent(chat_client=chat_client) + agent = Agent(client=client) # Set up mock streaming responses - chat_client.streaming_responses = [ + client.streaming_responses = [ [ ChatResponseUpdate(contents=[Content.from_text(text="Stream")], role="assistant"), ChatResponseUpdate(contents=[Content.from_text(text=" response")], role="assistant"), @@ -1093,48 +1065,44 @@ class TestRunLevelMiddleware: # Agent-level middleware class AgentLevelAgentMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_log.append("agent_level_agent_start") context.metadata["agent_level_agent"] = "processed" - await call_next(context) + await call_next() execution_log.append("agent_level_agent_end") class AgentLevelFunctionMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: execution_log.append("agent_level_function_start") context.metadata["agent_level_function"] = "processed" - await call_next(context) + await call_next() execution_log.append("agent_level_function_end") # Run-level middleware class RunLevelAgentMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_log.append("run_level_agent_start") # Verify agent-level middleware metadata is available assert "agent_level_agent" in context.metadata context.metadata["run_level_agent"] = "processed" - await call_next(context) + await call_next() execution_log.append("run_level_agent_end") class RunLevelFunctionMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: execution_log.append("run_level_function_start") # Verify agent-level function middleware metadata is available assert "agent_level_function" in context.metadata context.metadata["run_level_function"] = "processed" - await call_next(context) + await call_next() execution_log.append("run_level_function_end") # Create tool function for testing function middleware @@ -1149,7 +1117,7 @@ class TestRunLevelMiddleware: # Set up mock to return a function call first, then a regular response function_call_response = ChatResponse( messages=[ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call( @@ -1161,12 +1129,12 @@ class TestRunLevelMiddleware: ) ] ) - final_response = ChatResponse(messages=[ChatMessage(role="assistant", text="Final response")]) + final_response = ChatResponse(messages=[Message(role="assistant", text="Final response")]) chat_client_base.run_responses = [function_call_response, final_response] # Create agent with agent-level middleware - agent = ChatAgent( - chat_client=chat_client_base, + agent = Agent( + client=chat_client_base, middleware=[AgentLevelAgentMiddleware(), AgentLevelFunctionMiddleware()], tools=[custom_tool_wrapped], ) @@ -1217,18 +1185,16 @@ class TestMiddlewareDecoratorLogic: execution_order: list[str] = [] @agent_middleware - async def matching_agent_middleware( - context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def matching_agent_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("decorator_type_match_agent") - await call_next(context) + await call_next() @function_middleware async def matching_function_middleware( - context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] ) -> None: execution_order.append("decorator_type_match_function") - await call_next(context) + await call_next() # Create tool function for testing function middleware def custom_tool(message: str) -> str: @@ -1242,7 +1208,7 @@ class TestMiddlewareDecoratorLogic: # Set up mock to return a function call first, then a regular response function_call_response = ChatResponse( messages=[ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call( @@ -1254,23 +1220,23 @@ class TestMiddlewareDecoratorLogic: ) ] ) - final_response = ChatResponse(messages=[ChatMessage(role="assistant", text="Final response")]) + final_response = ChatResponse(messages=[Message(role="assistant", text="Final response")]) chat_client_base.responses = [function_call_response, final_response] # Should work without errors - agent = ChatAgent( - chat_client=chat_client_base, + agent = Agent( + client=chat_client_base, middleware=[matching_agent_middleware, matching_function_middleware], tools=[custom_tool_wrapped], ) - response = await agent.run([ChatMessage(role="user", text="test")]) + response = await agent.run([Message(role="user", text="test")]) assert response is not None assert "decorator_type_match_agent" in execution_order assert "decorator_type_match_function" not in execution_order - async def test_decorator_and_type_mismatch(self, chat_client: MockChatClient) -> None: + async def test_decorator_and_type_mismatch(self, client: MockChatClient) -> None: """Both decorator and parameter type specified but don't match.""" # This will cause a type error at decoration time, so we need to test differently @@ -1282,10 +1248,10 @@ class TestMiddlewareDecoratorLogic: context: FunctionInvocationContext, # Wrong type for @agent_middleware call_next: Any, ) -> None: - await call_next(context) + await call_next() - agent = ChatAgent(chat_client=chat_client, middleware=[mismatched_middleware]) - await agent.run([ChatMessage(role="user", text="test")]) + agent = Agent(client=client, middleware=[mismatched_middleware]) + await agent.run([Message(role="user", text="test")]) async def test_only_decorator_specified(self, chat_client_base: "MockBaseChatClient") -> None: """Only decorator specified - rely on decorator.""" @@ -1294,12 +1260,12 @@ class TestMiddlewareDecoratorLogic: @agent_middleware async def decorator_only_agent(context: Any, call_next: Any) -> None: # No type annotation execution_order.append("decorator_only_agent") - await call_next(context) + await call_next() @function_middleware async def decorator_only_function(context: Any, call_next: Any) -> None: # No type annotation execution_order.append("decorator_only_function") - await call_next(context) + await call_next() # Create tool function for testing function middleware def custom_tool(message: str) -> str: @@ -1313,7 +1279,7 @@ class TestMiddlewareDecoratorLogic: # Set up mock to return a function call first, then a regular response function_call_response = ChatResponse( messages=[ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call( @@ -1325,17 +1291,17 @@ class TestMiddlewareDecoratorLogic: ) ] ) - final_response = ChatResponse(messages=[ChatMessage(role="assistant", text="Final response")]) + final_response = ChatResponse(messages=[Message(role="assistant", text="Final response")]) chat_client_base.responses = [function_call_response, final_response] # Should work - relies on decorator - agent = ChatAgent( - chat_client=chat_client_base, + agent = Agent( + client=chat_client_base, middleware=[decorator_only_agent, decorator_only_function], tools=[custom_tool_wrapped], ) - response = await agent.run([ChatMessage(role="user", text="test")]) + response = await agent.run([Message(role="user", text="test")]) assert response is not None assert "decorator_only_agent" in execution_order @@ -1346,16 +1312,16 @@ class TestMiddlewareDecoratorLogic: execution_order: list[str] = [] # No decorator - async def type_only_agent(context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def type_only_agent(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("type_only_agent") - await call_next(context) + await call_next() # No decorator async def type_only_function( - context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] ) -> None: execution_order.append("type_only_function") - await call_next(context) + await call_next() # Create tool function for testing function middleware def custom_tool(message: str) -> str: @@ -1369,7 +1335,7 @@ class TestMiddlewareDecoratorLogic: # Set up mock to return a function call first, then a regular response function_call_response = ChatResponse( messages=[ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call( @@ -1381,34 +1347,34 @@ class TestMiddlewareDecoratorLogic: ) ] ) - final_response = ChatResponse(messages=[ChatMessage(role="assistant", text="Final response")]) + final_response = ChatResponse(messages=[Message(role="assistant", text="Final response")]) chat_client_base.responses = [function_call_response, final_response] # Should work - relies on type annotations - agent = ChatAgent( - chat_client=chat_client_base, middleware=[type_only_agent, type_only_function], tools=[custom_tool_wrapped] + agent = Agent( + client=chat_client_base, middleware=[type_only_agent, type_only_function], tools=[custom_tool_wrapped] ) - response = await agent.run([ChatMessage(role="user", text="test")]) + response = await agent.run([Message(role="user", text="test")]) assert response is not None assert "type_only_agent" in execution_order assert "type_only_function" not in execution_order - async def test_neither_decorator_nor_type(self, chat_client: Any) -> None: + async def test_neither_decorator_nor_type(self, client: Any) -> None: """Neither decorator nor parameter type specified - should throw exception.""" async def no_info_middleware(context: Any, call_next: Any) -> None: # No decorator, no type - await call_next(context) + await call_next() # Should raise MiddlewareException with pytest.raises(MiddlewareException, match="Cannot determine middleware type"): - agent = ChatAgent(chat_client=chat_client, middleware=[no_info_middleware]) - await agent.run([ChatMessage(role="user", text="test")]) + agent = Agent(client=client, middleware=[no_info_middleware]) + await agent.run([Message(role="user", text="test")]) - async def test_insufficient_parameters_error(self, chat_client: Any) -> None: + async def test_insufficient_parameters_error(self, client: Any) -> None: """Test that middleware with insufficient parameters raises an error.""" - from agent_framework import ChatAgent, agent_middleware + from agent_framework import Agent, agent_middleware # Should raise MiddlewareException about insufficient parameters with pytest.raises(MiddlewareException, match="must have at least 2 parameters"): @@ -1417,8 +1383,8 @@ class TestMiddlewareDecoratorLogic: async def insufficient_params_middleware(context: Any) -> None: # Missing 'next' parameter pass - agent = ChatAgent(chat_client=chat_client, middleware=[insufficient_params_middleware]) - await agent.run([ChatMessage(role="user", text="test")]) + agent = Agent(client=client, middleware=[insufficient_params_middleware]) + await agent.run([Message(role="user", text="test")]) async def test_decorator_markers_preserved(self) -> None: """Test that decorator markers are properly set on functions.""" @@ -1439,21 +1405,19 @@ class TestMiddlewareDecoratorLogic: assert test_function_middleware._middleware_type == MiddlewareType.FUNCTION # type: ignore[attr-defined] -class TestChatAgentThreadBehavior: - """Test cases for thread behavior in AgentContext across multiple runs.""" +class TestChatAgentSessionBehavior: + """Test cases for session behavior in AgentContext across multiple runs.""" - async def test_agent_context_thread_behavior_across_multiple_runs(self, chat_client: "MockChatClient") -> None: - """Test that AgentContext.thread property behaves correctly across multiple agent runs.""" + async def test_agent_context_session_behavior_across_multiple_runs(self, client: "MockChatClient") -> None: + """Test that AgentContext.session property behaves correctly across multiple agent runs.""" thread_states: list[dict[str, Any]] = [] - class ThreadTrackingMiddleware(AgentMiddleware): - async def process( - self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + class SessionTrackingMiddleware(AgentMiddleware): + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: # Capture state before next() call thread_messages = [] - if context.thread and context.thread.message_store: - thread_messages = await context.thread.message_store.list_messages() + if context.session and context.session.state.get("memory"): + thread_messages = context.session.state.get("memory", {}).get("messages", []) before_state = { "before_next": True, @@ -1464,12 +1428,12 @@ class TestChatAgentThreadBehavior: } thread_states.append(before_state) - await call_next(context) + await call_next() # Capture state after next() call thread_messages_after = [] - if context.thread and context.thread.message_store: - thread_messages_after = await context.thread.message_store.list_messages() + if context.session and context.session.state.get("memory"): + thread_messages_after = context.session.state.get("memory", {}).get("messages", []) after_state = { "before_next": False, @@ -1480,27 +1444,24 @@ class TestChatAgentThreadBehavior: } thread_states.append(after_state) - # Import the ChatMessageStore to configure the agent with a message store factory - from agent_framework import ChatMessageStore + # Create Agent with session tracking middleware + middleware = SessionTrackingMiddleware() + agent = Agent(client=client, middleware=[middleware]) - # Create ChatAgent with thread tracking middleware and a message store factory - middleware = ThreadTrackingMiddleware() - agent = ChatAgent(chat_client=chat_client, middleware=[middleware], chat_message_store_factory=ChatMessageStore) - - # Create a thread that will persist messages between runs - thread = agent.get_new_thread() + # Create a session that will persist messages between runs + session = agent.create_session() # First run - first_messages = [ChatMessage(role="user", text="first message")] - first_response = await agent.run(first_messages, thread=thread) + first_messages = [Message(role="user", text="first message")] + first_response = await agent.run(first_messages, session=session) # Verify first response assert first_response is not None assert len(first_response.messages) > 0 # Second run - use the same thread - second_messages = [ChatMessage(role="user", text="second message")] - second_response = await agent.run(second_messages, thread=thread) + second_messages = [Message(role="user", text="second message")] + second_response = await agent.run(second_messages, session=session) # Verify second response assert second_response is not None @@ -1553,25 +1514,25 @@ class TestChatAgentThreadBehavior: class TestChatAgentChatMiddleware: - """Test cases for chat middleware integration with ChatAgent.""" + """Test cases for chat middleware integration with Agent.""" async def test_class_based_chat_middleware_with_chat_agent(self) -> None: - """Test class-based chat middleware with ChatAgent.""" + """Test class-based chat middleware with Agent.""" execution_order: list[str] = [] class TrackingChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("chat_middleware_before") - await call_next(context) + await call_next() execution_order.append("chat_middleware_after") - # Create ChatAgent with chat middleware - chat_client = MockBaseChatClient() + # Create Agent with chat middleware + client = MockBaseChatClient() middleware = TrackingChatMiddleware() - agent = ChatAgent(chat_client=chat_client, middleware=[middleware]) + agent = Agent(client=client, middleware=[middleware]) # Execute the agent - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await agent.run(messages) # Verify response @@ -1585,22 +1546,20 @@ class TestChatAgentChatMiddleware: ] async def test_function_based_chat_middleware_with_chat_agent(self) -> None: - """Test function-based chat middleware with ChatAgent.""" + """Test function-based chat middleware with Agent.""" execution_order: list[str] = [] - async def tracking_chat_middleware( - context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] - ) -> None: + async def tracking_chat_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("chat_middleware_before") - await call_next(context) + await call_next() execution_order.append("chat_middleware_after") - # Create ChatAgent with function-based chat middleware - chat_client = MockBaseChatClient() - agent = ChatAgent(chat_client=chat_client, middleware=[tracking_chat_middleware]) + # Create Agent with function-based chat middleware + client = MockBaseChatClient() + agent = Agent(client=client, middleware=[tracking_chat_middleware]) # Execute the agent - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await agent.run(messages) # Verify response @@ -1617,25 +1576,23 @@ class TestChatAgentChatMiddleware: """Test that chat middleware can modify messages before sending to model.""" @chat_middleware - async def message_modifier_middleware( - context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] - ) -> None: + async def message_modifier_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: # Modify the first message by adding a prefix if context.messages: for idx, msg in enumerate(context.messages): if msg.role == "system": continue original_text = msg.text or "" - context.messages[idx] = ChatMessage(role=msg.role, text=f"MODIFIED: {original_text}") + context.messages[idx] = Message(role=msg.role, text=f"MODIFIED: {original_text}") break - await call_next(context) + await call_next() - # Create ChatAgent with message-modifying middleware - chat_client = MockBaseChatClient() - agent = ChatAgent(chat_client=chat_client, middleware=[message_modifier_middleware]) + # Create Agent with message-modifying middleware + client = MockBaseChatClient() + agent = Agent(client=client, middleware=[message_modifier_middleware]) # Execute the agent - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await agent.run(messages) # Verify that the message was modified (MockBaseChatClient echoes back the input) @@ -1646,22 +1603,20 @@ class TestChatAgentChatMiddleware: """Test that chat middleware can override the response.""" @chat_middleware - async def response_override_middleware( - context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] - ) -> None: + async def response_override_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: # Override the response without calling next() context.result = ChatResponse( - messages=[ChatMessage(role="assistant", text="MiddlewareTypes overridden response")], + messages=[Message(role="assistant", text="MiddlewareTypes overridden response")], response_id="middleware-response-123", ) context.terminate = True - # Create ChatAgent with response-overriding middleware - chat_client = MockBaseChatClient() - agent = ChatAgent(chat_client=chat_client, middleware=[response_override_middleware]) + # Create Agent with response-overriding middleware + client = MockBaseChatClient() + agent = Agent(client=client, middleware=[response_override_middleware]) # Execute the agent - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await agent.run(messages) # Verify that the response was overridden @@ -1675,23 +1630,23 @@ class TestChatAgentChatMiddleware: execution_order: list[str] = [] @chat_middleware - async def first_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def first_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("first_before") - await call_next(context) + await call_next() execution_order.append("first_after") @chat_middleware - async def second_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def second_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("second_before") - await call_next(context) + await call_next() execution_order.append("second_after") - # Create ChatAgent with multiple chat middleware - chat_client = MockBaseChatClient() - agent = ChatAgent(chat_client=chat_client, middleware=[first_middleware, second_middleware]) + # Create Agent with multiple chat middleware + client = MockBaseChatClient() + agent = Agent(client=client, middleware=[first_middleware, second_middleware]) # Execute the agent - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await agent.run(messages) # Verify response @@ -1709,19 +1664,19 @@ class TestChatAgentChatMiddleware: streaming_flags: list[bool] = [] class StreamingTrackingChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("streaming_chat_before") streaming_flags.append(context.stream) - await call_next(context) + await call_next() execution_order.append("streaming_chat_after") - # Create ChatAgent with chat middleware - chat_client = MockBaseChatClient() - agent = ChatAgent(chat_client=chat_client, middleware=[StreamingTrackingChatMiddleware()]) + # Create Agent with chat middleware + client = MockBaseChatClient() + agent = Agent(client=client, middleware=[StreamingTrackingChatMiddleware()]) # Set up mock streaming responses # TODO: refactor to return a ResponseStream object - chat_client.streaming_responses = [ + client.streaming_responses = [ [ ChatResponseUpdate(contents=[Content.from_text(text="Stream")], role="assistant"), ChatResponseUpdate(contents=[Content.from_text(text=" response")], role="assistant"), @@ -1729,7 +1684,7 @@ class TestChatAgentChatMiddleware: ] # Execute streaming - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] updates: list[AgentResponseUpdate] = [] async for update in agent.run(messages, stream=True): updates.append(update) @@ -1749,21 +1704,21 @@ class TestChatAgentChatMiddleware: execution_order: list[str] = [] class PreTerminationChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("middleware_before") # Set a custom response since we're terminating - context.result = ChatResponse(messages=[ChatMessage(role="assistant", text="Terminated by middleware")]) + context.result = ChatResponse(messages=[Message(role="assistant", text="Terminated by middleware")]) raise MiddlewareTermination # We call next() but since terminate=True, execution should stop - await call_next(context) + await call_next() execution_order.append("middleware_after") - # Create ChatAgent with terminating middleware - chat_client = MockBaseChatClient() - agent = ChatAgent(chat_client=chat_client, middleware=[PreTerminationChatMiddleware()]) + # Create Agent with terminating middleware + client = MockBaseChatClient() + agent = Agent(client=client, middleware=[PreTerminationChatMiddleware()]) # Execute the agent - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await agent.run(messages) # Verify response was from middleware @@ -1777,18 +1732,18 @@ class TestChatAgentChatMiddleware: execution_order: list[str] = [] class PostTerminationChatMiddleware(ChatMiddleware): - async def process(self, context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("middleware_before") - await call_next(context) + await call_next() execution_order.append("middleware_after") context.terminate = True - # Create ChatAgent with terminating middleware - chat_client = MockBaseChatClient() - agent = ChatAgent(chat_client=chat_client, middleware=[PostTerminationChatMiddleware()]) + # Create Agent with terminating middleware + client = MockBaseChatClient() + agent = Agent(client=client, middleware=[PostTerminationChatMiddleware()]) # Execute the agent - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await agent.run(messages) # Verify response is from actual execution @@ -1801,33 +1756,33 @@ class TestChatAgentChatMiddleware: ] async def test_combined_middleware(self) -> None: - """Test ChatAgent with combined middleware types.""" + """Test Agent with combined middleware types.""" execution_order: list[str] = [] - async def agent_middleware(context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def agent_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("agent_middleware_before") - await call_next(context) + await call_next() execution_order.append("agent_middleware_after") - async def chat_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def chat_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("chat_middleware_before") - await call_next(context) + await call_next() execution_order.append("chat_middleware_after") async def function_middleware( - context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] ) -> None: execution_order.append("function_middleware_before") - await call_next(context) + await call_next() execution_order.append("function_middleware_after") - # Create ChatAgent with function middleware and tools - agent = ChatAgent( - chat_client=MockBaseChatClient(), + # Create Agent with function middleware and tools + agent = Agent( + client=MockBaseChatClient(), middleware=[chat_middleware, function_middleware, agent_middleware], tools=[sample_tool_function], ) - await agent.run([ChatMessage(role="user", text="test")]) + await agent.run([Message(role="user", text="test")]) assert execution_order == [ "agent_middleware_before", @@ -1842,9 +1797,7 @@ class TestChatAgentChatMiddleware: modified_kwargs: dict[str, Any] = {} @agent_middleware - async def kwargs_middleware( - context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] - ) -> None: + async def kwargs_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: # Capture the original kwargs captured_kwargs.update(context.kwargs) @@ -1856,14 +1809,14 @@ class TestChatAgentChatMiddleware: # Store modified kwargs for verification modified_kwargs.update(context.kwargs) - await call_next(context) + await call_next() - # Create ChatAgent with agent middleware - chat_client = MockBaseChatClient() - agent = ChatAgent(chat_client=chat_client, middleware=[kwargs_middleware]) + # Create Agent with agent middleware + client = MockBaseChatClient() + agent = Agent(client=client, middleware=[kwargs_middleware]) # Execute the agent with custom parameters - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await agent.run(messages, temperature=0.7, max_tokens=100, custom_param="test_value") # Verify response @@ -1895,10 +1848,10 @@ class TestChatAgentChatMiddleware: # class TrackingMiddleware(AgentMiddleware): # async def process( -# self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]] +# self, context: AgentContext, call_next: Callable[[], Awaitable[None]] # ) -> None: # execution_order.append("before") -# await call_next(context) +# await call_next() # execution_order.append("after") # @use_agent_middleware @@ -1920,7 +1873,7 @@ class TestChatAgentChatMiddleware: # yield AgentResponseUpdate() # return _stream() -# return AgentResponse(messages=[ChatMessage(role="assistant", text="response")]) +# return AgentResponse(messages=[Message(role="assistant", text="response")]) # def get_new_thread(self, **kwargs): # return None diff --git a/python/packages/core/tests/core/test_middleware_with_chat.py b/python/packages/core/tests/core/test_middleware_with_chat.py index 15621f759f..62a168ccb0 100644 --- a/python/packages/core/tests/core/test_middleware_with_chat.py +++ b/python/packages/core/tests/core/test_middleware_with_chat.py @@ -4,16 +4,16 @@ from collections.abc import Awaitable, Callable from typing import Any from agent_framework import ( - ChatAgent, - ChatClientProtocol, + Agent, ChatContext, - ChatMessage, ChatMiddleware, ChatResponse, ChatResponseUpdate, Content, FunctionInvocationContext, FunctionTool, + Message, + SupportsChatGetResponse, chat_middleware, function_middleware, ) @@ -24,7 +24,7 @@ from .conftest import MockBaseChatClient class TestChatMiddleware: """Test cases for chat middleware functionality.""" - async def test_class_based_chat_middleware(self, chat_client_base: ChatClientProtocol) -> None: + async def test_class_based_chat_middleware(self, chat_client_base: SupportsChatGetResponse) -> None: """Test class-based chat middleware with ChatClient.""" execution_order: list[str] = [] @@ -32,17 +32,17 @@ class TestChatMiddleware: async def process( self, context: ChatContext, - call_next: Callable[[ChatContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: execution_order.append("chat_middleware_before") - await call_next(context) + await call_next() execution_order.append("chat_middleware_after") # Add middleware to chat client chat_client_base.chat_middleware = [LoggingChatMiddleware()] # Execute chat client directly - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await chat_client_base.get_response(messages) # Verify response @@ -58,18 +58,16 @@ class TestChatMiddleware: execution_order: list[str] = [] @chat_middleware - async def logging_chat_middleware( - context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] - ) -> None: + async def logging_chat_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("function_middleware_before") - await call_next(context) + await call_next() execution_order.append("function_middleware_after") # Add middleware to chat client chat_client_base.chat_middleware = [logging_chat_middleware] # Execute chat client directly - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await chat_client_base.get_response(messages) # Verify response @@ -84,20 +82,18 @@ class TestChatMiddleware: """Test that chat middleware can modify messages before sending to model.""" @chat_middleware - async def message_modifier_middleware( - context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] - ) -> None: + async def message_modifier_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: # Modify the first message by adding a prefix if context.messages and len(context.messages) > 0: original_text = context.messages[0].text or "" - context.messages[0] = ChatMessage(role=context.messages[0].role, text=f"MODIFIED: {original_text}") - await call_next(context) + context.messages[0] = Message(role=context.messages[0].role, text=f"MODIFIED: {original_text}") + await call_next() # Add middleware to chat client chat_client_base.chat_middleware = [message_modifier_middleware] # Execute chat client - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await chat_client_base.get_response(messages) # Verify that the message was modified (MockChatClient echoes back the input) @@ -110,12 +106,10 @@ class TestChatMiddleware: """Test that chat middleware can override the response.""" @chat_middleware - async def response_override_middleware( - context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] - ) -> None: + async def response_override_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: # Override the response without calling next() context.result = ChatResponse( - messages=[ChatMessage(role="assistant", text="MiddlewareTypes overridden response")], + messages=[Message(role="assistant", text="MiddlewareTypes overridden response")], response_id="middleware-response-123", ) context.terminate = True @@ -124,7 +118,7 @@ class TestChatMiddleware: chat_client_base.chat_middleware = [response_override_middleware] # Execute chat client - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await chat_client_base.get_response(messages) # Verify that the response was overridden @@ -138,22 +132,22 @@ class TestChatMiddleware: execution_order: list[str] = [] @chat_middleware - async def first_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def first_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("first_before") - await call_next(context) + await call_next() execution_order.append("first_after") @chat_middleware - async def second_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def second_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("second_before") - await call_next(context) + await call_next() execution_order.append("second_after") # Add middleware to chat client (order should be preserved) chat_client_base.chat_middleware = [first_middleware, second_middleware] # Execute chat client - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await chat_client_base.get_response(messages) # Verify response @@ -169,24 +163,22 @@ class TestChatMiddleware: assert execution_order == expected_order async def test_chat_agent_with_chat_middleware(self) -> None: - """Test ChatAgent with chat middleware specified at agent level.""" + """Test Agent with chat middleware specified at agent level.""" execution_order: list[str] = [] @chat_middleware - async def agent_level_chat_middleware( - context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] - ) -> None: + async def agent_level_chat_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("agent_chat_middleware_before") - await call_next(context) + await call_next() execution_order.append("agent_chat_middleware_after") - chat_client = MockBaseChatClient() + client = MockBaseChatClient() - # Create ChatAgent with chat middleware - agent = ChatAgent(chat_client=chat_client, middleware=[agent_level_chat_middleware]) + # Create Agent with chat middleware + agent = Agent(client=client, middleware=[agent_level_chat_middleware]) # Execute the agent - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await agent.run(messages) # Verify response @@ -201,26 +193,26 @@ class TestChatMiddleware: ] async def test_chat_agent_with_multiple_chat_middleware(self, chat_client_base: "MockBaseChatClient") -> None: - """Test that ChatAgent can have multiple chat middleware.""" + """Test that Agent can have multiple chat middleware.""" execution_order: list[str] = [] @chat_middleware - async def first_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def first_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("first_before") - await call_next(context) + await call_next() execution_order.append("first_after") @chat_middleware - async def second_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def second_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("second_before") - await call_next(context) + await call_next() execution_order.append("second_after") - # Create ChatAgent with multiple chat middleware - agent = ChatAgent(chat_client=chat_client_base, middleware=[first_middleware, second_middleware]) + # Create Agent with multiple chat middleware + agent = Agent(client=chat_client_base, middleware=[first_middleware, second_middleware]) # Execute the agent - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await agent.run(messages) # Verify response @@ -240,9 +232,7 @@ class TestChatMiddleware: execution_order: list[str] = [] @chat_middleware - async def streaming_middleware( - context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] - ) -> None: + async def streaming_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_order.append("streaming_before") # Verify it's a streaming context assert context.stream is True @@ -254,14 +244,14 @@ class TestChatMiddleware: return update context.stream_transform_hooks.append(upper_case_update) - await call_next(context) + await call_next() execution_order.append("streaming_after") # Add middleware to chat client chat_client_base.chat_middleware = [streaming_middleware] # Execute streaming response - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] updates: list[object] = [] async for update in chat_client_base.get_response(messages, stream=True): updates.append(update) @@ -278,26 +268,24 @@ class TestChatMiddleware: execution_count = {"count": 0} @chat_middleware - async def counting_middleware( - context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]] - ) -> None: + async def counting_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: execution_count["count"] += 1 - await call_next(context) + await call_next() # First call with run-level middleware - messages = [ChatMessage(role="user", text="first message")] + messages = [Message(role="user", text="first message")] response1 = await chat_client_base.get_response(messages, middleware=[counting_middleware]) assert response1 is not None assert execution_count["count"] == 1 # Second call WITHOUT run-level middleware - should not execute the middleware - messages = [ChatMessage(role="user", text="second message")] + messages = [Message(role="user", text="second message")] response2 = await chat_client_base.get_response(messages) assert response2 is not None assert execution_count["count"] == 1 # Should still be 1, not 2 # Third call with run-level middleware again - should execute - messages = [ChatMessage(role="user", text="third message")] + messages = [Message(role="user", text="third message")] response3 = await chat_client_base.get_response(messages, middleware=[counting_middleware]) assert response3 is not None assert execution_count["count"] == 2 # Should be 2 now @@ -310,7 +298,7 @@ class TestChatMiddleware: modified_kwargs: dict[str, Any] = {} @chat_middleware - async def kwargs_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: + async def kwargs_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: # Capture the original kwargs captured_kwargs.update(context.kwargs) @@ -322,13 +310,13 @@ class TestChatMiddleware: # Store modified kwargs for verification modified_kwargs.update(context.kwargs) - await call_next(context) + await call_next() # Add middleware to chat client chat_client_base.chat_middleware = [kwargs_middleware] # Execute chat client with custom parameters - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] response = await chat_client_base.get_response( messages, temperature=0.7, max_tokens=100, custom_param="test_value" ) @@ -355,11 +343,11 @@ class TestChatMiddleware: @function_middleware async def test_function_middleware( - context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] ) -> None: nonlocal execution_order execution_order.append(f"function_middleware_before_{context.function.name}") - await call_next(context) + await call_next() execution_order.append(f"function_middleware_after_{context.function.name}") # Define a simple tool function @@ -375,15 +363,15 @@ class TestChatMiddleware: ) # Create function-invocation enabled chat client (MockBaseChatClient already includes FunctionInvocationLayer) - chat_client = MockBaseChatClient() + client = MockBaseChatClient() # Set function middleware directly on the chat client - chat_client.function_middleware = [test_function_middleware] + client.function_middleware = [test_function_middleware] # Prepare responses that will trigger function invocation function_call_response = ChatResponse( messages=[ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call( @@ -396,18 +384,18 @@ class TestChatMiddleware: ] ) final_response = ChatResponse( - messages=[ChatMessage(role="assistant", text="Based on the weather data, it's sunny!")] + messages=[Message(role="assistant", text="Based on the weather data, it's sunny!")] ) - chat_client.run_responses = [function_call_response, final_response] + client.run_responses = [function_call_response, final_response] # Execute the chat client directly with tools - this should trigger function invocation and middleware - messages = [ChatMessage(role="user", text="What's the weather in San Francisco?")] - response = await chat_client.get_response(messages, options={"tools": [sample_tool_wrapped]}) + messages = [Message(role="user", text="What's the weather in San Francisco?")] + response = await client.get_response(messages, options={"tools": [sample_tool_wrapped]}) # Verify response assert response is not None assert len(response.messages) > 0 - assert chat_client.call_count == 2 # Two calls: function call + final response + assert client.call_count == 2 # Two calls: function call + final response # Verify function middleware was executed assert execution_order == [ @@ -421,10 +409,10 @@ class TestChatMiddleware: @function_middleware async def run_level_function_middleware( - context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] ) -> None: execution_order.append("run_level_function_middleware_before") - await call_next(context) + await call_next() execution_order.append("run_level_function_middleware_after") # Define a simple tool function @@ -440,12 +428,12 @@ class TestChatMiddleware: ) # Create function-invocation enabled chat client (MockBaseChatClient already includes FunctionInvocationLayer) - chat_client = MockBaseChatClient() + client = MockBaseChatClient() # Prepare responses that will trigger function invocation function_call_response = ChatResponse( messages=[ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call( @@ -457,18 +445,18 @@ class TestChatMiddleware: ) ] ) - chat_client.run_responses = [function_call_response] + client.run_responses = [function_call_response] # Execute the chat client directly with run-level middleware and tools - messages = [ChatMessage(role="user", text="What's the weather in New York?")] - response = await chat_client.get_response( + messages = [Message(role="user", text="What's the weather in New York?")] + response = await client.get_response( messages, options={"tools": [sample_tool_wrapped]}, middleware=[run_level_function_middleware] ) # Verify response assert response is not None assert len(response.messages) > 0 - assert chat_client.call_count == 2 # Two calls: function call + final response + assert client.call_count == 2 # Two calls: function call + final response # Verify run-level function middleware was executed once (during function invocation) assert execution_order == [ diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index a85f851957..83f3cbf304 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -14,10 +14,10 @@ from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, AgentResponse, BaseChatClient, - ChatMessage, ChatResponse, ChatResponseUpdate, Content, + Message, ResponseStream, SupportsAgentRun, UsageDetails, @@ -27,9 +27,10 @@ from agent_framework import ( from agent_framework.observability import ( ROLE_EVENT_MAP, AgentTelemetryLayer, - ChatMessageListTimestampFilter, ChatTelemetryLayer, + MessageListTimestampFilter, OtelAttr, + _capture_messages, get_function_span, ) @@ -54,12 +55,12 @@ def test_enum_values(): assert OtelAttr.AGENT_INVOKE_OPERATION == "invoke_agent" -# region Test ChatMessageListTimestampFilter +# region Test MessageListTimestampFilter def test_filter_without_index_key(): """Test filter method when record doesn't have INDEX_KEY.""" - log_filter = ChatMessageListTimestampFilter() + log_filter = MessageListTimestampFilter() record = logging.LogRecord( name="test", level=logging.INFO, pathname="", lineno=0, msg="test message", args=(), exc_info=None ) @@ -73,14 +74,14 @@ def test_filter_without_index_key(): def test_filter_with_index_key(): """Test filter method when record has INDEX_KEY.""" - log_filter = ChatMessageListTimestampFilter() + log_filter = MessageListTimestampFilter() record = logging.LogRecord( name="test", level=logging.INFO, pathname="", lineno=0, msg="test message", args=(), exc_info=None ) original_created = record.created # Add the index key - setattr(record, ChatMessageListTimestampFilter.INDEX_KEY, 5) + setattr(record, MessageListTimestampFilter.INDEX_KEY, 5) result = log_filter.filter(record) @@ -91,7 +92,7 @@ def test_filter_with_index_key(): def test_index_key_constant(): """Test that INDEX_KEY constant is correctly defined.""" - assert ChatMessageListTimestampFilter.INDEX_KEY == "chat_message_index" + assert MessageListTimestampFilter.INDEX_KEY == "chat_message_index" # region Test get_function_span @@ -162,7 +163,7 @@ def mock_chat_client(): return "https://test.example.com" def _inner_get_response( - self, *, messages: MutableSequence[ChatMessage], stream: bool, options: dict[str, Any], **kwargs: Any + self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: if stream: return self._get_streaming_response(messages=messages, options=options, **kwargs) @@ -173,16 +174,16 @@ def mock_chat_client(): return _get() async def _get_non_streaming_response( - self, *, messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + self, *, messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> ChatResponse: return ChatResponse( - messages=[ChatMessage("assistant", ["Test response"])], + messages=[Message("assistant", ["Test response"])], usage_details=UsageDetails(input_token_count=10, output_token_count=20), finish_reason=None, ) def _get_streaming_response( - self, *, messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any + self, *, messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any ) -> ResponseStream[ChatResponseUpdate, ChatResponse]: async def _stream() -> AsyncIterable[ChatResponseUpdate]: yield ChatResponseUpdate(contents=[Content.from_text("Hello")], role="assistant") @@ -203,7 +204,7 @@ async def test_chat_client_observability(mock_chat_client, span_exporter: InMemo """Test that when diagnostics are enabled, telemetry is applied.""" client = mock_chat_client() - messages = [ChatMessage(role="user", text="Test message")] + messages = [Message(role="user", text="Test message")] span_exporter.clear() response = await client.get_response(messages=messages, model_id="Test") assert response is not None @@ -226,7 +227,7 @@ async def test_chat_client_streaming_observability( ): """Test streaming telemetry through the chat telemetry mixin.""" client = mock_chat_client() - messages = [ChatMessage(role="user", text="Test")] + messages = [Message(role="user", text="Test")] span_exporter.clear() # Collect all yielded updates updates = [] @@ -257,7 +258,7 @@ async def test_chat_client_observability_with_instructions( client = mock_chat_client() - messages = [ChatMessage(role="user", text="Test message")] + messages = [Message(role="user", text="Test message")] options = {"model_id": "Test", "instructions": "You are a helpful assistant."} span_exporter.clear() response = await client.get_response(messages=messages, options=options) @@ -286,7 +287,7 @@ async def test_chat_client_streaming_observability_with_instructions( import json client = mock_chat_client() - messages = [ChatMessage(role="user", text="Test")] + messages = [Message(role="user", text="Test")] options = {"model_id": "Test", "instructions": "You are a helpful assistant."} span_exporter.clear() @@ -315,7 +316,7 @@ async def test_chat_client_observability_without_instructions( """Test that system_instructions attribute is not set when instructions are not provided.""" client = mock_chat_client() - messages = [ChatMessage(role="user", text="Test message")] + messages = [Message(role="user", text="Test message")] options = {"model_id": "Test"} # No instructions span_exporter.clear() response = await client.get_response(messages=messages, options=options) @@ -336,7 +337,7 @@ async def test_chat_client_observability_with_empty_instructions( """Test that system_instructions attribute is not set when instructions is an empty string.""" client = mock_chat_client() - messages = [ChatMessage(role="user", text="Test message")] + messages = [Message(role="user", text="Test message")] options = {"model_id": "Test", "instructions": ""} # Empty string span_exporter.clear() response = await client.get_response(messages=messages, options=options) @@ -359,7 +360,7 @@ async def test_chat_client_observability_with_list_instructions( client = mock_chat_client() - messages = [ChatMessage(role="user", text="Test message")] + messages = [Message(role="user", text="Test message")] options = {"model_id": "Test", "instructions": ["Instruction 1", "Instruction 2"]} span_exporter.clear() response = await client.get_response(messages=messages, options=options) @@ -380,7 +381,7 @@ async def test_chat_client_observability_with_list_instructions( async def test_chat_client_without_model_id_observability(mock_chat_client, span_exporter: InMemorySpanExporter): """Test telemetry shouldn't fail when the model_id is not provided for unknown reason.""" client = mock_chat_client() - messages = [ChatMessage(role="user", text="Test")] + messages = [Message(role="user", text="Test")] span_exporter.clear() response = await client.get_response(messages=messages) @@ -399,7 +400,7 @@ async def test_chat_client_streaming_without_model_id_observability( ): """Test streaming telemetry shouldn't fail when the model_id is not provided for unknown reason.""" client = mock_chat_client() - messages = [ChatMessage(role="user", text="Test")] + messages = [Message(role="user", text="Test")] span_exporter.clear() # Collect all yielded updates updates = [] @@ -441,19 +442,19 @@ def mock_chat_agent(): self.description = "Test agent description" self.default_options: dict[str, Any] = {"model_id": "TestModel"} - def run(self, messages=None, *, thread=None, stream=False, **kwargs): + def run(self, messages=None, *, session=None, stream=False, **kwargs): if stream: return self._run_stream_impl(messages=messages, **kwargs) return self._run_impl(messages=messages, **kwargs) - async def _run_impl(self, messages=None, *, thread=None, **kwargs): + async def _run_impl(self, messages=None, *, session=None, **kwargs): return AgentResponse( - messages=[ChatMessage("assistant", ["Agent response"])], + messages=[Message("assistant", ["Agent response"])], usage_details=UsageDetails(input_token_count=15, output_token_count=25), response_id="test_response_id", ) - async def _run_stream_impl(self, messages=None, *, thread=None, **kwargs): + async def _run_stream_impl(self, messages=None, *, session=None, **kwargs): from agent_framework import AgentResponse, AgentResponseUpdate, ResponseStream async def _stream(): @@ -1261,7 +1262,7 @@ async def test_chat_client_observability_exception(mock_chat_client, span_export raise ValueError("Test error") client = FailingChatClient() - messages = [ChatMessage(role="user", text="Test")] + messages = [Message(role="user", text="Test")] span_exporter.clear() with pytest.raises(ValueError, match="Test error"): @@ -1291,7 +1292,7 @@ async def test_chat_client_streaming_observability_exception(mock_chat_client, s return ResponseStream(_stream(), finalizer=ChatResponse.from_updates) client = FailingStreamingChatClient() - messages = [ChatMessage(role="user", text="Test")] + messages = [Message(role="user", text="Test")] span_exporter.clear() with pytest.raises(ValueError, match="Streaming error"): @@ -1572,21 +1573,21 @@ async def test_agent_observability(span_exporter: InMemorySpanExporter, enable_s messages=None, *, stream: bool = False, - thread=None, + session=None, **kwargs, ): if stream: return ResponseStream( - self._run_stream(messages=messages, thread=thread), + self._run_stream(messages=messages, session=session), finalizer=lambda x: AgentResponse.from_updates(x), ) - return AgentResponse(messages=[ChatMessage("assistant", ["Test response"])]) + return AgentResponse(messages=[Message("assistant", ["Test response"])]) async def _run_stream( self, messages=None, *, - thread=None, + session=None, **kwargs, ): from agent_framework import AgentResponseUpdate @@ -1635,7 +1636,7 @@ async def test_agent_observability_with_exception(span_exporter: InMemorySpanExp def default_options(self): return self._default_options - async def run(self, messages=None, *, stream: bool = False, thread=None, **kwargs): + async def run(self, messages=None, *, stream: bool = False, session=None, **kwargs): raise RuntimeError("Agent failed") class FailingAgent(AgentTelemetryLayer, _FailingAgent): @@ -1685,15 +1686,15 @@ async def test_agent_streaming_observability(span_exporter: InMemorySpanExporter def default_options(self): return self._default_options - def run(self, messages=None, *, stream=False, thread=None, **kwargs): + def run(self, messages=None, *, stream=False, session=None, **kwargs): if stream: return self._run_stream_impl(messages=messages, **kwargs) return self._run_impl(messages=messages, **kwargs) - async def _run_impl(self, messages=None, *, thread=None, **kwargs): - return AgentResponse(messages=[ChatMessage("assistant", ["Test"])]) + async def _run_impl(self, messages=None, *, session=None, **kwargs): + return AgentResponse(messages=[Message("assistant", ["Test"])]) - def _run_stream_impl(self, messages=None, *, thread=None, **kwargs): + def _run_stream_impl(self, messages=None, *, session=None, **kwargs): async def _stream(): yield AgentResponseUpdate(contents=[Content.from_text("Hello ")], role="assistant") yield AgentResponseUpdate(contents=[Content.from_text("World")], role="assistant") @@ -1767,13 +1768,13 @@ async def test_capture_messages_with_finish_reason(mock_chat_client, span_export class ClientWithFinishReason(mock_chat_client): async def _inner_get_response(self, *, messages, options, **kwargs): return ChatResponse( - messages=[ChatMessage(role="assistant", text="Done")], + messages=[Message(role="assistant", text="Done")], usage_details=UsageDetails(input_token_count=5, output_token_count=10), finish_reason="stop", ) client = ClientWithFinishReason() - messages = [ChatMessage(role="user", text="Test")] + messages = [Message(role="user", text="Test")] span_exporter.clear() response = await client.get_response(messages=messages, model_id="Test") @@ -1822,15 +1823,15 @@ async def test_agent_streaming_exception(span_exporter: InMemorySpanExporter, en def default_options(self): return self._default_options - def run(self, messages=None, *, stream=False, thread=None, **kwargs): + def run(self, messages=None, *, stream=False, session=None, **kwargs): if stream: return self._run_stream_impl(messages=messages, **kwargs) return self._run_impl(messages=messages, **kwargs) - async def _run_impl(self, messages=None, *, thread=None, **kwargs): + async def _run_impl(self, messages=None, *, session=None, **kwargs): return AgentResponse(messages=[]) - def _run_stream_impl(self, messages=None, *, thread=None, **kwargs): + def _run_stream_impl(self, messages=None, *, session=None, **kwargs): async def _stream(): yield AgentResponseUpdate(contents=[Content.from_text("Starting")], role="assistant") raise RuntimeError("Stream failed") @@ -1863,7 +1864,7 @@ async def test_agent_streaming_exception(span_exporter: InMemorySpanExporter, en async def test_chat_client_when_disabled(mock_chat_client, span_exporter: InMemorySpanExporter): """Test that no spans are created when instrumentation is disabled.""" client = mock_chat_client() - messages = [ChatMessage(role="user", text="Test")] + messages = [Message(role="user", text="Test")] span_exporter.clear() response = await client.get_response(messages=messages, model_id="Test") @@ -1878,7 +1879,7 @@ async def test_chat_client_when_disabled(mock_chat_client, span_exporter: InMemo async def test_chat_client_streaming_when_disabled(mock_chat_client, span_exporter: InMemorySpanExporter): """Test streaming creates no spans when instrumentation is disabled.""" client = mock_chat_client() - messages = [ChatMessage(role="user", text="Test")] + messages = [Message(role="user", text="Test")] span_exporter.clear() updates = [] @@ -1919,7 +1920,7 @@ async def test_agent_when_disabled(span_exporter: InMemorySpanExporter): def default_options(self): return self._default_options - async def run(self, messages=None, *, stream: bool = False, thread=None, **kwargs): + async def run(self, messages=None, *, stream: bool = False, session=None, **kwargs): if stream: return ResponseStream( self._run_stream(messages=messages, **kwargs), @@ -1927,7 +1928,7 @@ async def test_agent_when_disabled(span_exporter: InMemorySpanExporter): ) return AgentResponse(messages=[]) - async def _run_stream(self, messages=None, *, thread=None, **kwargs): + async def _run_stream(self, messages=None, *, session=None, **kwargs): from agent_framework import AgentResponseUpdate yield AgentResponseUpdate(contents=[Content.from_text("test")], role="assistant") @@ -1974,15 +1975,15 @@ async def test_agent_streaming_when_disabled(span_exporter: InMemorySpanExporter def default_options(self): return self._default_options - def run(self, messages=None, *, stream=False, thread=None, **kwargs): + def run(self, messages=None, *, stream=False, session=None, **kwargs): if stream: return self._run_stream(messages=messages, **kwargs) return self._run(messages=messages, **kwargs) - async def _run(self, messages=None, *, thread=None, **kwargs): + async def _run(self, messages=None, *, session=None, **kwargs): return AgentResponse(messages=[]) - async def _run_stream(self, messages=None, *, thread=None, **kwargs): + async def _run_stream(self, messages=None, *, session=None, **kwargs): yield AgentResponseUpdate(contents=[Content.from_text("test")], role="assistant") class TestAgent(AgentTelemetryLayer, _TestAgent): @@ -2208,14 +2209,14 @@ async def test_layer_ordering_span_sequence_with_function_calling(span_exporter: return "https://test.example.com" def _inner_get_response( - self, *, messages: MutableSequence[ChatMessage], stream: bool, options: dict[str, Any], **kwargs: Any + self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: async def _get() -> ChatResponse: self.call_count += 1 if self.call_count == 1: return ChatResponse( messages=[ - ChatMessage( + Message( role="assistant", contents=[ Content.from_function_call( @@ -2228,7 +2229,7 @@ async def test_layer_ordering_span_sequence_with_function_calling(span_exporter: ], ) return ChatResponse( - messages=[ChatMessage(role="assistant", text="The weather in Seattle is sunny!")], + messages=[Message(role="assistant", text="The weather in Seattle is sunny!")], ) return _get() @@ -2237,7 +2238,7 @@ async def test_layer_ordering_span_sequence_with_function_calling(span_exporter: span_exporter.clear() response = await client.get_response( - messages=[ChatMessage(role="user", text="What's the weather in Seattle?")], + messages=[Message(role="user", text="What's the weather in Seattle?")], options={"tools": [get_weather], "tool_choice": "auto"}, ) @@ -2263,3 +2264,176 @@ async def test_layer_ordering_span_sequence_with_function_calling(span_exporter: # Third span: second chat (LLM call with function result) assert sorted_spans[2].name.startswith("chat"), f"Third span should be 'chat', got '{sorted_spans[2].name}'" + + +# region Test non-ASCII character handling in JSON serialization + + +@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True) +async def test_capture_messages_preserves_non_ascii_characters(mock_chat_client, span_exporter: InMemorySpanExporter): + """Test that non-ASCII characters (e.g., Japanese) are preserved in span attributes.""" + import json + + japanese_text = "こんにちは世界" # "Hello World" in Japanese + + class ClientWithJapanese(mock_chat_client): + async def _inner_get_response(self, *, messages, options, **kwargs): + return ChatResponse( + messages=[Message(role="assistant", text=japanese_text)], + usage_details=UsageDetails(input_token_count=5, output_token_count=10), + ) + + client = ClientWithJapanese() + messages = [Message(role="user", text=japanese_text)] + + span_exporter.clear() + response = await client.get_response(messages=messages, model_id="Test") + + assert response is not None + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + + # Verify input messages preserve Japanese characters + input_messages_json = span.attributes[OtelAttr.INPUT_MESSAGES] + assert japanese_text in input_messages_json + # Ensure it's not escaped to Unicode + assert "\\u" not in input_messages_json + + # Verify output messages preserve Japanese characters + output_messages_json = span.attributes[OtelAttr.OUTPUT_MESSAGES] + assert japanese_text in output_messages_json + assert "\\u" not in output_messages_json + + # Verify JSON is valid and contains the text + input_messages = json.loads(input_messages_json) + assert input_messages[0]["parts"][0]["content"] == japanese_text + output_messages = json.loads(output_messages_json) + assert output_messages[0]["parts"][0]["content"] == japanese_text + + +@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True) +async def test_system_instructions_preserves_non_ascii_characters(span_exporter: InMemorySpanExporter): + """Test that non-ASCII characters are preserved in system instructions span attribute.""" + import json + + from opentelemetry import trace + + chinese_text = "你好世界" # "Hello World" in Chinese + + tracer = trace.get_tracer("test") + span_exporter.clear() + + with tracer.start_as_current_span("test_span") as span: + _capture_messages( + span=span, + provider_name="test_provider", + messages=[Message(role="user", text="Test")], + system_instructions=chinese_text, + ) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + + # Verify system instructions preserve Chinese characters + system_instructions_json = span.attributes[OtelAttr.SYSTEM_INSTRUCTIONS] + assert chinese_text in system_instructions_json + assert "\\u" not in system_instructions_json + + # Verify JSON is valid and contains the text + system_instructions = json.loads(system_instructions_json) + assert system_instructions[0]["content"] == chinese_text + + +@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True) +async def test_tool_arguments_preserves_non_ascii_characters(span_exporter: InMemorySpanExporter): + """Test that non-ASCII characters are preserved in tool arguments span attribute.""" + import json + + korean_text = "안녕하세요" # "Hello" in Korean + + @tool + def greet(message: str) -> str: + """Greet with a message.""" + return f"Greeted: {message}" + + span_exporter.clear() + await greet.invoke(message=korean_text) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + + # Verify tool arguments preserve Korean characters + tool_arguments_json = span.attributes[OtelAttr.TOOL_ARGUMENTS] + assert korean_text in tool_arguments_json + assert "\\u" not in tool_arguments_json + + # Verify JSON is valid and contains the text + tool_arguments = json.loads(tool_arguments_json) + assert tool_arguments["message"] == korean_text + + +@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True) +async def test_tool_result_preserves_non_ascii_characters(span_exporter: InMemorySpanExporter): + """Test that non-ASCII characters are preserved in tool result span attribute.""" + arabic_text = "مرحبا بالعالم" # "Hello World" in Arabic + + @tool + def echo(text: str) -> str: + """Echo the text back.""" + return text + + span_exporter.clear() + result = await echo.invoke(text=arabic_text) + + assert result == arabic_text + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + + # Verify tool result preserves Arabic characters + tool_result = span.attributes[OtelAttr.TOOL_RESULT] + assert arabic_text in tool_result + + +@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True) +async def test_tool_arguments_pydantic_preserves_non_ascii_characters( + span_exporter: InMemorySpanExporter, +) -> None: + """Test that non-ASCII characters are preserved in tool arguments when using a Pydantic model.""" + import json + + from pydantic import BaseModel + + japanese_text = "こんにちは" # "Hello" in Japanese + + class Greeting(BaseModel): + message: str + + @tool + def greet_with_model(greeting: Greeting) -> str: + """Greet with a message contained in a Pydantic model.""" + # When invoked via the tool's input_model, greeting is passed as a dict + if isinstance(greeting, dict): + return f"Greeted: {greeting['message']}" + return f"Greeted: {greeting.message}" + + span_exporter.clear() + # Use the tool's input_model to properly pass the Pydantic model argument + input_model = greet_with_model.input_model + await greet_with_model.invoke(arguments=input_model(greeting=Greeting(message=japanese_text))) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + + # Verify tool arguments preserve Japanese characters + tool_arguments_json = span.attributes[OtelAttr.TOOL_ARGUMENTS] + assert japanese_text in tool_arguments_json + assert "\\u" not in tool_arguments_json + + # Verify JSON is valid and contains the text + tool_arguments = json.loads(tool_arguments_json) + assert tool_arguments["greeting"]["message"] == japanese_text diff --git a/python/packages/core/tests/core/test_sessions.py b/python/packages/core/tests/core/test_sessions.py new file mode 100644 index 0000000000..bd3a22e70d --- /dev/null +++ b/python/packages/core/tests/core/test_sessions.py @@ -0,0 +1,421 @@ +# Copyright (c) Microsoft. All rights reserved. + +import json +from collections.abc import Sequence + +from agent_framework import Message +from agent_framework._sessions import ( + AgentSession, + BaseContextProvider, + BaseHistoryProvider, + InMemoryHistoryProvider, + SessionContext, +) + +# --------------------------------------------------------------------------- +# SessionContext tests +# --------------------------------------------------------------------------- + + +class TestSessionContext: + def test_init_defaults(self) -> None: + ctx = SessionContext(input_messages=[]) + assert ctx.session_id is None + assert ctx.service_session_id is None + assert ctx.input_messages == [] + assert ctx.context_messages == {} + assert ctx.instructions == [] + assert ctx.tools == [] + assert ctx.response is None + assert ctx.options == {} + assert ctx.metadata == {} + + def test_extend_messages_creates_key(self) -> None: + ctx = SessionContext(input_messages=[]) + msg = Message(role="user", contents=["hello"]) + ctx.extend_messages("rag", [msg]) + assert "rag" in ctx.context_messages + assert len(ctx.context_messages["rag"]) == 1 + assert ctx.context_messages["rag"][0].text == "hello" + + def test_extend_messages_appends_to_existing(self) -> None: + ctx = SessionContext(input_messages=[]) + msg1 = Message(role="user", contents=["first"]) + msg2 = Message(role="user", contents=["second"]) + ctx.extend_messages("src", [msg1]) + ctx.extend_messages("src", [msg2]) + assert len(ctx.context_messages["src"]) == 2 + + def test_extend_messages_preserves_source_order(self) -> None: + ctx = SessionContext(input_messages=[]) + ctx.extend_messages("a", [Message(role="user", contents=["a"])]) + ctx.extend_messages("b", [Message(role="user", contents=["b"])]) + ctx.extend_messages("c", [Message(role="user", contents=["c"])]) + assert list(ctx.context_messages.keys()) == ["a", "b", "c"] + + def test_extend_messages_sets_attribution(self) -> None: + ctx = SessionContext(input_messages=[]) + msg = Message(role="system", contents=["context"]) + ctx.extend_messages("rag", [msg]) + stored = ctx.context_messages["rag"][0] + assert stored.additional_properties["_attribution"] == {"source_id": "rag"} + # Original message is not mutated + assert "_attribution" not in msg.additional_properties + + def test_extend_messages_does_not_overwrite_existing_attribution(self) -> None: + ctx = SessionContext(input_messages=[]) + msg = Message( + role="system", contents=["context"], additional_properties={"_attribution": {"source_id": "custom"}} + ) + ctx.extend_messages("rag", [msg]) + stored = ctx.context_messages["rag"][0] + assert stored.additional_properties["_attribution"] == {"source_id": "custom"} + + def test_extend_messages_copies_messages(self) -> None: + ctx = SessionContext(input_messages=[]) + msg = Message(role="user", contents=["hello"]) + ctx.extend_messages("src", [msg]) + stored = ctx.context_messages["src"][0] + assert stored is not msg + assert stored.text == "hello" + # Mutating stored copy does not affect original + stored.additional_properties["extra"] = True + assert "extra" not in msg.additional_properties + + def test_extend_messages_sender_sets_source_type(self) -> None: + class MyProvider: + source_id = "rag" + + ctx = SessionContext(input_messages=[]) + msg = Message(role="system", contents=["ctx"]) + ctx.extend_messages(MyProvider(), [msg]) + stored = ctx.context_messages["rag"][0] + assert stored.additional_properties["_attribution"] == {"source_id": "rag", "source_type": "MyProvider"} + + def test_extend_instructions_string(self) -> None: + ctx = SessionContext(input_messages=[]) + ctx.extend_instructions("sys", "Be helpful") + assert ctx.instructions == ["Be helpful"] + + def test_extend_instructions_sequence(self) -> None: + ctx = SessionContext(input_messages=[]) + ctx.extend_instructions("sys", ["Be helpful", "Be concise"]) + assert ctx.instructions == ["Be helpful", "Be concise"] + + def test_get_messages_all(self) -> None: + ctx = SessionContext(input_messages=[]) + ctx.extend_messages("a", [Message(role="user", contents=["a"])]) + ctx.extend_messages("b", [Message(role="user", contents=["b"])]) + result = ctx.get_messages() + assert len(result) == 2 + assert result[0].text == "a" + assert result[1].text == "b" + + def test_get_messages_filter_sources(self) -> None: + ctx = SessionContext(input_messages=[]) + ctx.extend_messages("a", [Message(role="user", contents=["a"])]) + ctx.extend_messages("b", [Message(role="user", contents=["b"])]) + result = ctx.get_messages(sources=["a"]) + assert len(result) == 1 + assert result[0].text == "a" + + def test_get_messages_exclude_sources(self) -> None: + ctx = SessionContext(input_messages=[]) + ctx.extend_messages("a", [Message(role="user", contents=["a"])]) + ctx.extend_messages("b", [Message(role="user", contents=["b"])]) + result = ctx.get_messages(exclude_sources=["a"]) + assert len(result) == 1 + assert result[0].text == "b" + + def test_get_messages_include_input(self) -> None: + input_msg = Message(role="user", contents=["input"]) + ctx = SessionContext(input_messages=[input_msg]) + ctx.extend_messages("a", [Message(role="user", contents=["context"])]) + result = ctx.get_messages(include_input=True) + assert len(result) == 2 + assert result[1].text == "input" + + def test_get_messages_include_response(self) -> None: + from agent_framework import AgentResponse + + ctx = SessionContext(input_messages=[]) + ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["reply"])]) + result = ctx.get_messages(include_response=True) + assert len(result) == 1 + assert result[0].text == "reply" + + def test_response_readonly(self) -> None: + ctx = SessionContext(input_messages=[]) + assert ctx.response is None + # Can set via _response internally + from agent_framework import AgentResponse + + resp = AgentResponse(messages=[]) + ctx._response = resp + assert ctx.response is resp + + +# --------------------------------------------------------------------------- +# BaseContextProvider tests +# --------------------------------------------------------------------------- + + +class TestContextProviderBase: + def test_source_id_required(self) -> None: + provider = BaseContextProvider(source_id="test") + assert provider.source_id == "test" + + async def test_before_run_is_noop(self) -> None: + provider = BaseContextProvider(source_id="test") + session = AgentSession() + ctx = SessionContext(input_messages=[]) + # Should not raise + await provider.before_run(agent=None, session=session, context=ctx, state={}) # type: ignore[arg-type] + + async def test_after_run_is_noop(self) -> None: + provider = BaseContextProvider(source_id="test") + session = AgentSession() + ctx = SessionContext(input_messages=[]) + await provider.after_run(agent=None, session=session, context=ctx, state={}) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# BaseHistoryProvider tests +# --------------------------------------------------------------------------- + + +class ConcreteHistoryProvider(BaseHistoryProvider): + """Concrete test implementation.""" + + def __init__(self, source_id: str, stored_messages: list[Message] | None = None, **kwargs) -> None: + super().__init__(source_id, **kwargs) + self.stored: list[Message] = [] + self._stored_messages = stored_messages or [] + + async def get_messages(self, session_id: str | None, **kwargs) -> list[Message]: + return list(self._stored_messages) + + async def save_messages(self, session_id: str | None, messages: Sequence[Message], **kwargs) -> None: + self.stored.extend(messages) + + +class TestHistoryProviderBase: + def test_default_flags(self) -> None: + provider = ConcreteHistoryProvider("mem") + assert provider.load_messages is True + assert provider.store_outputs is True + assert provider.store_inputs is True + assert provider.store_context_messages is False + assert provider.store_context_from is None + + def test_custom_flags(self) -> None: + provider = ConcreteHistoryProvider( + "audit", + load_messages=False, + store_inputs=False, + store_context_messages=True, + store_context_from={"rag"}, + ) + assert provider.load_messages is False + assert provider.store_inputs is False + assert provider.store_context_messages is True + assert provider.store_context_from == {"rag"} + + async def test_before_run_loads_messages(self) -> None: + msgs = [Message(role="user", contents=["history"])] + provider = ConcreteHistoryProvider("mem", stored_messages=msgs) + session = AgentSession() + ctx = SessionContext(session_id="s1", input_messages=[]) + await provider.before_run(agent=None, session=session, context=ctx, state={}) # type: ignore[arg-type] + assert len(ctx.context_messages["mem"]) == 1 + assert ctx.context_messages["mem"][0].text == "history" + + async def test_after_run_stores_inputs_and_responses(self) -> None: + from agent_framework import AgentResponse + + provider = ConcreteHistoryProvider("mem") + session = AgentSession() + input_msg = Message(role="user", contents=["hello"]) + resp_msg = Message(role="assistant", contents=["hi"]) + ctx = SessionContext(session_id="s1", input_messages=[input_msg]) + ctx._response = AgentResponse(messages=[resp_msg]) + await provider.after_run(agent=None, session=session, context=ctx, state={}) # type: ignore[arg-type] + assert len(provider.stored) == 2 + assert provider.stored[0].text == "hello" + assert provider.stored[1].text == "hi" + + async def test_after_run_skips_inputs_when_disabled(self) -> None: + from agent_framework import AgentResponse + + provider = ConcreteHistoryProvider("mem", store_inputs=False) + ctx = SessionContext(session_id="s1", input_messages=[Message(role="user", contents=["hello"])]) + ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hi"])]) + await provider.after_run(agent=None, session=AgentSession(), context=ctx, state={}) # type: ignore[arg-type] + assert len(provider.stored) == 1 + assert provider.stored[0].text == "hi" + + async def test_after_run_skips_responses_when_disabled(self) -> None: + from agent_framework import AgentResponse + + provider = ConcreteHistoryProvider("mem", store_outputs=False) + ctx = SessionContext(session_id="s1", input_messages=[Message(role="user", contents=["hello"])]) + ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hi"])]) + await provider.after_run(agent=None, session=AgentSession(), context=ctx, state={}) # type: ignore[arg-type] + assert len(provider.stored) == 1 + assert provider.stored[0].text == "hello" + + async def test_after_run_stores_context_messages(self) -> None: + from agent_framework import AgentResponse + + provider = ConcreteHistoryProvider("audit", load_messages=False, store_context_messages=True) + ctx = SessionContext(session_id="s1", input_messages=[Message(role="user", contents=["hello"])]) + ctx.extend_messages("rag", [Message(role="system", contents=["context"])]) + ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hi"])]) + await provider.after_run(agent=None, session=AgentSession(), context=ctx, state={}) # type: ignore[arg-type] + # Should store: context from rag + input + response + texts = [m.text for m in provider.stored] + assert "context" in texts + assert "hello" in texts + assert "hi" in texts + + async def test_after_run_stores_context_from_specific_sources(self) -> None: + from agent_framework import AgentResponse + + provider = ConcreteHistoryProvider( + "audit", load_messages=False, store_context_messages=True, store_context_from={"rag"} + ) + ctx = SessionContext(session_id="s1", input_messages=[]) + ctx.extend_messages("rag", [Message(role="system", contents=["rag-context"])]) + ctx.extend_messages("other", [Message(role="system", contents=["other-context"])]) + ctx._response = AgentResponse(messages=[]) + await provider.after_run(agent=None, session=AgentSession(), context=ctx, state={}) # type: ignore[arg-type] + texts = [m.text for m in provider.stored] + assert "rag-context" in texts + assert "other-context" not in texts + + +# --------------------------------------------------------------------------- +# AgentSession tests +# --------------------------------------------------------------------------- + + +class TestAgentSession: + def test_auto_generates_session_id(self) -> None: + session = AgentSession() + assert session.session_id is not None + assert len(session.session_id) > 0 + + def test_custom_session_id(self) -> None: + session = AgentSession(session_id="custom-123") + assert session.session_id == "custom-123" + + def test_state_starts_empty(self) -> None: + session = AgentSession() + assert session.state == {} + + def test_service_session_id(self) -> None: + session = AgentSession(service_session_id="svc-456") + assert session.service_session_id == "svc-456" + + def test_to_dict(self) -> None: + session = AgentSession(session_id="s1", service_session_id="svc1") + session.state = {"key": "value"} + d = session.to_dict() + assert d["type"] == "session" + assert d["session_id"] == "s1" + assert d["service_session_id"] == "svc1" + assert d["state"] == {"key": "value"} + + def test_from_dict(self) -> None: + data = { + "type": "session", + "session_id": "s1", + "service_session_id": "svc1", + "state": {"key": "value"}, + } + session = AgentSession.from_dict(data) + assert session.session_id == "s1" + assert session.service_session_id == "svc1" + assert session.state == {"key": "value"} + + def test_roundtrip(self) -> None: + session = AgentSession(session_id="rt-1") + session.state = {"messages": ["a", "b"], "count": 42} + json_str = json.dumps(session.to_dict()) + restored = AgentSession.from_dict(json.loads(json_str)) + assert restored.session_id == "rt-1" + assert restored.state == {"messages": ["a", "b"], "count": 42} + + def test_from_dict_missing_state(self) -> None: + data = {"session_id": "s1"} + session = AgentSession.from_dict(data) + assert session.state == {} + + +# --------------------------------------------------------------------------- +# InMemoryHistoryProvider tests +# --------------------------------------------------------------------------- + + +class TestInMemoryHistoryProvider: + async def test_empty_state_returns_no_messages(self) -> None: + provider = InMemoryHistoryProvider("memory") + session = AgentSession() + ctx = SessionContext(session_id="s1", input_messages=[]) + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + assert ctx.context_messages.get("memory", []) == [] + + async def test_stores_and_loads_messages(self) -> None: + from agent_framework import AgentResponse + + provider = InMemoryHistoryProvider("memory") + session = AgentSession() + + # First run: send input, get response + input_msg = Message(role="user", contents=["hello"]) + resp_msg = Message(role="assistant", contents=["hi there"]) + ctx1 = SessionContext(session_id="s1", input_messages=[input_msg]) + await provider.before_run(agent=None, session=session, context=ctx1, state=session.state) # type: ignore[arg-type] + ctx1._response = AgentResponse(messages=[resp_msg]) + await provider.after_run(agent=None, session=session, context=ctx1, state=session.state) # type: ignore[arg-type] + + # Second run: should load previous messages + ctx2 = SessionContext(session_id="s1", input_messages=[Message(role="user", contents=["again"])]) + await provider.before_run(agent=None, session=session, context=ctx2, state=session.state) # type: ignore[arg-type] + loaded = ctx2.context_messages.get("memory", []) + assert len(loaded) == 2 + assert loaded[0].text == "hello" + assert loaded[1].text == "hi there" + + async def test_state_is_serializable(self) -> None: + from agent_framework import AgentResponse + + provider = InMemoryHistoryProvider("memory") + session = AgentSession() + + input_msg = Message(role="user", contents=["test"]) + ctx = SessionContext(session_id="s1", input_messages=[input_msg]) + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["reply"])]) + await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + # State contains Message objects (not dicts) + assert isinstance(session.state["memory"]["messages"][0], Message) + + # to_dict() serializes them via SerializationProtocol + session_dict = session.to_dict() + json_str = json.dumps(session_dict) + assert json_str # no error + + # Round-trip through session serialization restores Message objects + restored = AgentSession.from_dict(json.loads(json_str)) + assert isinstance(restored.state["memory"]["messages"][0], Message) + assert restored.state["memory"]["messages"][0].text == "test" + assert restored.state["memory"]["messages"][1].text == "reply" + + async def test_source_id_attribution(self) -> None: + provider = InMemoryHistoryProvider("custom-source") + assert provider.source_id == "custom-source" + ctx = SessionContext(session_id="s1", input_messages=[]) + ctx.extend_messages("custom-source", [Message(role="user", contents=["test"])]) + assert "custom-source" in ctx.context_messages 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..8ab60ca043 --- /dev/null +++ b/python/packages/core/tests/core/test_settings.py @@ -0,0 +1,330 @@ +# 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 ExclusiveSettings(TypedDict, total=False): + source_a: str | None + source_b: str | None + other: 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" + + +class TestMutuallyExclusive: + """Test mutually exclusive field validation via tuple entries in required_fields.""" + + def test_exactly_one_set_passes(self) -> None: + settings = load_settings( + ExclusiveSettings, + env_prefix="TEST_", + required_fields=[("source_a", "source_b")], + source_a="value-a", + ) + + assert settings["source_a"] == "value-a" + assert settings["source_b"] is None + + def test_none_set_raises(self) -> None: + from agent_framework.exceptions import SettingNotFoundError + + with pytest.raises(SettingNotFoundError, match="none was set"): + load_settings( + ExclusiveSettings, + env_prefix="TEST_", + required_fields=[("source_a", "source_b")], + ) + + def test_both_set_raises(self) -> None: + from agent_framework.exceptions import SettingNotFoundError + + with pytest.raises(SettingNotFoundError, match="multiple were set"): + load_settings( + ExclusiveSettings, + env_prefix="TEST_", + required_fields=[("source_a", "source_b")], + source_a="a", + source_b="b", + ) + + def test_env_var_counts_as_set(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TEST_SOURCE_B", "env-b") + + settings = load_settings( + ExclusiveSettings, + env_prefix="TEST_", + required_fields=[("source_a", "source_b")], + ) + + assert settings["source_b"] == "env-b" + + def test_env_var_and_override_both_set_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + from agent_framework.exceptions import SettingNotFoundError + + monkeypatch.setenv("TEST_SOURCE_B", "env-b") + + with pytest.raises(SettingNotFoundError, match="multiple were set"): + load_settings( + ExclusiveSettings, + env_prefix="TEST_", + required_fields=[("source_a", "source_b")], + source_a="a", + ) + + def test_other_fields_unaffected(self) -> None: + settings = load_settings( + ExclusiveSettings, + env_prefix="TEST_", + required_fields=[("source_a", "source_b")], + source_a="a", + other="extra", + ) + + assert settings["source_a"] == "a" + assert settings["other"] == "extra" + + def test_mixed_required_and_exclusive(self) -> None: + settings = load_settings( + ExclusiveSettings, + env_prefix="TEST_", + required_fields=["other", ("source_a", "source_b")], + source_b="b", + other="required-val", + ) + + assert settings["other"] == "required-val" + assert settings["source_b"] == "b" + assert settings["source_a"] is None diff --git a/python/packages/core/tests/core/test_threads.py b/python/packages/core/tests/core/test_threads.py deleted file mode 100644 index a891f6b440..0000000000 --- a/python/packages/core/tests/core/test_threads.py +++ /dev/null @@ -1,600 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from collections.abc import Sequence -from typing import Any - -import pytest - -from agent_framework import AgentThread, ChatMessage, ChatMessageStore -from agent_framework._threads import AgentThreadState, ChatMessageStoreState -from agent_framework.exceptions import AgentThreadException - - -class MockChatMessageStore: - """Mock implementation of ChatMessageStoreProtocol for testing.""" - - def __init__(self, messages: list[ChatMessage] | None = None) -> None: - self._messages = messages or [] - self._serialize_calls = 0 - self._deserialize_calls = 0 - - async def list_messages(self) -> list[ChatMessage]: - return self._messages - - async def add_messages(self, messages: Sequence[ChatMessage]) -> None: - self._messages.extend(messages) - - async def serialize(self, **kwargs: Any) -> Any: - self._serialize_calls += 1 - return {"messages": [msg.__dict__ for msg in self._messages], "kwargs": kwargs} - - async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> None: - self._deserialize_calls += 1 - if serialized_store_state and "messages" in serialized_store_state: - self._messages = serialized_store_state["messages"] - - @classmethod - async def deserialize(cls, serialized_store_state: Any, **kwargs: Any) -> "MockChatMessageStore": - instance = cls() - await instance.update_from_state(serialized_store_state, **kwargs) - return instance - - -@pytest.fixture -def sample_messages() -> list[ChatMessage]: - """Fixture providing sample chat messages for testing.""" - return [ - ChatMessage(role="user", text="Hello", message_id="msg1"), - ChatMessage(role="assistant", text="Hi there!", message_id="msg2"), - ChatMessage(role="user", text="How are you?", message_id="msg3"), - ] - - -@pytest.fixture -def sample_message() -> ChatMessage: - """Fixture providing a single sample chat message for testing.""" - return ChatMessage(role="user", text="Test message", message_id="test1") - - -class TestAgentThread: - """Test cases for AgentThread class.""" - - def test_init_with_no_parameters(self) -> None: - """Test AgentThread initialization with no parameters.""" - thread = AgentThread() - assert thread.service_thread_id is None - assert thread.message_store is None - - def test_init_with_service_thread_id(self) -> None: - """Test AgentThread initialization with service_thread_id.""" - service_thread_id = "test-conversation-123" - thread = AgentThread(service_thread_id=service_thread_id) - assert thread.service_thread_id == service_thread_id - assert thread.message_store is None - - def test_init_with_message_store(self) -> None: - """Test AgentThread initialization with message_store.""" - store = ChatMessageStore() - thread = AgentThread(message_store=store) - assert thread.service_thread_id is None - assert thread.message_store is store - - def test_service_thread_id_property_setter(self) -> None: - """Test service_thread_id property setter.""" - thread = AgentThread() - service_thread_id = "test-conversation-456" - - thread.service_thread_id = service_thread_id - assert thread.service_thread_id == service_thread_id - - def test_service_thread_id_setter_with_existing_message_store_raises_error(self) -> None: - """Test that setting service_thread_id when message_store exists raises AgentThreadException.""" - store = ChatMessageStore() - thread = AgentThread(message_store=store) - - with pytest.raises(AgentThreadException, match="Only the service_thread_id or message_store may be set"): - thread.service_thread_id = "test-conversation-789" - - def test_service_thread_id_setter_with_none_values(self) -> None: - """Test service_thread_id setter with None values does nothing.""" - thread = AgentThread() - thread.service_thread_id = None # Should not raise error - assert thread.service_thread_id is None - - def test_message_store_property_setter(self) -> None: - """Test message_store property setter.""" - thread = AgentThread() - store = ChatMessageStore() - - thread.message_store = store - assert thread.message_store is store - - def test_message_store_setter_with_existing_service_thread_id_raises_error(self) -> None: - """Test that setting message_store when service_thread_id exists raises AgentThreadException.""" - service_thread_id = "test-conversation-999" - thread = AgentThread(service_thread_id=service_thread_id) - store = ChatMessageStore() - - with pytest.raises(AgentThreadException, match="Only the service_thread_id or message_store may be set"): - thread.message_store = store - - def test_message_store_setter_with_none_values(self) -> None: - """Test message_store setter with None values does nothing.""" - thread = AgentThread() - thread.message_store = None # Should not raise error - assert thread.message_store is None - - async def test_get_messages_with_message_store(self, sample_messages: list[ChatMessage]) -> None: - """Test get_messages when message_store is set.""" - store = ChatMessageStore(sample_messages) - thread = AgentThread(message_store=store) - - assert thread.message_store is not None - - messages: list[ChatMessage] = await thread.message_store.list_messages() - - assert messages is not None - assert len(messages) == 3 - assert messages[0].text == "Hello" - assert messages[1].text == "Hi there!" - assert messages[2].text == "How are you?" - - async def test_get_messages_with_no_message_store(self) -> None: - """Test get_messages when no message_store is set.""" - thread = AgentThread() - - assert thread.message_store is None - - async def test_on_new_messages_with_service_thread_id(self, sample_message: ChatMessage) -> None: - """Test _on_new_messages when service_thread_id is set (should do nothing).""" - thread = AgentThread(service_thread_id="test-conv") - - await thread.on_new_messages(sample_message) - - # Should not create a message store - assert thread.message_store is None - - async def test_on_new_messages_single_message_creates_store(self, sample_message: ChatMessage) -> None: - """Test _on_new_messages with single message creates ChatMessageStore.""" - thread = AgentThread() - - await thread.on_new_messages(sample_message) - - assert thread.message_store is not None - assert isinstance(thread.message_store, ChatMessageStore) - messages = await thread.message_store.list_messages() - assert len(messages) == 1 - assert messages[0].text == "Test message" - - async def test_on_new_messages_multiple_messages(self, sample_messages: list[ChatMessage]) -> None: - """Test _on_new_messages with multiple messages.""" - thread = AgentThread() - - await thread.on_new_messages(sample_messages) - - assert thread.message_store is not None - messages = await thread.message_store.list_messages() - assert len(messages) == 3 - - async def test_on_new_messages_with_existing_store(self, sample_message: ChatMessage) -> None: - """Test _on_new_messages adds to existing message store.""" - initial_messages = [ChatMessage(role="user", text="Initial", message_id="init1")] - store = ChatMessageStore(initial_messages) - thread = AgentThread(message_store=store) - - await thread.on_new_messages(sample_message) - - assert thread.message_store is not None - messages = await thread.message_store.list_messages() - assert len(messages) == 2 - assert messages[0].text == "Initial" - assert messages[1].text == "Test message" - - async def test_deserialize_with_service_thread_id(self) -> None: - """Test _deserialize with service_thread_id.""" - serialized_data = {"service_thread_id": "test-conv-123", "chat_message_store_state": None} - - thread = await AgentThread.deserialize(serialized_data) - - assert thread.service_thread_id == "test-conv-123" - assert thread.message_store is None - - async def test_deserialize_with_store_state(self, sample_messages: list[ChatMessage]) -> None: - """Test _deserialize with chat_message_store_state.""" - store_state = {"messages": sample_messages} - serialized_data = {"service_thread_id": None, "chat_message_store_state": store_state} - - thread = await AgentThread.deserialize(serialized_data) - - assert thread.service_thread_id is None - assert thread.message_store is not None - assert isinstance(thread.message_store, ChatMessageStore) - - async def test_deserialize_with_no_state(self) -> None: - """Test _deserialize with no state.""" - thread = AgentThread() - serialized_data = {"service_thread_id": None, "chat_message_store_state": None} - - await thread.deserialize(serialized_data) - - assert thread.service_thread_id is None - assert thread.message_store is None - - async def test_deserialize_with_existing_store(self) -> None: - """Test _deserialize with existing message store.""" - store = MockChatMessageStore() - thread = AgentThread(message_store=store) - serialized_data: dict[str, Any] = { - "service_thread_id": None, - "chat_message_store_state": {"messages": [ChatMessage(role="user", text="test")]}, - } - - await thread.update_from_thread_state(serialized_data) - - assert store._messages - assert store._messages[0].text == "test" - - async def test_serialize_with_service_thread_id(self) -> None: - """Test serialize with service_thread_id.""" - thread = AgentThread(service_thread_id="test-conv-456") - - result = await thread.serialize() - - assert result["service_thread_id"] == "test-conv-456" - assert result["chat_message_store_state"] is None - - async def test_serialize_with_message_store(self) -> None: - """Test serialize with message_store.""" - store = MockChatMessageStore() - thread = AgentThread(message_store=store) - - result = await thread.serialize() - - assert result["service_thread_id"] is None - assert result["chat_message_store_state"] is not None - assert store._serialize_calls == 1 # pyright: ignore[reportPrivateUsage] - - async def test_serialize_with_no_state(self) -> None: - """Test serialize with no state.""" - thread = AgentThread() - - result = await thread.serialize() - - assert result["service_thread_id"] is None - assert result["chat_message_store_state"] is None - - async def test_serialize_with_kwargs(self) -> None: - """Test serialize passes kwargs to message store.""" - store = MockChatMessageStore() - thread = AgentThread(message_store=store) - - await thread.serialize(custom_param="test_value") - - assert store._serialize_calls == 1 # pyright: ignore[reportPrivateUsage] - - async def test_serialize_round_trip_messages(self, sample_messages: list[ChatMessage]) -> None: - """Test a roundtrip of the serialization.""" - store = ChatMessageStore(sample_messages) - thread = AgentThread(message_store=store) - new_thread = await AgentThread.deserialize(await thread.serialize()) - assert new_thread.message_store is not None - new_messages = await new_thread.message_store.list_messages() - assert len(new_messages) == len(sample_messages) - assert {new.text for new in new_messages} == {orig.text for orig in sample_messages} - - async def test_serialize_round_trip_thread_id(self) -> None: - """Test a roundtrip of the serialization.""" - thread = AgentThread(service_thread_id="test-1234") - new_thread = await AgentThread.deserialize(await thread.serialize()) - assert new_thread.message_store is None - assert new_thread.service_thread_id == "test-1234" - - -class TestChatMessageList: - """Test cases for ChatMessageStore class.""" - - def test_init_empty(self) -> None: - """Test ChatMessageStore initialization with no messages.""" - store = ChatMessageStore() - assert len(store.messages) == 0 - - def test_init_with_messages(self, sample_messages: list[ChatMessage]) -> None: - """Test ChatMessageStore initialization with messages.""" - store = ChatMessageStore(sample_messages) - assert len(store.messages) == 3 - - async def test_add_messages(self, sample_messages: list[ChatMessage]) -> None: - """Test adding messages to the store.""" - store = ChatMessageStore() - - await store.add_messages(sample_messages) - - assert len(store.messages) == 3 - messages = await store.list_messages() - assert messages[0].text == "Hello" - - async def test_get_messages(self, sample_messages: list[ChatMessage]) -> None: - """Test getting messages from the store.""" - store = ChatMessageStore(sample_messages) - - messages = await store.list_messages() - - assert len(messages) == 3 - assert messages[0].message_id == "msg1" - - async def test_serialize_state(self, sample_messages: list[ChatMessage]) -> None: - """Test serializing store state.""" - store = ChatMessageStore(sample_messages) - - result = await store.serialize() - - assert "messages" in result - assert len(result["messages"]) == 3 - - async def test_serialize_state_empty(self) -> None: - """Test serializing empty store state.""" - store = ChatMessageStore() - - result = await store.serialize() - - assert "messages" in result - assert len(result["messages"]) == 0 - - async def test_deserialize_state(self, sample_messages: list[ChatMessage]) -> None: - """Test deserializing store state.""" - store = ChatMessageStore() - state_data = {"messages": sample_messages} - - await store.update_from_state(state_data) - - messages = await store.list_messages() - assert len(messages) == 3 - assert messages[0].text == "Hello" - - async def test_deserialize_state_none(self) -> None: - """Test deserializing None state.""" - store = ChatMessageStore() - - await store.update_from_state(None) - - assert len(store.messages) == 0 - - async def test_deserialize_state_empty(self) -> None: - """Test deserializing empty state.""" - store = ChatMessageStore() - - await store.update_from_state({}) - - assert len(store.messages) == 0 - - -class TestStoreState: - """Test cases for ChatMessageStoreState class.""" - - def test_init(self, sample_messages: list[ChatMessage]) -> None: - """Test ChatMessageStoreState initialization.""" - state = ChatMessageStoreState(messages=sample_messages) - - assert len(state.messages) == 3 - assert state.messages[0].text == "Hello" - - def test_init_empty(self) -> None: - """Test ChatMessageStoreState initialization with empty messages.""" - state = ChatMessageStoreState(messages=[]) - - assert len(state.messages) == 0 - - def test_init_none(self) -> None: - """Test ChatMessageStoreState initialization with None messages.""" - state = ChatMessageStoreState(messages=None) - - assert len(state.messages) == 0 - - def test_init_no_messages_arg(self) -> None: - """Test ChatMessageStoreState initialization without messages argument.""" - state = ChatMessageStoreState() - - assert len(state.messages) == 0 - - -class TestThreadState: - """Test cases for AgentThreadState class.""" - - def test_init_with_service_thread_id(self) -> None: - """Test AgentThreadState initialization with service_thread_id.""" - state = AgentThreadState(service_thread_id="test-conv-123") - - assert state.service_thread_id == "test-conv-123" - assert state.chat_message_store_state is None - - def test_init_with_chat_message_store_state(self) -> None: - """Test AgentThreadState initialization with chat_message_store_state.""" - store_data: dict[str, Any] = {"messages": []} - state = AgentThreadState.from_dict({"chat_message_store_state": store_data}) - - assert state.service_thread_id is None - assert state.chat_message_store_state.messages == [] - - def test_init_with_both(self) -> None: - """Test AgentThreadState initialization with both parameters.""" - store_data: dict[str, Any] = {"messages": []} - with pytest.raises(AgentThreadException): - AgentThreadState(service_thread_id="test-conv-123", chat_message_store_state=store_data) - - def test_init_defaults(self) -> None: - """Test AgentThreadState initialization with defaults.""" - state = AgentThreadState() - - assert state.service_thread_id is None - assert state.chat_message_store_state is None - - def test_init_with_chat_message_store_state_no_messages(self) -> None: - """Test AgentThreadState initialization with chat_message_store_state without messages field. - - This tests the scenario where a custom ChatMessageStore (like RedisChatMessageStore) - serializes its state without a 'messages' field, containing only configuration data - like thread_id, redis_url, etc. - """ - store_data: dict[str, Any] = { - "type": "redis_store_state", - "thread_id": "test_thread_123", - "redis_url": "redis://localhost:6379", - "key_prefix": "chat_messages", - } - state = AgentThreadState.from_dict({"chat_message_store_state": store_data}) - - assert state.service_thread_id is None - assert state.chat_message_store_state is not None - assert state.chat_message_store_state.messages == [] - - def test_init_with_chat_message_store_state_object(self) -> None: - """Test AgentThreadState initialization with ChatMessageStoreState object.""" - store_state = ChatMessageStoreState(messages=[ChatMessage(role="user", text="test")]) - state = AgentThreadState(chat_message_store_state=store_state) - - assert state.service_thread_id is None - assert state.chat_message_store_state is store_state - assert len(state.chat_message_store_state.messages) == 1 - - def test_init_with_invalid_chat_message_store_state_type(self) -> None: - """Test AgentThreadState initialization with invalid chat_message_store_state type.""" - with pytest.raises(TypeError, match="Could not parse ChatMessageStoreState"): - AgentThreadState(chat_message_store_state="invalid_type") # type: ignore[arg-type] - - -class TestChatMessageStoreStateEdgeCases: - """Additional edge case tests for ChatMessageStoreState.""" - - def test_init_with_invalid_messages_type(self) -> None: - """Test ChatMessageStoreState initialization with invalid messages type.""" - with pytest.raises(TypeError, match="Messages should be a list"): - ChatMessageStoreState(messages="invalid") # type: ignore[arg-type] - - def test_init_with_dict_messages(self) -> None: - """Test ChatMessageStoreState initialization with dict messages.""" - messages = [ - {"role": "user", "text": "Hello"}, - {"role": "assistant", "text": "Hi there!"}, - ] - state = ChatMessageStoreState(messages=messages) - - assert len(state.messages) == 2 - assert isinstance(state.messages[0], ChatMessage) - assert state.messages[0].text == "Hello" - - -class TestChatMessageStoreEdgeCases: - """Additional edge case tests for ChatMessageStore.""" - - async def test_deserialize_class_method(self) -> None: - """Test ChatMessageStore.deserialize class method.""" - serialized_data = { - "messages": [ - {"role": "user", "text": "Hello", "message_id": "msg1"}, - ] - } - - store = await ChatMessageStore.deserialize(serialized_data) - - assert isinstance(store, ChatMessageStore) - messages = await store.list_messages() - assert len(messages) == 1 - assert messages[0].text == "Hello" - - async def test_deserialize_empty_state(self) -> None: - """Test ChatMessageStore.deserialize with empty state.""" - serialized_data: dict[str, Any] = {"messages": []} - - store = await ChatMessageStore.deserialize(serialized_data) - - assert isinstance(store, ChatMessageStore) - messages = await store.list_messages() - assert len(messages) == 0 - - -class TestAgentThreadEdgeCases: - """Additional edge case tests for AgentThread.""" - - def test_is_initialized_with_service_thread_id(self) -> None: - """Test is_initialized property when service_thread_id is set.""" - thread = AgentThread(service_thread_id="test-123") - assert thread.is_initialized is True - - def test_is_initialized_with_message_store(self) -> None: - """Test is_initialized property when message_store is set.""" - store = ChatMessageStore() - thread = AgentThread(message_store=store) - assert thread.is_initialized is True - - def test_is_initialized_with_nothing(self) -> None: - """Test is_initialized property when nothing is set.""" - thread = AgentThread() - assert thread.is_initialized is False - - async def test_deserialize_with_custom_message_store(self) -> None: - """Test deserialize using a custom message store.""" - serialized_data = { - "service_thread_id": None, - "chat_message_store_state": { - "messages": [{"role": "user", "text": "Hello"}], - }, - } - custom_store = MockChatMessageStore() - - thread = await AgentThread.deserialize(serialized_data, message_store=custom_store) - - assert thread.message_store is custom_store - messages = await custom_store.list_messages() - assert len(messages) == 1 - - async def test_deserialize_with_failing_message_store_raises(self) -> None: - """Test deserialize raises AgentThreadException when message store fails.""" - - class FailingStore: - async def add_messages(self, messages: Sequence[ChatMessage], **kwargs: Any) -> None: - raise RuntimeError("Store failed") - - serialized_data = { - "service_thread_id": None, - "chat_message_store_state": { - "messages": [{"role": "user", "text": "Hello"}], - }, - } - failing_store = FailingStore() - - with pytest.raises(AgentThreadException, match="Failed to deserialize"): - await AgentThread.deserialize(serialized_data, message_store=failing_store) - - async def test_update_from_thread_state_with_service_thread_id(self) -> None: - """Test update_from_thread_state sets service_thread_id.""" - thread = AgentThread() - serialized_data = {"service_thread_id": "new-thread-id"} - - await thread.update_from_thread_state(serialized_data) - - assert thread.service_thread_id == "new-thread-id" - - async def test_update_from_thread_state_with_empty_chat_state(self) -> None: - """Test update_from_thread_state with empty chat_message_store_state.""" - thread = AgentThread() - serialized_data = {"service_thread_id": None, "chat_message_store_state": None} - - await thread.update_from_thread_state(serialized_data) - - assert thread.message_store is None - - async def test_update_from_thread_state_creates_message_store(self) -> None: - """Test update_from_thread_state creates message store if not existing.""" - thread = AgentThread() - serialized_data = { - "service_thread_id": None, - "chat_message_store_state": { - "messages": [{"role": "user", "text": "Hello"}], - }, - } - - await thread.update_from_thread_state(serialized_data) - - assert thread.message_store is not None - messages = await thread.message_store.list_messages() - assert len(messages) == 1 diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index 0a616a35fc..dbcf7aac6f 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -10,10 +10,6 @@ from pydantic import BaseModel, ValidationError from agent_framework import ( Content, FunctionTool, - HostedCodeInterpreterTool, - HostedImageGenerationTool, - HostedMCPTool, - ToolProtocol, tool, ) from agent_framework._tools import ( @@ -21,7 +17,6 @@ from agent_framework._tools import ( _parse_annotation, _parse_inputs, ) -from agent_framework.exceptions import ToolException from agent_framework.observability import OtelAttr # region FunctionTool and tool decorator tests @@ -35,7 +30,6 @@ def test_tool_decorator(): """A simple function that adds two numbers.""" return x + y - assert isinstance(test_tool, ToolProtocol) assert isinstance(test_tool, FunctionTool) assert test_tool.name == "test_tool" assert test_tool.description == "A test tool" @@ -56,7 +50,6 @@ def test_tool_decorator_without_args(): """A simple function that adds two numbers.""" return x + y - assert isinstance(test_tool, ToolProtocol) assert isinstance(test_tool, FunctionTool) assert test_tool.name == "test_tool" assert test_tool.description == "A simple function that adds two numbers." @@ -145,7 +138,7 @@ async def test_tool_decorator_with_schema_invoke(): return a + b result = await calculate.invoke(arguments=CalcInput(a=3, b=7)) - assert result == 10 + assert result == "10" def test_tool_decorator_with_schema_overrides_annotations(): @@ -174,7 +167,7 @@ def test_tool_without_args(): """A simple function that adds two numbers.""" return 1 + 2 - assert isinstance(test_tool, ToolProtocol) + assert isinstance(test_tool, FunctionTool) assert isinstance(test_tool, FunctionTool) assert test_tool.name == "test_tool" assert test_tool.description == "A simple function that adds two numbers." @@ -194,7 +187,6 @@ async def test_tool_decorator_with_async(): """An async function that adds two numbers.""" return x + y - assert isinstance(async_test_tool, ToolProtocol) assert isinstance(async_test_tool, FunctionTool) assert async_test_tool.name == "async_test_tool" assert async_test_tool.description == "An async test tool" @@ -218,7 +210,6 @@ def test_tool_decorator_in_class(): test_tool = my_tools().test_tool - assert isinstance(test_tool, ToolProtocol) assert isinstance(test_tool, FunctionTool) assert test_tool.name == "test_tool" assert test_tool.description == "A test tool" @@ -445,7 +436,7 @@ async def test_tool_invoke_telemetry_enabled(span_exporter: InMemorySpanExporter result = await telemetry_test_tool.invoke(x=1, y=2, tool_call_id="test_call_id") # Verify result - assert result == 3 + assert result == "3" # Verify telemetry calls spans = span_exporter.get_finished_spans() @@ -489,7 +480,7 @@ async def test_tool_invoke_telemetry_sensitive_disabled(span_exporter: InMemoryS result = await telemetry_test_tool.invoke(x=1, y=2, tool_call_id="test_call_id") # Verify result - assert result == 3 + assert result == "3" # Verify telemetry calls spans = span_exporter.get_finished_spans() @@ -554,7 +545,7 @@ async def test_tool_invoke_telemetry_with_pydantic_args(span_exporter: InMemoryS result = await pydantic_test_tool.invoke(arguments=args_model, tool_call_id="pydantic_call") # Verify result - assert result == 15 + assert result == "15" spans = span_exporter.get_finished_spans() assert len(spans) == 1 span = spans[0] @@ -622,7 +613,7 @@ async def test_tool_invoke_telemetry_async_function(span_exporter: InMemorySpanE result = await async_telemetry_test.invoke(x=3, y=4, tool_call_id="async_call") # Verify result - assert result == 12 + assert result == "12" spans = span_exporter.get_finished_spans() assert len(spans) == 1 span = spans[0] @@ -701,30 +692,7 @@ def test_tool_serialization(): assert restored_tool_2(10, 4) == 6 -# region HostedCodeInterpreterTool and _parse_inputs - - -def test_hosted_code_interpreter_tool_default(): - """Test HostedCodeInterpreterTool with default parameters.""" - tool = HostedCodeInterpreterTool() - - assert tool.name == "code_interpreter" - assert tool.inputs == [] - assert tool.description == "" - assert tool.additional_properties is None - assert str(tool) == "HostedCodeInterpreterTool(name=code_interpreter)" - - -def test_hosted_code_interpreter_tool_with_description(): - """Test HostedCodeInterpreterTool with description and additional properties.""" - tool = HostedCodeInterpreterTool( - description="A test code interpreter", - additional_properties={"version": "1.0", "language": "python"}, - ) - - assert tool.name == "code_interpreter" - assert tool.description == "A test code interpreter" - assert tool.additional_properties == {"version": "1.0", "language": "python"} +# region _parse_inputs tests def test_parse_inputs_none(): @@ -853,185 +821,7 @@ def test_parse_inputs_unsupported_type(): _parse_inputs(123) -def test_hosted_code_interpreter_tool_with_string_input(): - """Test HostedCodeInterpreterTool with string input.""" - - tool = HostedCodeInterpreterTool(inputs="http://example.com") - - assert len(tool.inputs) == 1 - assert tool.inputs[0].type == "uri" - assert tool.inputs[0].uri == "http://example.com" - - -def test_hosted_code_interpreter_tool_with_dict_inputs(): - """Test HostedCodeInterpreterTool with dictionary inputs.""" - - inputs = [{"uri": "http://example.com", "media_type": "text/html"}, {"file_id": "file-123"}] - - tool = HostedCodeInterpreterTool(inputs=inputs) - - assert len(tool.inputs) == 2 - assert tool.inputs[0].type == "uri" - assert tool.inputs[0].uri == "http://example.com" - assert tool.inputs[0].media_type == "text/html" - assert tool.inputs[1].type == "hosted_file" - assert tool.inputs[1].file_id == "file-123" - - -def test_hosted_code_interpreter_tool_with_ai_contents(): - """Test HostedCodeInterpreterTool with Content instances.""" - - inputs = [Content.from_text(text="Hello, world!"), Content.from_data(data=b"test", media_type="text/plain")] - - tool = HostedCodeInterpreterTool(inputs=inputs) - - assert len(tool.inputs) == 2 - assert tool.inputs[0].type == "text" - assert tool.inputs[0].text == "Hello, world!" - assert tool.inputs[1].type == "data" - assert tool.inputs[1].media_type == "text/plain" - - -def test_hosted_code_interpreter_tool_with_single_input(): - """Test HostedCodeInterpreterTool with single input (not in list).""" - - input_dict = {"file_id": "file-single"} - tool = HostedCodeInterpreterTool(inputs=input_dict) - - assert len(tool.inputs) == 1 - assert tool.inputs[0].type == "hosted_file" - assert tool.inputs[0].file_id == "file-single" - - -def test_hosted_code_interpreter_tool_with_unknown_input(): - """Test HostedCodeInterpreterTool with single unknown input.""" - with pytest.raises(ValueError, match="Unsupported input type"): - HostedCodeInterpreterTool(inputs={"hosted_file": "file-single"}) - - -def test_hosted_image_generation_tool_defaults(): - """HostedImageGenerationTool should default name and empty description.""" - tool = HostedImageGenerationTool() - - assert tool.name == "image_generation" - assert tool.description == "" - assert tool.options is None - assert str(tool) == "HostedImageGenerationTool(name=image_generation)" - - -def test_hosted_image_generation_tool_with_options(): - """HostedImageGenerationTool should store options.""" - tool = HostedImageGenerationTool( - description="Generate images", - options={"format": "png", "size": "1024x1024"}, - additional_properties={"quality": "high"}, - ) - - assert tool.name == "image_generation" - assert tool.description == "Generate images" - assert tool.options == {"format": "png", "size": "1024x1024"} - assert tool.additional_properties == {"quality": "high"} - - -# region HostedMCPTool tests - - -def test_hosted_mcp_tool_with_other_fields(): - """Test creating a HostedMCPTool with a specific approval dict, headers and additional properties.""" - tool = HostedMCPTool( - name="mcp-tool", - url="https://mcp.example", - description="A test MCP tool", - headers={"x": "y"}, - additional_properties={"p": 1}, - ) - - assert tool.name == "mcp-tool" - # pydantic AnyUrl preserves as string-like - assert str(tool.url).startswith("https://") - assert tool.headers == {"x": "y"} - assert tool.additional_properties == {"p": 1} - assert tool.description == "A test MCP tool" - - -@pytest.mark.parametrize( - "approval_mode", - [ - "always_require", - "never_require", - { - "always_require_approval": {"toolA"}, - "never_require_approval": {"toolB"}, - }, - { - "always_require_approval": ["toolA"], - "never_require_approval": ("toolB",), - }, - ], - ids=["always_require", "never_require", "specific", "specific_with_parsing"], -) -def test_hosted_mcp_tool_with_approval_mode(approval_mode: str | dict[str, Any]): - """Test creating a HostedMCPTool with a specific approval dict, headers and additional properties.""" - tool = HostedMCPTool(name="mcp-tool", url="https://mcp.example", approval_mode=approval_mode) - - assert tool.name == "mcp-tool" - # pydantic AnyUrl preserves as string-like - assert str(tool.url).startswith("https://") - if not isinstance(approval_mode, dict): - assert tool.approval_mode == approval_mode - else: - # approval_mode parsed to sets - assert isinstance(tool.approval_mode["always_require_approval"], set) - assert isinstance(tool.approval_mode["never_require_approval"], set) - assert "toolA" in tool.approval_mode["always_require_approval"] - assert "toolB" in tool.approval_mode["never_require_approval"] - - -def test_hosted_mcp_tool_invalid_approval_mode_raises(): - """Invalid approval_mode string should raise ServiceInitializationError.""" - with pytest.raises(ToolException): - HostedMCPTool(name="bad", url="https://x", approval_mode="invalid_mode") - - -@pytest.mark.parametrize( - "tools", - [ - {"toolA", "toolB"}, - ("toolA", "toolB"), - ["toolA", "toolB"], - ["toolA", "toolB", "toolA"], - ], - ids=[ - "set", - "tuple", - "list", - "list_with_duplicates", - ], -) -def test_hosted_mcp_tool_with_allowed_tools(tools: list[str] | tuple[str, ...] | set[str]): - """Test creating a HostedMCPTool with a list of allowed tools.""" - tool = HostedMCPTool( - name="mcp-tool", - url="https://mcp.example", - allowed_tools=tools, - ) - - assert tool.name == "mcp-tool" - # pydantic AnyUrl preserves as string-like - assert str(tool.url).startswith("https://") - # approval_mode parsed to set - assert isinstance(tool.allowed_tools, set) - assert tool.allowed_tools == {"toolA", "toolB"} - - -def test_hosted_mcp_tool_with_dict_of_allowed_tools(): - """Test creating a HostedMCPTool with a dict of allowed tools.""" - with pytest.raises(ToolException): - HostedMCPTool( - name="mcp-tool", - url="https://mcp.example", - allowed_tools={"toolA": "Tool A", "toolC": "Tool C"}, - ) +# endregion async def test_ai_function_with_kwargs_injection(): diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 3fe9a1cf88..7a5acdedf7 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -14,19 +14,18 @@ from agent_framework import ( AgentResponse, AgentResponseUpdate, Annotation, - ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate, Content, + FunctionTool, + Message, ResponseStream, TextSpanRegion, ToolMode, - ToolProtocol, UsageDetails, detect_media_type_from_base64, merge_chat_options, - prepare_function_call_results, tool, ) from agent_framework._types import ( @@ -41,26 +40,20 @@ from agent_framework.exceptions import ContentError @fixture -def ai_tool() -> ToolProtocol: - """Returns a generic ToolProtocol.""" +def ai_tool() -> FunctionTool: + """Returns a generic FunctionTool.""" - class GenericTool(BaseModel): - name: str - description: str | None = None - additional_properties: dict[str, Any] | None = None + @tool + def generic_tool(name: str) -> str: + """A generic tool that echoes the name.""" + return f"Hello, {name}" - def parameters(self) -> dict[str, Any]: - """Return the parameters of the tool as a JSON schema.""" - return { - "name": {"type": "string"}, - } - - return GenericTool(name="generic_tool", description="A generic tool") + return generic_tool @fixture -def tool_tool() -> ToolProtocol: - """Returns a executable ToolProtocol.""" +def tool_tool() -> FunctionTool: + """Returns a executable FunctionTool.""" @tool def simple_function(x: int, y: int) -> int: @@ -566,13 +559,13 @@ def test_ai_content_serialization(args: dict): assert content == deserialized -# region ChatMessage +# region Message def test_chat_message_text(): - """Test the ChatMessage class to ensure it initializes correctly with text content.""" - # Create a ChatMessage with a role and text content - message = ChatMessage(role="user", text="Hello, how are you?") + """Test the Message class to ensure it initializes correctly with text content.""" + # Create a Message with a role and text content + message = Message(role="user", text="Hello, how are you?") # Check the type and content assert message.role == "user" @@ -586,11 +579,11 @@ def test_chat_message_text(): def test_chat_message_contents(): - """Test the ChatMessage class to ensure it initializes correctly with contents.""" - # Create a ChatMessage with a role and multiple contents + """Test the Message class to ensure it initializes correctly with contents.""" + # Create a Message with a role and multiple contents content1 = Content.from_text("Hello, how are you?") content2 = Content.from_text("I'm fine, thank you!") - message = ChatMessage(role="user", contents=[content1, content2]) + message = Message(role="user", contents=[content1, content2]) # Check the type and content assert message.role == "user" @@ -603,7 +596,7 @@ def test_chat_message_contents(): def test_chat_message_with_chatrole_instance(): - m = ChatMessage(role="user", text="hi") + m = Message(role="user", text="hi") assert m.role == "user" assert m.text == "hi" @@ -613,8 +606,8 @@ def test_chat_message_with_chatrole_instance(): def test_chat_response(): """Test the ChatResponse class to ensure it initializes correctly with a message.""" - # Create a ChatMessage - message = ChatMessage(role="assistant", text="I'm doing well, thank you!") + # Create a Message + message = Message(role="assistant", text="I'm doing well, thank you!") # Create a ChatResponse with the message response = ChatResponse(messages=message) @@ -622,7 +615,7 @@ def test_chat_response(): # Check the type and content assert response.messages[0].role == "assistant" assert response.messages[0].text == "I'm doing well, thank you!" - assert isinstance(response.messages[0], ChatMessage) + assert isinstance(response.messages[0], Message) # __str__ returns text assert str(response) == response.text @@ -633,8 +626,8 @@ class OutputModel(BaseModel): def test_chat_response_with_format(): """Test the ChatResponse class to ensure it initializes correctly with a message.""" - # Create a ChatMessage - message = ChatMessage(role="assistant", text='{"response": "Hello"}') + # Create a Message + message = Message(role="assistant", text='{"response": "Hello"}') # Create a ChatResponse with the message response = ChatResponse(messages=message, response_format=OutputModel) @@ -642,7 +635,7 @@ def test_chat_response_with_format(): # Check the type and content assert response.messages[0].role == "assistant" assert response.messages[0].text == '{"response": "Hello"}' - assert isinstance(response.messages[0], ChatMessage) + assert isinstance(response.messages[0], Message) assert response.text == '{"response": "Hello"}' assert response.value is not None assert response.value.response == "Hello" @@ -650,8 +643,8 @@ def test_chat_response_with_format(): def test_chat_response_with_format_init(): """Test the ChatResponse class to ensure it initializes correctly with a message.""" - # Create a ChatMessage - message = ChatMessage(role="assistant", text='{"response": "Hello"}') + # Create a Message + message = Message(role="assistant", text='{"response": "Hello"}') # Create a ChatResponse with the message response = ChatResponse(messages=message, response_format=OutputModel) @@ -659,7 +652,7 @@ def test_chat_response_with_format_init(): # Check the type and content assert response.messages[0].role == "assistant" assert response.messages[0].text == '{"response": "Hello"}' - assert isinstance(response.messages[0], ChatMessage) + assert isinstance(response.messages[0], Message) assert response.text == '{"response": "Hello"}' assert response.value is not None assert response.value.response == "Hello" @@ -673,7 +666,7 @@ def test_chat_response_value_raises_on_invalid_schema(): name: str = Field(min_length=10) score: int = Field(gt=0, le=100) - message = ChatMessage(role="assistant", text='{"id": 1, "name": "test", "score": -5}') + message = Message(role="assistant", text='{"id": 1, "name": "test", "score": -5}') response = ChatResponse(messages=message, response_format=StrictSchema) with raises(ValidationError) as exc_info: @@ -694,7 +687,7 @@ def test_agent_response_value_raises_on_invalid_schema(): name: str = Field(min_length=10) score: int = Field(gt=0, le=100) - message = ChatMessage(role="assistant", text='{"id": 1, "name": "test", "score": -5}') + message = Message(role="assistant", text='{"id": 1, "name": "test", "score": -5}') response = AgentResponse(messages=message, response_format=StrictSchema) with raises(ValidationError) as exc_info: @@ -712,7 +705,7 @@ def test_agent_response_value_raises_on_invalid_schema(): def test_chat_response_update(): """Test the ChatResponseUpdate class to ensure it initializes correctly with a message.""" - # Create a ChatMessage + # Create a Message message = Content.from_text(text="I'm doing well, thank you!") # Create a ChatResponseUpdate with the message @@ -726,7 +719,7 @@ def test_chat_response_update(): def test_chat_response_updates_to_chat_response_one(): """Test converting ChatResponseUpdate to ChatResponse.""" - # Create a ChatMessage + # Create a Message message1 = Content.from_text("I'm doing well, ") message2 = Content.from_text("thank you!") @@ -742,14 +735,14 @@ def test_chat_response_updates_to_chat_response_one(): # Check the type and content assert len(chat_response.messages) == 1 assert chat_response.text == "I'm doing well, thank you!" - assert isinstance(chat_response.messages[0], ChatMessage) + assert isinstance(chat_response.messages[0], Message) assert len(chat_response.messages[0].contents) == 1 assert chat_response.messages[0].message_id == "1" def test_chat_response_updates_to_chat_response_two(): """Test converting ChatResponseUpdate to ChatResponse.""" - # Create a ChatMessage + # Create a Message message1 = Content.from_text("I'm doing well, ") message2 = Content.from_text("thank you!") @@ -765,15 +758,15 @@ def test_chat_response_updates_to_chat_response_two(): # Check the type and content assert len(chat_response.messages) == 2 assert chat_response.text == "I'm doing well, \nthank you!" - assert isinstance(chat_response.messages[0], ChatMessage) + assert isinstance(chat_response.messages[0], Message) assert chat_response.messages[0].message_id == "1" - assert isinstance(chat_response.messages[1], ChatMessage) + assert isinstance(chat_response.messages[1], Message) assert chat_response.messages[1].message_id == "2" def test_chat_response_updates_to_chat_response_multiple(): """Test converting ChatResponseUpdate to ChatResponse.""" - # Create a ChatMessage + # Create a Message message1 = Content.from_text("I'm doing well, ") message2 = Content.from_text("thank you!") @@ -790,14 +783,14 @@ def test_chat_response_updates_to_chat_response_multiple(): # Check the type and content assert len(chat_response.messages) == 1 assert chat_response.text == "I'm doing well, thank you!" - assert isinstance(chat_response.messages[0], ChatMessage) + assert isinstance(chat_response.messages[0], Message) assert len(chat_response.messages[0].contents) == 3 assert chat_response.messages[0].message_id == "1" def test_chat_response_updates_to_chat_response_multiple_multiple(): """Test converting ChatResponseUpdate to ChatResponse.""" - # Create a ChatMessage + # Create a Message message1 = Content.from_text("I'm doing well, ", raw_representation="I'm doing well, ") message2 = Content.from_text("thank you!") @@ -815,7 +808,7 @@ def test_chat_response_updates_to_chat_response_multiple_multiple(): # Check the type and content assert len(chat_response.messages) == 1 - assert isinstance(chat_response.messages[0], ChatMessage) + assert isinstance(chat_response.messages[0], Message) assert chat_response.messages[0].message_id == "1" assert chat_response.messages[0].contents[0].raw_representation is not None @@ -1012,8 +1005,8 @@ def test_chat_options_and_tool_choice_required_specific_function() -> None: @fixture -def chat_message() -> ChatMessage: - return ChatMessage(role="user", text="Hello") +def chat_message() -> Message: + return Message(role="user", text="Hello") @fixture @@ -1022,7 +1015,7 @@ def text_content() -> Content: @fixture -def agent_response(chat_message: ChatMessage) -> AgentResponse: +def agent_response(chat_message: Message) -> AgentResponse: return AgentResponse(messages=chat_message) @@ -1034,12 +1027,12 @@ def agent_response_update(text_content: Content) -> AgentResponseUpdate: # region AgentResponse -def test_agent_run_response_init_single_message(chat_message: ChatMessage) -> None: +def test_agent_run_response_init_single_message(chat_message: Message) -> None: response = AgentResponse(messages=chat_message) assert response.messages == [chat_message] -def test_agent_run_response_init_list_messages(chat_message: ChatMessage) -> None: +def test_agent_run_response_init_list_messages(chat_message: Message) -> None: response = AgentResponse(messages=[chat_message, chat_message]) assert len(response.messages) == 2 assert response.messages[0] == chat_message @@ -1050,7 +1043,7 @@ def test_agent_run_response_init_none_messages() -> None: assert response.messages == [] -def test_agent_run_response_text_property(chat_message: ChatMessage) -> None: +def test_agent_run_response_text_property(chat_message: Message) -> None: response = AgentResponse(messages=[chat_message, chat_message]) assert response.text == "HelloHello" @@ -1067,7 +1060,7 @@ def test_agent_run_response_from_updates(agent_response_update: AgentResponseUpd assert response.text == "Test contentTest content" -def test_agent_run_response_str_method(chat_message: ChatMessage) -> None: +def test_agent_run_response_str_method(chat_message: Message) -> None: response = AgentResponse(messages=chat_message) assert str(response) == "Hello" @@ -1130,7 +1123,7 @@ def test_agent_run_response_created_at() -> None: # Test with a properly formatted UTC timestamp utc_timestamp = "2024-12-01T00:31:30.000000Z" response = AgentResponse( - messages=[ChatMessage(role="assistant", text="Hello")], + messages=[Message(role="assistant", text="Hello")], created_at=utc_timestamp, ) assert response.created_at == utc_timestamp @@ -1140,7 +1133,7 @@ def test_agent_run_response_created_at() -> None: now_utc = datetime.now(tz=timezone.utc) formatted_utc = now_utc.strftime("%Y-%m-%dT%H:%M:%S.%fZ") response_with_now = AgentResponse( - messages=[ChatMessage(role="assistant", text="Hello")], + messages=[Message(role="assistant", text="Hello")], created_at=formatted_utc, ) assert response_with_now.created_at == formatted_utc @@ -1294,7 +1287,7 @@ def test_chat_tool_mode_eq_with_string(): @fixture def agent_run_response_async() -> AgentResponse: - return AgentResponse(messages=[ChatMessage(role="user", text="Hello")]) + return AgentResponse(messages=[Message(role="user", text="Hello")]) async def test_agent_run_response_from_async_generator(): @@ -1444,7 +1437,7 @@ def test_usage_details_iadd_edge_cases(): def test_chat_message_from_dict_with_mixed_content(): - """Test ChatMessage from_dict with mixed content types for better coverage.""" + """Test Message from_dict with mixed content types for better coverage.""" message_data = { "role": "assistant", @@ -1455,7 +1448,7 @@ def test_chat_message_from_dict_with_mixed_content(): ], } - message = ChatMessage.from_dict(message_data) + message = Message.from_dict(message_data) assert len(message.contents) == 3 # Unknown type is ignored assert message.contents[0].type == "text" assert message.contents[1].type == "function_call" @@ -1513,7 +1506,7 @@ def test_comprehensive_serialization_methods(): def test_chat_message_complex_content_serialization(): - """Test ChatMessage serialization with various content types.""" + """Test Message serialization with various content types.""" # Create a message with multiple content types contents = [ @@ -1522,7 +1515,7 @@ def test_chat_message_complex_content_serialization(): Content.from_function_result(call_id="call1", result="success"), ] - message = ChatMessage(role="assistant", contents=contents) + message = Message(role="assistant", contents=contents) # Test to_dict message_dict = message.to_dict() @@ -1532,7 +1525,7 @@ def test_chat_message_complex_content_serialization(): assert message_dict["contents"][2]["type"] == "function_result" # Test from_dict round-trip - reconstructed = ChatMessage.from_dict(message_dict) + reconstructed = Message.from_dict(message_dict) assert len(reconstructed.contents) == 3 assert reconstructed.contents[0].type == "text" assert reconstructed.contents[1].type == "function_call" @@ -1610,7 +1603,7 @@ def test_chat_response_complex_serialization(): response = ChatResponse.from_dict(response_data) assert len(response.messages) == 2 - assert isinstance(response.messages[0], ChatMessage) + assert isinstance(response.messages[0], Message) assert isinstance(response.finish_reason, str) # FinishReason is now a NewType of str assert isinstance(response.usage_details, dict) assert response.model_id == "gpt-4" # Should be stored as model_id @@ -1687,7 +1680,7 @@ def test_agent_run_response_complex_serialization(): response = AgentResponse.from_dict(response_data) assert len(response.messages) == 2 - assert isinstance(response.messages[0], ChatMessage) + assert isinstance(response.messages[0], Message) assert isinstance(response.usage_details, dict) # Test to_dict @@ -1869,7 +1862,7 @@ def test_agent_run_response_update_all_content_types(): id="function_approval_response", ), pytest.param( - ChatMessage, + Message, { "role": "\1", "contents": [ @@ -1887,12 +1880,12 @@ def test_agent_run_response_update_all_content_types(): "type": "chat_response", "messages": [ { - "type": "chat_message", + "type": "message", "role": "\1", "contents": [{"type": "text", "text": "Hello"}], }, { - "type": "chat_message", + "type": "message", "role": "\1", "contents": [{"type": "text", "text": "Hi there"}], }, @@ -2078,7 +2071,7 @@ def test_text_content_with_annotations_serialization(): assert all(isinstance(ann["annotated_regions"][0], dict) for ann in reconstructed.annotations) -# region prepare_function_call_results with Pydantic models +# region FunctionTool.parse_result with Pydantic models class WeatherResult(BaseModel): @@ -2095,10 +2088,10 @@ class NestedModel(BaseModel): weather: WeatherResult -def test_prepare_function_call_results_pydantic_model(): +def test_parse_result_pydantic_model(): """Test that Pydantic BaseModel subclasses are properly serialized using model_dump().""" result = WeatherResult(temperature=22.5, condition="sunny") - json_result = prepare_function_call_results(result) + json_result = FunctionTool.parse_result(result) # The result should be a valid JSON string assert isinstance(json_result, str) @@ -2106,13 +2099,13 @@ def test_prepare_function_call_results_pydantic_model(): assert '"condition": "sunny"' in json_result or '"condition":"sunny"' in json_result -def test_prepare_function_call_results_pydantic_model_in_list(): +def test_parse_result_pydantic_model_in_list(): """Test that lists containing Pydantic models are properly serialized.""" results = [ WeatherResult(temperature=20.0, condition="cloudy"), WeatherResult(temperature=25.0, condition="sunny"), ] - json_result = prepare_function_call_results(results) + json_result = FunctionTool.parse_result(results) # The result should be a valid JSON string representing a list assert isinstance(json_result, str) @@ -2122,13 +2115,13 @@ def test_prepare_function_call_results_pydantic_model_in_list(): assert "sunny" in json_result -def test_prepare_function_call_results_pydantic_model_in_dict(): +def test_parse_result_pydantic_model_in_dict(): """Test that dicts containing Pydantic models are properly serialized.""" results = { "current": WeatherResult(temperature=22.0, condition="partly cloudy"), "forecast": WeatherResult(temperature=24.0, condition="sunny"), } - json_result = prepare_function_call_results(results) + json_result = FunctionTool.parse_result(results) # The result should be a valid JSON string representing a dict assert isinstance(json_result, str) @@ -2138,10 +2131,10 @@ def test_prepare_function_call_results_pydantic_model_in_dict(): assert "sunny" in json_result -def test_prepare_function_call_results_nested_pydantic_model(): +def test_parse_result_nested_pydantic_model(): """Test that nested Pydantic models are properly serialized.""" result = NestedModel(name="Seattle", weather=WeatherResult(temperature=18.0, condition="rainy")) - json_result = prepare_function_call_results(result) + json_result = FunctionTool.parse_result(result) # The result should be a valid JSON string assert isinstance(json_result, str) @@ -2150,10 +2143,10 @@ def test_prepare_function_call_results_nested_pydantic_model(): assert "18.0" in json_result or "18" in json_result -# region prepare_function_call_results with MCP TextContent-like objects +# region FunctionTool.parse_result with MCP TextContent-like objects -def test_prepare_function_call_results_text_content_single(): +def test_parse_result_text_content_single(): """Test that objects with text attribute (like MCP TextContent) are properly handled.""" @dataclass @@ -2161,14 +2154,14 @@ def test_prepare_function_call_results_text_content_single(): text: str result = [MockTextContent("Hello from MCP tool!")] - json_result = prepare_function_call_results(result) + json_result = FunctionTool.parse_result(result) # Should extract text and serialize as JSON array of strings assert isinstance(json_result, str) assert json_result == '["Hello from MCP tool!"]' -def test_prepare_function_call_results_text_content_multiple(): +def test_parse_result_text_content_multiple(): """Test that multiple TextContent-like objects are serialized correctly.""" @dataclass @@ -2176,14 +2169,14 @@ def test_prepare_function_call_results_text_content_multiple(): text: str result = [MockTextContent("First result"), MockTextContent("Second result")] - json_result = prepare_function_call_results(result) + json_result = FunctionTool.parse_result(result) # Should extract text from each and serialize as JSON array assert isinstance(json_result, str) assert json_result == '["First result", "Second result"]' -def test_prepare_function_call_results_text_content_with_non_string_text(): +def test_parse_result_text_content_with_non_string_text(): """Test that objects with non-string text attribute are not treated as TextContent.""" class BadTextContent: @@ -2191,12 +2184,40 @@ def test_prepare_function_call_results_text_content_with_non_string_text(): self.text = 12345 # Not a string! result = [BadTextContent()] - json_result = prepare_function_call_results(result) + json_result = FunctionTool.parse_result(result) # Should not extract text since it's not a string, will serialize the object assert isinstance(json_result, str) +def test_parse_result_none_returns_empty_string(): + """Test that None returns an empty string.""" + assert FunctionTool.parse_result(None) == "" + + +def test_parse_result_string_passthrough(): + """Test that strings are returned as-is.""" + assert FunctionTool.parse_result("hello world") == "hello world" + assert FunctionTool.parse_result('{"key": "value"}') == '{"key": "value"}' + + +def test_parse_result_content_object(): + """Test that Content objects are serialized via to_dict.""" + content = Content.from_text("hello") + result = FunctionTool.parse_result(content) + assert isinstance(result, str) + assert "hello" in result + + +def test_parse_result_list_of_content(): + """Test that list[Content] is serialized to JSON.""" + contents = [Content.from_text("hello"), Content.from_text("world")] + result = FunctionTool.parse_result(contents) + assert isinstance(result, str) + assert "hello" in result + assert "world" in result + + # endregion @@ -2761,7 +2782,7 @@ class TestResponseStreamResultHooks: """Result hook can transform the final result.""" def wrap_text(response: ChatResponse) -> ChatResponse: - return ChatResponse(messages=ChatMessage("assistant", [f"[{response.text}]"])) + return ChatResponse(messages=Message("assistant", [f"[{response.text}]"])) stream = ResponseStream( _generate_updates(2), @@ -2777,10 +2798,10 @@ class TestResponseStreamResultHooks: """Multiple result hooks are called in order.""" def add_prefix(response: ChatResponse) -> ChatResponse: - return ChatResponse(messages=ChatMessage("assistant", [f"prefix_{response.text}"])) + return ChatResponse(messages=Message("assistant", [f"prefix_{response.text}"])) def add_suffix(response: ChatResponse) -> ChatResponse: - return ChatResponse(messages=ChatMessage("assistant", [f"{response.text}_suffix"])) + return ChatResponse(messages=Message("assistant", [f"{response.text}_suffix"])) stream = ResponseStream( _generate_updates(1), @@ -2828,7 +2849,7 @@ class TestResponseStreamResultHooks: """Async result hooks are awaited.""" async def async_hook(response: ChatResponse) -> ChatResponse: - return ChatResponse(messages=ChatMessage("assistant", [f"async_{response.text}"])) + return ChatResponse(messages=Message("assistant", [f"async_{response.text}"])) stream = ResponseStream( _generate_updates(2), @@ -2850,7 +2871,7 @@ class TestResponseStreamFinalizer: def capturing_finalizer(updates: list[ChatResponseUpdate]) -> ChatResponse: received_updates.extend(updates) - return ChatResponse(messages=ChatMessage("assistant", ["done"])) + return ChatResponse(messages=Message("assistant", ["done"])) stream = ResponseStream(_generate_updates(3), finalizer=capturing_finalizer) @@ -2875,7 +2896,7 @@ class TestResponseStreamFinalizer: async def async_finalizer(updates: list[ChatResponseUpdate]) -> ChatResponse: text = "".join(u.text or "" for u in updates) - return ChatResponse(messages=ChatMessage("assistant", [f"async_{text}"])) + return ChatResponse(messages=Message("assistant", [f"async_{text}"])) stream = ResponseStream(_generate_updates(2), finalizer=async_finalizer) @@ -2889,7 +2910,7 @@ class TestResponseStreamFinalizer: def counting_finalizer(updates: list[ChatResponseUpdate]) -> ChatResponse: call_count["value"] += 1 - return ChatResponse(messages=ChatMessage("assistant", ["done"])) + return ChatResponse(messages=Message("assistant", ["done"])) stream = ResponseStream(_generate_updates(2), finalizer=counting_finalizer) @@ -2949,7 +2970,7 @@ class TestResponseStreamMapAndWithFinalizer: def inner_result_hook(response: ChatResponse) -> ChatResponse: inner_result_hook_called["value"] = True - return ChatResponse(messages=ChatMessage("assistant", [f"hooked_{response.text}"])) + return ChatResponse(messages=Message("assistant", [f"hooked_{response.text}"])) inner = ResponseStream( _generate_updates(2), @@ -2969,7 +2990,7 @@ class TestResponseStreamMapAndWithFinalizer: def inner_finalizer(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: inner_finalizer_called["value"] = True - return ChatResponse(messages=ChatMessage("assistant", ["inner_result"])) + return ChatResponse(messages=Message("assistant", ["inner_result"])) inner = ResponseStream( _generate_updates(2), @@ -2989,7 +3010,7 @@ class TestResponseStreamMapAndWithFinalizer: inner = ResponseStream(_generate_updates(2), finalizer=_combine_updates) def outer_hook(response: ChatResponse) -> ChatResponse: - return ChatResponse(messages=ChatMessage("assistant", [f"outer_{response.text}"])) + return ChatResponse(messages=Message("assistant", [f"outer_{response.text}"])) outer = inner.with_finalizer(_combine_updates).with_result_hook(outer_hook) @@ -3114,7 +3135,7 @@ class TestResponseStreamExecutionOrder: def finalizer(updates: list[ChatResponseUpdate]) -> ChatResponse: order.append("finalizer") - return ChatResponse(messages=ChatMessage("assistant", ["done"])) + return ChatResponse(messages=Message("assistant", ["done"])) def result_hook(response: ChatResponse) -> ChatResponse: order.append("result") @@ -3149,7 +3170,7 @@ class TestResponseStreamExecutionOrder: def finalizer(updates: list[ChatResponseUpdate]) -> ChatResponse: order.append("finalizer") - return ChatResponse(messages=ChatMessage("assistant", ["done"])) + return ChatResponse(messages=Message("assistant", ["done"])) stream = ResponseStream( _generate_updates(2), @@ -3269,7 +3290,7 @@ class TestResponseStreamEdgeCases: def finalizer(updates: list[ChatResponseUpdate]) -> ChatResponse: events.append("finalizer") - return ChatResponse(messages=ChatMessage("assistant", ["done"])) + return ChatResponse(messages=Message("assistant", ["done"])) def result(r: ChatResponse) -> ChatResponse: events.append("result") diff --git a/python/packages/core/tests/openai/test_assistant_provider.py b/python/packages/core/tests/openai/test_assistant_provider.py index 90b077c941..a9dbb039b6 100644 --- a/python/packages/core/tests/openai/test_assistant_provider.py +++ b/python/packages/core/tests/openai/test_assistant_provider.py @@ -8,9 +8,9 @@ import pytest from openai.types.beta.assistant import Assistant from pydantic import BaseModel, Field -from agent_framework import ChatAgent, HostedCodeInterpreterTool, HostedFileSearchTool, normalize_tools, tool +from agent_framework import Agent, normalize_tools, tool from agent_framework.exceptions import ServiceInitializationError -from agent_framework.openai import OpenAIAssistantProvider +from agent_framework.openai import OpenAIAssistantProvider, OpenAIAssistantsClient from agent_framework.openai._shared import from_assistant_tools, to_assistant_tools # region Test Helpers @@ -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() @@ -202,7 +208,7 @@ class TestOpenAIAssistantProviderCreateAgent: instructions="You are helpful.", ) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) assert agent.name == "CreatedAssistant" mock_async_openai.beta.assistants.create.assert_called_once() @@ -235,7 +241,7 @@ class TestOpenAIAssistantProviderCreateAgent: tools=[get_weather], ) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) # Verify tools were passed to create call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs @@ -269,7 +275,7 @@ class TestOpenAIAssistantProviderCreateAgent: await provider.create_agent( name="CodeAgent", model="gpt-4", - tools=[HostedCodeInterpreterTool()], + tools=[OpenAIAssistantsClient.get_code_interpreter_tool()], ) call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs @@ -282,7 +288,7 @@ class TestOpenAIAssistantProviderCreateAgent: await provider.create_agent( name="SearchAgent", model="gpt-4", - tools=[HostedFileSearchTool()], + tools=[OpenAIAssistantsClient.get_file_search_tool()], ) call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs @@ -295,7 +301,7 @@ class TestOpenAIAssistantProviderCreateAgent: await provider.create_agent( name="SearchAgent", model="gpt-4", - tools=[HostedFileSearchTool(max_results=10)], + tools=[OpenAIAssistantsClient.get_file_search_tool(max_num_results=10)], ) call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs @@ -309,7 +315,11 @@ class TestOpenAIAssistantProviderCreateAgent: await provider.create_agent( name="MultiToolAgent", model="gpt-4", - tools=[get_weather, HostedCodeInterpreterTool(), HostedFileSearchTool()], + tools=[ + get_weather, + OpenAIAssistantsClient.get_code_interpreter_tool(), + OpenAIAssistantsClient.get_file_search_tool(), + ], ) call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs @@ -343,7 +353,7 @@ class TestOpenAIAssistantProviderCreateAgent: assert call_kwargs["response_format"]["json_schema"]["name"] == "WeatherResponse" async def test_create_agent_returns_chat_agent(self, mock_async_openai: MagicMock) -> None: - """Test that create_agent returns a ChatAgent instance.""" + """Test that create_agent returns a Agent instance.""" provider = OpenAIAssistantProvider(mock_async_openai) agent = await provider.create_agent( @@ -351,7 +361,7 @@ class TestOpenAIAssistantProviderCreateAgent: model="gpt-4", ) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) # endregion @@ -369,7 +379,7 @@ class TestOpenAIAssistantProviderGetAgent: agent = await provider.get_agent(assistant_id="asst_123") - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) mock_async_openai.beta.assistants.retrieve.assert_called_once_with("asst_123") async def test_get_agent_with_instructions_override(self, mock_async_openai: MagicMock) -> None: @@ -382,7 +392,7 @@ class TestOpenAIAssistantProviderGetAgent: ) # Agent should be created successfully with the custom instructions - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) assert agent.id == "asst_retrieved123" async def test_get_agent_with_function_tools(self, mock_async_openai: MagicMock) -> None: @@ -398,7 +408,7 @@ class TestOpenAIAssistantProviderGetAgent: tools=[get_weather], ) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) async def test_get_agent_validates_missing_function_tools(self, mock_async_openai: MagicMock) -> None: """Test that missing function tools raise ValueError.""" @@ -439,7 +449,7 @@ class TestOpenAIAssistantProviderGetAgent: agent = await provider.get_agent(assistant_id="asst_123") # Hosted tools should be merged automatically - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) # endregion @@ -458,7 +468,7 @@ class TestOpenAIAssistantProviderAsAgent: agent = provider.as_agent(assistant) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) # Verify no HTTP calls were made mock_async_openai.beta.assistants.create.assert_not_called() mock_async_openai.beta.assistants.retrieve.assert_not_called() @@ -477,7 +487,7 @@ class TestOpenAIAssistantProviderAsAgent: assert agent.id == "asst_wrap123" assert agent.name == "WrappedAssistant" # Instructions are passed to ChatOptions, not exposed as attribute - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) def test_as_agent_with_instructions_override(self, mock_async_openai: MagicMock) -> None: """Test as_agent with instruction override.""" @@ -487,7 +497,7 @@ class TestOpenAIAssistantProviderAsAgent: agent = provider.as_agent(assistant, instructions="Override") # Agent should be created successfully with override instructions - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) def test_as_agent_validates_function_tools(self, mock_async_openai: MagicMock) -> None: """Test that missing function tools raise ValueError.""" @@ -506,7 +516,7 @@ class TestOpenAIAssistantProviderAsAgent: agent = provider.as_agent(assistant, tools=[get_weather]) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) def test_as_agent_merges_hosted_tools(self, mock_async_openai: MagicMock) -> None: """Test that hosted tools are merged automatically.""" @@ -515,7 +525,7 @@ class TestOpenAIAssistantProviderAsAgent: agent = provider.as_agent(assistant) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) def test_as_agent_hosted_tools_not_required(self, mock_async_openai: MagicMock) -> None: """Test that hosted tools don't require user implementations.""" @@ -525,7 +535,7 @@ class TestOpenAIAssistantProviderAsAgent: # Should not raise - hosted tools don't need implementations agent = provider.as_agent(assistant) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) # endregion @@ -564,22 +574,22 @@ class TestToolConversion: assert api_tools[0]["function"]["name"] == "get_weather" def test_to_assistant_tools_code_interpreter(self) -> None: - """Test HostedCodeInterpreterTool conversion.""" - api_tools = to_assistant_tools([HostedCodeInterpreterTool()]) + """Test code_interpreter tool dict conversion.""" + api_tools = to_assistant_tools([OpenAIAssistantsClient.get_code_interpreter_tool()]) assert len(api_tools) == 1 assert api_tools[0] == {"type": "code_interpreter"} def test_to_assistant_tools_file_search(self) -> None: - """Test HostedFileSearchTool conversion.""" - api_tools = to_assistant_tools([HostedFileSearchTool()]) + """Test file_search tool dict conversion.""" + api_tools = to_assistant_tools([OpenAIAssistantsClient.get_file_search_tool()]) assert len(api_tools) == 1 assert api_tools[0]["type"] == "file_search" def test_to_assistant_tools_file_search_with_max_results(self) -> None: - """Test HostedFileSearchTool with max_results conversion.""" - api_tools = to_assistant_tools([HostedFileSearchTool(max_results=5)]) + """Test file_search tool with max_results conversion.""" + api_tools = to_assistant_tools([OpenAIAssistantsClient.get_file_search_tool(max_num_results=5)]) assert api_tools[0]["file_search"]["max_num_results"] == 5 @@ -605,7 +615,7 @@ class TestToolConversion: tools = from_assistant_tools(assistant_tools) assert len(tools) == 1 - assert isinstance(tools[0], HostedCodeInterpreterTool) + assert tools[0] == {"type": "code_interpreter"} def test_from_assistant_tools_file_search(self) -> None: """Test converting file_search tool from OpenAI format.""" @@ -614,7 +624,7 @@ class TestToolConversion: tools = from_assistant_tools(assistant_tools) assert len(tools) == 1 - assert isinstance(tools[0], HostedFileSearchTool) + assert tools[0] == {"type": "file_search"} def test_from_assistant_tools_function_skipped(self) -> None: """Test that function tools are skipped (no implementations).""" @@ -707,7 +717,7 @@ class TestToolMerging: merged = provider._merge_tools(assistant_tools, None) # type: ignore[reportPrivateUsage] assert len(merged) == 1 - assert isinstance(merged[0], HostedCodeInterpreterTool) + assert merged[0] == {"type": "code_interpreter"} def test_merge_file_search(self, mock_async_openai: MagicMock) -> None: """Test merging file search tool.""" @@ -717,7 +727,7 @@ class TestToolMerging: merged = provider._merge_tools(assistant_tools, None) # type: ignore[reportPrivateUsage] assert len(merged) == 1 - assert isinstance(merged[0], HostedFileSearchTool) + assert merged[0] == {"type": "file_search"} def test_merge_with_user_tools(self, mock_async_openai: MagicMock) -> None: """Test merging hosted and user tools.""" @@ -727,7 +737,7 @@ class TestToolMerging: merged = provider._merge_tools(assistant_tools, [get_weather]) # type: ignore[reportPrivateUsage] assert len(merged) == 2 - assert isinstance(merged[0], HostedCodeInterpreterTool) + assert merged[0] == {"type": "code_interpreter"} def test_merge_multiple_hosted_tools(self, mock_async_openai: MagicMock) -> None: """Test merging multiple hosted tools.""" 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 2cefc5ad54..80e2020d02 100644 --- a/python/packages/core/tests/openai/test_openai_assistants_client.py +++ b/python/packages/core/tests/openai/test_openai_assistants_client.py @@ -11,17 +11,15 @@ from openai.types.beta.threads.runs import RunStep from pydantic import Field from agent_framework import ( + Agent, AgentResponse, AgentResponseUpdate, - AgentThread, - ChatAgent, - ChatClientProtocol, - ChatMessage, + AgentSession, ChatResponse, ChatResponseUpdate, Content, - HostedCodeInterpreterTool, - HostedFileSearchTool, + Message, + SupportsChatGetResponse, tool, ) from agent_framework.exceptions import ServiceInitializationError @@ -113,16 +111,16 @@ def mock_async_openai() -> MagicMock: def test_init_with_client(mock_async_openai: MagicMock) -> None: """Test OpenAIAssistantsClient initialization with existing client.""" - chat_client = create_test_openai_assistants_client( + client = create_test_openai_assistants_client( mock_async_openai, model_id="gpt-4", assistant_id="existing-assistant-id", thread_id="test-thread-id" ) - assert chat_client.client is mock_async_openai - assert chat_client.model_id == "gpt-4" - assert chat_client.assistant_id == "existing-assistant-id" - assert chat_client.thread_id == "test-thread-id" - assert not chat_client._should_delete_assistant # type: ignore - assert isinstance(chat_client, ChatClientProtocol) + assert client.client is mock_async_openai + assert client.model_id == "gpt-4" + assert client.assistant_id == "existing-assistant-id" + assert client.thread_id == "test-thread-id" + assert not client._should_delete_assistant # type: ignore + assert isinstance(client, SupportsChatGetResponse) def test_init_auto_create_client( @@ -130,7 +128,7 @@ def test_init_auto_create_client( mock_async_openai: MagicMock, ) -> None: """Test OpenAIAssistantsClient initialization with auto-created client.""" - chat_client = OpenAIAssistantsClient( + client = OpenAIAssistantsClient( model_id=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], assistant_name="TestAssistant", api_key=openai_unit_test_env["OPENAI_API_KEY"], @@ -138,17 +136,17 @@ def test_init_auto_create_client( async_client=mock_async_openai, ) - assert chat_client.client is mock_async_openai - assert chat_client.model_id == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"] - assert chat_client.assistant_id is None - assert chat_client.assistant_name == "TestAssistant" - assert not chat_client._should_delete_assistant # type: ignore + assert client.client is mock_async_openai + assert client.model_id == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"] + assert client.assistant_id is None + assert client.assistant_name == "TestAssistant" + assert not client._should_delete_assistant # type: ignore 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 @@ -172,31 +170,31 @@ def test_init_with_default_headers(openai_unit_test_env: dict[str, str]) -> None """Test OpenAIAssistantsClient initialization with default headers.""" default_headers = {"X-Unit-Test": "test-guid"} - chat_client = OpenAIAssistantsClient( + client = OpenAIAssistantsClient( model_id="gpt-4", api_key=openai_unit_test_env["OPENAI_API_KEY"], default_headers=default_headers, ) - assert chat_client.model_id == "gpt-4" - assert isinstance(chat_client, ChatClientProtocol) + assert client.model_id == "gpt-4" + assert isinstance(client, SupportsChatGetResponse) # Assert that the default header we added is present in the client's default headers for key, value in default_headers.items(): - assert key in chat_client.client.default_headers - assert chat_client.client.default_headers[key] == value + assert key in client.client.default_headers + assert client.client.default_headers[key] == value async def test_get_assistant_id_or_create_existing_assistant( mock_async_openai: MagicMock, ) -> None: """Test _get_assistant_id_or_create when assistant_id is already provided.""" - chat_client = create_test_openai_assistants_client(mock_async_openai, assistant_id="existing-assistant-id") + client = create_test_openai_assistants_client(mock_async_openai, assistant_id="existing-assistant-id") - assistant_id = await chat_client._get_assistant_id_or_create() # type: ignore + assistant_id = await client._get_assistant_id_or_create() # type: ignore assert assistant_id == "existing-assistant-id" - assert not chat_client._should_delete_assistant # type: ignore + assert not client._should_delete_assistant # type: ignore mock_async_openai.beta.assistants.create.assert_not_called() @@ -204,14 +202,12 @@ async def test_get_assistant_id_or_create_create_new( mock_async_openai: MagicMock, ) -> None: """Test _get_assistant_id_or_create when creating a new assistant.""" - chat_client = create_test_openai_assistants_client( - mock_async_openai, model_id="gpt-4", assistant_name="TestAssistant" - ) + client = create_test_openai_assistants_client(mock_async_openai, model_id="gpt-4", assistant_name="TestAssistant") - assistant_id = await chat_client._get_assistant_id_or_create() # type: ignore + assistant_id = await client._get_assistant_id_or_create() # type: ignore assert assistant_id == "test-assistant-id" - assert chat_client._should_delete_assistant # type: ignore + assert client._should_delete_assistant # type: ignore mock_async_openai.beta.assistants.create.assert_called_once() @@ -219,38 +215,38 @@ async def test_aclose_should_not_delete( mock_async_openai: MagicMock, ) -> None: """Test close when assistant should not be deleted.""" - chat_client = create_test_openai_assistants_client( + client = create_test_openai_assistants_client( mock_async_openai, assistant_id="assistant-to-keep", should_delete_assistant=False ) - await chat_client.close() # type: ignore + await client.close() # type: ignore # Verify assistant deletion was not called mock_async_openai.beta.assistants.delete.assert_not_called() - assert not chat_client._should_delete_assistant # type: ignore + assert not client._should_delete_assistant # type: ignore async def test_aclose_should_delete(mock_async_openai: MagicMock) -> None: """Test close method calls cleanup.""" - chat_client = create_test_openai_assistants_client( + client = create_test_openai_assistants_client( mock_async_openai, assistant_id="assistant-to-delete", should_delete_assistant=True ) - await chat_client.close() + await client.close() # Verify assistant deletion was called mock_async_openai.beta.assistants.delete.assert_called_once_with("assistant-to-delete") - assert not chat_client._should_delete_assistant # type: ignore + assert not client._should_delete_assistant # type: ignore async def test_async_context_manager(mock_async_openai: MagicMock) -> None: """Test async context manager functionality.""" - chat_client = create_test_openai_assistants_client( + client = create_test_openai_assistants_client( mock_async_openai, assistant_id="assistant-to-delete", should_delete_assistant=True ) # Test context manager - async with chat_client: + async with client: pass # Just test that we can enter and exit # Verify cleanup was called on exit @@ -262,7 +258,7 @@ def test_serialize(openai_unit_test_env: dict[str, str]) -> None: default_headers = {"X-Unit-Test": "test-guid"} # Test basic initialization and to_dict - chat_client = OpenAIAssistantsClient( + client = OpenAIAssistantsClient( model_id="gpt-4", assistant_id="test-assistant-id", assistant_name="TestAssistant", @@ -272,7 +268,7 @@ def test_serialize(openai_unit_test_env: dict[str, str]) -> None: default_headers=default_headers, ) - dumped_settings = chat_client.to_dict() + dumped_settings = client.to_dict() assert dumped_settings["model_id"] == "gpt-4" assert dumped_settings["assistant_id"] == "test-assistant-id" @@ -290,9 +286,9 @@ def test_serialize(openai_unit_test_env: dict[str, str]) -> None: async def test_get_active_thread_run_none_thread_id(mock_async_openai: MagicMock) -> None: """Test _get_active_thread_run with None thread_id returns None.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) - result = await chat_client._get_active_thread_run(None) # type: ignore + result = await client._get_active_thread_run(None) # type: ignore assert result is None # Should not call the API when thread_id is None @@ -302,7 +298,7 @@ async def test_get_active_thread_run_none_thread_id(mock_async_openai: MagicMock async def test_get_active_thread_run_with_active_run(mock_async_openai: MagicMock) -> None: """Test _get_active_thread_run finds an active run.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) # Mock an active run (status not in completed states) mock_run = MagicMock() @@ -314,7 +310,7 @@ async def test_get_active_thread_run_with_active_run(mock_async_openai: MagicMoc mock_async_openai.beta.threads.runs.list.return_value.__aiter__ = mock_runs_list - result = await chat_client._get_active_thread_run("thread-123") # type: ignore + result = await client._get_active_thread_run("thread-123") # type: ignore assert result == mock_run mock_async_openai.beta.threads.runs.list.assert_called_once_with(thread_id="thread-123", limit=1, order="desc") @@ -322,7 +318,7 @@ async def test_get_active_thread_run_with_active_run(mock_async_openai: MagicMoc async def test_prepare_thread_create_new(mock_async_openai: MagicMock) -> None: """Test _prepare_thread creates new thread when thread_id is None.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) # Mock thread creation mock_thread = MagicMock() @@ -336,7 +332,7 @@ async def test_prepare_thread_create_new(mock_async_openai: MagicMock) -> None: "metadata": {"test": "true"}, } - result = await chat_client._prepare_thread(None, None, run_options) # type: ignore + result = await client._prepare_thread(None, None, run_options) # type: ignore assert result == "new-thread-123" assert run_options["additional_messages"] == [] # Should be cleared @@ -349,7 +345,7 @@ async def test_prepare_thread_create_new(mock_async_openai: MagicMock) -> None: async def test_prepare_thread_cancel_existing_run(mock_async_openai: MagicMock) -> None: """Test _prepare_thread cancels existing run when provided.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) # Mock an existing thread run mock_thread_run = MagicMock() @@ -357,7 +353,7 @@ async def test_prepare_thread_cancel_existing_run(mock_async_openai: MagicMock) run_options: dict[str, Any] = {"additional_messages": []} - result = await chat_client._prepare_thread("thread-123", mock_thread_run, run_options) # type: ignore + result = await client._prepare_thread("thread-123", mock_thread_run, run_options) # type: ignore assert result == "thread-123" mock_async_openai.beta.threads.runs.cancel.assert_called_once_with(run_id="run-456", thread_id="thread-123") @@ -365,11 +361,11 @@ async def test_prepare_thread_cancel_existing_run(mock_async_openai: MagicMock) async def test_prepare_thread_existing_no_run(mock_async_openai: MagicMock) -> None: """Test _prepare_thread with existing thread_id but no active run.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) run_options: dict[str, list[dict[str, str]]] = {"additional_messages": []} - result = await chat_client._prepare_thread("thread-123", None, run_options) # type: ignore + result = await client._prepare_thread("thread-123", None, run_options) # type: ignore assert result == "thread-123" # Should not call cancel since no thread_run provided @@ -378,7 +374,7 @@ async def test_prepare_thread_existing_no_run(mock_async_openai: MagicMock) -> N async def test_process_stream_events_thread_run_created(mock_async_openai: MagicMock) -> None: """Test _process_stream_events with thread.run.created event.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) # Create a mock stream response for thread.run.created mock_response = MagicMock() @@ -396,7 +392,7 @@ async def test_process_stream_events_thread_run_created(mock_async_openai: Magic thread_id = "thread-123" updates: list[ChatResponseUpdate] = [] - async for update in chat_client._process_stream_events(mock_stream, thread_id): # type: ignore + async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore updates.append(update) # Should yield one ChatResponseUpdate for thread.run.created @@ -411,7 +407,7 @@ async def test_process_stream_events_thread_run_created(mock_async_openai: Magic async def test_process_stream_events_message_delta_text(mock_async_openai: MagicMock) -> None: """Test _process_stream_events with thread.message.delta event containing text.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) # Create a mock TextDeltaBlock with proper spec mock_delta_block = MagicMock(spec=TextDeltaBlock) @@ -440,7 +436,7 @@ async def test_process_stream_events_message_delta_text(mock_async_openai: Magic thread_id = "thread-456" updates: list[ChatResponseUpdate] = [] - async for update in chat_client._process_stream_events(mock_stream, thread_id): # type: ignore + async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore updates.append(update) # Should yield one text update @@ -455,11 +451,11 @@ async def test_process_stream_events_message_delta_text(mock_async_openai: Magic async def test_process_stream_events_requires_action(mock_async_openai: MagicMock) -> None: """Test _process_stream_events with thread.run.requires_action event.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) # Mock the _parse_function_calls_from_assistants method to return test content test_function_content = Content.from_function_call(call_id="call-123", name="test_func", arguments={"arg": "value"}) - chat_client._parse_function_calls_from_assistants = MagicMock(return_value=[test_function_content]) # type: ignore + client._parse_function_calls_from_assistants = MagicMock(return_value=[test_function_content]) # type: ignore # Create a mock Run object mock_run = MagicMock(spec=Run) @@ -479,7 +475,7 @@ async def test_process_stream_events_requires_action(mock_async_openai: MagicMoc thread_id = "thread-789" updates: list[ChatResponseUpdate] = [] - async for update in chat_client._process_stream_events(mock_stream, thread_id): # type: ignore + async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore updates.append(update) # Should yield one function call update @@ -493,13 +489,13 @@ async def test_process_stream_events_requires_action(mock_async_openai: MagicMoc assert update.raw_representation == mock_run # Verify _parse_function_calls_from_assistants was called correctly - chat_client._parse_function_calls_from_assistants.assert_called_once_with(mock_run, None) # type: ignore + client._parse_function_calls_from_assistants.assert_called_once_with(mock_run, None) # type: ignore async def test_process_stream_events_run_step_created(mock_async_openai: MagicMock) -> None: """Test _process_stream_events with thread.run.step.created event.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) # Create a mock RunStep object mock_run_step = MagicMock(spec=RunStep) @@ -520,7 +516,7 @@ async def test_process_stream_events_run_step_created(mock_async_openai: MagicMo thread_id = "thread-789" updates: list[ChatResponseUpdate] = [] - async for update in chat_client._process_stream_events(mock_stream, thread_id): # type: ignore + async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore updates.append(update) # The run step creation itself doesn't yield an update, @@ -533,7 +529,7 @@ async def test_process_stream_events_run_completed_with_usage( ) -> None: """Test _process_stream_events with thread.run.completed event containing usage.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) # Create a mock Run object with usage information mock_usage = MagicMock() @@ -559,7 +555,7 @@ async def test_process_stream_events_run_completed_with_usage( thread_id = "thread-999" updates: list[ChatResponseUpdate] = [] - async for update in chat_client._process_stream_events(mock_stream, thread_id): # type: ignore + async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore updates.append(update) # Should yield one usage update @@ -582,7 +578,7 @@ async def test_process_stream_events_run_completed_with_usage( def test_parse_function_calls_from_assistants_basic(mock_async_openai: MagicMock) -> None: """Test _parse_function_calls_from_assistants with a simple function call.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) # Create a mock Run event that requires action mock_run = MagicMock() @@ -599,7 +595,7 @@ def test_parse_function_calls_from_assistants_basic(mock_async_openai: MagicMock # Call the method response_id = "response_456" - contents = chat_client._parse_function_calls_from_assistants(mock_run, response_id) # type: ignore + contents = client._parse_function_calls_from_assistants(mock_run, response_id) # type: ignore # Test that one function call content was created assert len(contents) == 1 @@ -685,7 +681,7 @@ def test_parse_run_step_with_mcp_tool_call(mock_async_openai: MagicMock) -> None def test_prepare_options_basic(mock_async_openai: MagicMock) -> None: """Test _prepare_options with basic chat options.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) # Create basic chat options as a dict options = { @@ -695,10 +691,10 @@ def test_prepare_options_basic(mock_async_openai: MagicMock) -> None: "top_p": 0.9, } - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] # Call the method - run_options, tool_results = chat_client._prepare_options(messages, options) # type: ignore + run_options, tool_results = client._prepare_options(messages, options) # type: ignore # Check basic options were set assert run_options["max_completion_tokens"] == 100 @@ -711,7 +707,7 @@ def test_prepare_options_basic(mock_async_openai: MagicMock) -> None: def test_prepare_options_with_tool_tool(mock_async_openai: MagicMock) -> None: """Test _prepare_options with a FunctionTool.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) # Create a simple function for testing and decorate it @tool(approval_mode="never_require") @@ -724,10 +720,10 @@ def test_prepare_options_with_tool_tool(mock_async_openai: MagicMock) -> None: "tool_choice": "auto", } - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] # Call the method - run_options, tool_results = chat_client._prepare_options(messages, options) # type: ignore + run_options, tool_results = client._prepare_options(messages, options) # type: ignore # Check tools were set correctly assert "tools" in run_options @@ -738,21 +734,21 @@ def test_prepare_options_with_tool_tool(mock_async_openai: MagicMock) -> None: def test_prepare_options_with_code_interpreter(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with HostedCodeInterpreterTool.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + """Test _prepare_options with code interpreter tool.""" + client = create_test_openai_assistants_client(mock_async_openai) - # Create a real HostedCodeInterpreterTool - code_tool = HostedCodeInterpreterTool() + # Create a code interpreter tool dict + code_tool = OpenAIAssistantsClient.get_code_interpreter_tool() options = { "tools": [code_tool], "tool_choice": "auto", } - messages = [ChatMessage(role="user", text="Calculate something")] + messages = [Message(role="user", text="Calculate something")] # Call the method - run_options, tool_results = chat_client._prepare_options(messages, options) # type: ignore + run_options, tool_results = client._prepare_options(messages, options) # type: ignore # Check code interpreter tool was set correctly assert "tools" in run_options @@ -763,16 +759,16 @@ def test_prepare_options_with_code_interpreter(mock_async_openai: MagicMock) -> def test_prepare_options_tool_choice_none(mock_async_openai: MagicMock) -> None: """Test _prepare_options with tool_choice set to 'none' and no tools.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) options = { "tool_choice": "none", } - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] # Call the method - run_options, tool_results = chat_client._prepare_options(messages, options) # type: ignore + run_options, tool_results = client._prepare_options(messages, options) # type: ignore # Should set tool_choice to none - no tools because none were provided assert run_options["tool_choice"] == "none" @@ -785,7 +781,7 @@ def test_prepare_options_tool_choice_none_with_tools(mock_async_openai: MagicMoc When tool_choice='none', the model won't call tools, but tools should still be sent to the API so they're available for future turns in the conversation. """ - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) # Create a function tool @tool(approval_mode="never_require") @@ -797,10 +793,10 @@ def test_prepare_options_tool_choice_none_with_tools(mock_async_openai: MagicMoc "tools": [test_func], } - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] # Call the method - run_options, tool_results = chat_client._prepare_options(messages, options) # type: ignore + run_options, tool_results = client._prepare_options(messages, options) # type: ignore # Should set tool_choice to none BUT still include tools assert run_options["tool_choice"] == "none" @@ -810,7 +806,7 @@ def test_prepare_options_tool_choice_none_with_tools(mock_async_openai: MagicMoc def test_prepare_options_required_function(mock_async_openai: MagicMock) -> None: """Test _prepare_options with required function tool choice.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) # Create a required function tool choice as dict tool_choice = {"mode": "required", "required_function_name": "specific_function"} @@ -819,10 +815,10 @@ def test_prepare_options_required_function(mock_async_openai: MagicMock) -> None "tool_choice": tool_choice, } - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] # Call the method - run_options, tool_results = chat_client._prepare_options(messages, options) # type: ignore + run_options, tool_results = client._prepare_options(messages, options) # type: ignore # Check required function tool choice was set correctly expected_tool_choice = { @@ -833,34 +829,34 @@ def test_prepare_options_required_function(mock_async_openai: MagicMock) -> None def test_prepare_options_with_file_search_tool(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with HostedFileSearchTool.""" + """Test _prepare_options with file_search tool.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) - # Create a HostedFileSearchTool with max_results - file_search_tool = HostedFileSearchTool(max_results=10) + # Create a file_search tool with max_results + file_search_tool = OpenAIAssistantsClient.get_file_search_tool(max_num_results=10) options = { "tools": [file_search_tool], "tool_choice": "auto", } - messages = [ChatMessage(role="user", text="Search for information")] + messages = [Message(role="user", text="Search for information")] # Call the method - run_options, tool_results = chat_client._prepare_options(messages, options) # type: ignore + run_options, tool_results = client._prepare_options(messages, options) # type: ignore # Check file search tool was set correctly assert "tools" in run_options assert len(run_options["tools"]) == 1 - expected_tool = {"type": "file_search", "max_num_results": 10} + expected_tool = {"type": "file_search", "file_search": {"max_num_results": 10}} assert run_options["tools"][0] == expected_tool assert run_options["tool_choice"] == "auto" def test_prepare_options_with_mapping_tool(mock_async_openai: MagicMock) -> None: """Test _prepare_options with MutableMapping tool.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) # Create a tool as a MutableMapping (dict) mapping_tool = {"type": "custom_tool", "parameters": {"setting": "value"}} @@ -870,10 +866,10 @@ def test_prepare_options_with_mapping_tool(mock_async_openai: MagicMock) -> None "tool_choice": "auto", } - messages = [ChatMessage(role="user", text="Use custom tool")] + messages = [Message(role="user", text="Use custom tool")] # Call the method - run_options, tool_results = chat_client._prepare_options(messages, options) # type: ignore + run_options, tool_results = client._prepare_options(messages, options) # type: ignore # Check mapping tool was set correctly assert "tools" in run_options @@ -891,11 +887,11 @@ def test_prepare_options_with_pydantic_response_format(mock_async_openai: MagicM value: int model_config = ConfigDict(extra="forbid") - chat_client = create_test_openai_assistants_client(mock_async_openai) - messages = [ChatMessage(role="user", text="Test")] + client = create_test_openai_assistants_client(mock_async_openai) + messages = [Message(role="user", text="Test")] options = {"response_format": TestResponse} - run_options, _ = chat_client._prepare_options(messages, options) # type: ignore + run_options, _ = client._prepare_options(messages, options) # type: ignore assert "response_format" in run_options assert run_options["response_format"]["type"] == "json_schema" @@ -905,15 +901,15 @@ def test_prepare_options_with_pydantic_response_format(mock_async_openai: MagicM def test_prepare_options_with_system_message(mock_async_openai: MagicMock) -> None: """Test _prepare_options with system message converted to instructions.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) messages = [ - ChatMessage(role="system", text="You are a helpful assistant."), - ChatMessage(role="user", text="Hello"), + Message(role="system", text="You are a helpful assistant."), + Message(role="user", text="Hello"), ] # Call the method - run_options, tool_results = chat_client._prepare_options(messages, {}) # type: ignore + run_options, tool_results = client._prepare_options(messages, {}) # type: ignore # Check that additional_messages only contains the user message # System message should be converted to instructions (though this is handled internally) @@ -925,14 +921,14 @@ def test_prepare_options_with_system_message(mock_async_openai: MagicMock) -> No def test_prepare_options_with_image_content(mock_async_openai: MagicMock) -> None: """Test _prepare_options with image content.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) # Create message with image content image_content = Content.from_uri(uri="https://example.com/image.jpg", media_type="image/jpeg") - messages = [ChatMessage(role="user", contents=[image_content])] + messages = [Message(role="user", contents=[image_content])] # Call the method - run_options, tool_results = chat_client._prepare_options(messages, {}) # type: ignore + run_options, tool_results = client._prepare_options(messages, {}) # type: ignore # Check that image content was processed assert "additional_messages" in run_options @@ -946,9 +942,9 @@ def test_prepare_options_with_image_content(mock_async_openai: MagicMock) -> Non def test_prepare_tool_outputs_for_assistants_empty(mock_async_openai: MagicMock) -> None: """Test _prepare_tool_outputs_for_assistants with empty list.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) - run_id, tool_outputs = chat_client._prepare_tool_outputs_for_assistants([]) # type: ignore + run_id, tool_outputs = client._prepare_tool_outputs_for_assistants([]) # type: ignore assert run_id is None assert tool_outputs is None @@ -956,12 +952,12 @@ def test_prepare_tool_outputs_for_assistants_empty(mock_async_openai: MagicMock) def test_prepare_tool_outputs_for_assistants_valid(mock_async_openai: MagicMock) -> None: """Test _prepare_tool_outputs_for_assistants with valid function results.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) call_id = json.dumps(["run-123", "call-456"]) function_result = Content.from_function_result(call_id=call_id, result="Function executed successfully") - run_id, tool_outputs = chat_client._prepare_tool_outputs_for_assistants([function_result]) # type: ignore + run_id, tool_outputs = client._prepare_tool_outputs_for_assistants([function_result]) # type: ignore assert run_id == "run-123" assert tool_outputs is not None @@ -974,7 +970,7 @@ def test_prepare_tool_outputs_for_assistants_mismatched_run_ids( mock_async_openai: MagicMock, ) -> None: """Test _prepare_tool_outputs_for_assistants with mismatched run IDs.""" - chat_client = create_test_openai_assistants_client(mock_async_openai) + client = create_test_openai_assistants_client(mock_async_openai) # Create function results with different run IDs call_id1 = json.dumps(["run-123", "call-456"]) @@ -982,7 +978,7 @@ def test_prepare_tool_outputs_for_assistants_mismatched_run_ids( function_result1 = Content.from_function_result(call_id=call_id1, result="Result 1") function_result2 = Content.from_function_result(call_id=call_id2, result="Result 2") - run_id, tool_outputs = chat_client._prepare_tool_outputs_for_assistants([function_result1, function_result2]) # type: ignore + run_id, tool_outputs = client._prepare_tool_outputs_for_assistants([function_result1, function_result2]) # type: ignore # Should only process the first one since run IDs don't match assert run_id == "run-123" @@ -994,36 +990,36 @@ def test_prepare_tool_outputs_for_assistants_mismatched_run_ids( def test_update_agent_name_and_description(mock_async_openai: MagicMock) -> None: """Test _update_agent_name_and_description method updates assistant_name when not already set.""" # Test updating agent name when assistant_name is None - chat_client = create_test_openai_assistants_client(mock_async_openai, assistant_name=None) + client = create_test_openai_assistants_client(mock_async_openai, assistant_name=None) # Call the private method to update agent name - chat_client._update_agent_name_and_description("New Assistant Name") # type: ignore + client._update_agent_name_and_description("New Assistant Name") # type: ignore - assert chat_client.assistant_name == "New Assistant Name" + assert client.assistant_name == "New Assistant Name" def test_update_agent_name_and_description_existing(mock_async_openai: MagicMock) -> None: """Test _update_agent_name_and_description method doesn't override existing assistant_name.""" # Test that existing assistant_name is not overridden - chat_client = create_test_openai_assistants_client(mock_async_openai, assistant_name="Existing Assistant") + client = create_test_openai_assistants_client(mock_async_openai, assistant_name="Existing Assistant") # Call the private method to update agent name - chat_client._update_agent_name_and_description("New Assistant Name") # type: ignore + client._update_agent_name_and_description("New Assistant Name") # type: ignore # Should keep the existing name - assert chat_client.assistant_name == "Existing Assistant" + assert client.assistant_name == "Existing Assistant" def test_update_agent_name_and_description_none(mock_async_openai: MagicMock) -> None: """Test _update_agent_name_and_description method with None agent_name parameter.""" # Test that None agent_name doesn't change anything - chat_client = create_test_openai_assistants_client(mock_async_openai, assistant_name=None) + client = create_test_openai_assistants_client(mock_async_openai, assistant_name=None) # Call the private method with None - chat_client._update_agent_name_and_description(None) # type: ignore + client._update_agent_name_and_description(None) # type: ignore # Should remain None - assert chat_client.assistant_name is None + assert client.assistant_name is None @tool(approval_mode="never_require") @@ -1039,17 +1035,17 @@ def get_weather( async def test_get_response() -> None: """Test OpenAI Assistants Client response.""" async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client: - assert isinstance(openai_assistants_client, ChatClientProtocol) + assert isinstance(openai_assistants_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] + messages: list[Message] = [] messages.append( - ChatMessage( + Message( role="user", text="The weather in Seattle is currently sunny with a high of 25°C. " "It's a beautiful day for outdoor activities.", ) ) - messages.append(ChatMessage(role="user", text="What's the weather like today?")) + messages.append(Message(role="user", text="What's the weather like today?")) # Test that the client can be used to get a response response = await openai_assistants_client.get_response(messages=messages) @@ -1064,10 +1060,10 @@ async def test_get_response() -> None: async def test_get_response_tools() -> None: """Test OpenAI Assistants Client response with tools.""" async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client: - assert isinstance(openai_assistants_client, ChatClientProtocol) + assert isinstance(openai_assistants_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] - messages.append(ChatMessage(role="user", text="What's the weather like in Seattle?")) + messages: list[Message] = [] + messages.append(Message(role="user", text="What's the weather like in Seattle?")) # Test that the client can be used to get a response response = await openai_assistants_client.get_response( @@ -1085,17 +1081,17 @@ async def test_get_response_tools() -> None: async def test_streaming() -> None: """Test OpenAI Assistants Client streaming response.""" async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client: - assert isinstance(openai_assistants_client, ChatClientProtocol) + assert isinstance(openai_assistants_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] + messages: list[Message] = [] messages.append( - ChatMessage( + Message( role="user", text="The weather in Seattle is currently sunny with a high of 25°C. " "It's a beautiful day for outdoor activities.", ) ) - messages.append(ChatMessage(role="user", text="What's the weather like today?")) + messages.append(Message(role="user", text="What's the weather like today?")) # Test that the client can be used to get a response response = openai_assistants_client.get_response(stream=True, messages=messages) @@ -1116,10 +1112,10 @@ async def test_streaming() -> None: async def test_streaming_tools() -> None: """Test OpenAI Assistants Client streaming response with tools.""" async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client: - assert isinstance(openai_assistants_client, ChatClientProtocol) + assert isinstance(openai_assistants_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] - messages.append(ChatMessage(role="user", text="What's the weather like in Seattle?")) + messages: list[Message] = [] + messages.append(Message(role="user", text="What's the weather like in Seattle?")) # Test that the client can be used to get a response response = openai_assistants_client.get_response( @@ -1148,7 +1144,7 @@ async def test_with_existing_assistant() -> None: # First create an assistant to use in the test async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as temp_client: # Get the assistant ID by triggering assistant creation - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] await temp_client.get_response(messages=messages) assistant_id = temp_client.assistant_id @@ -1156,10 +1152,10 @@ async def test_with_existing_assistant() -> None: async with OpenAIAssistantsClient( model_id=INTEGRATION_TEST_MODEL, assistant_id=assistant_id ) as openai_assistants_client: - assert isinstance(openai_assistants_client, ChatClientProtocol) + assert isinstance(openai_assistants_client, SupportsChatGetResponse) assert openai_assistants_client.assistant_id == assistant_id - messages = [ChatMessage(role="user", text="What can you do?")] + messages = [Message(role="user", text="What can you do?")] # Test that the client can be used to get a response response = await openai_assistants_client.get_response(messages=messages) @@ -1175,16 +1171,16 @@ async def test_with_existing_assistant() -> None: async def test_file_search() -> None: """Test OpenAI Assistants Client response.""" async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client: - assert isinstance(openai_assistants_client, ChatClientProtocol) + assert isinstance(openai_assistants_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] - messages.append(ChatMessage(role="user", text="What's the weather like today?")) + messages: list[Message] = [] + messages.append(Message(role="user", text="What's the weather like today?")) file_id, vector_store = await create_vector_store(openai_assistants_client) response = await openai_assistants_client.get_response( messages=messages, options={ - "tools": [HostedFileSearchTool()], + "tools": [OpenAIAssistantsClient.get_file_search_tool()], "tool_resources": {"file_search": {"vector_store_ids": [vector_store.vector_store_id]}}, }, ) @@ -1201,17 +1197,17 @@ async def test_file_search() -> None: async def test_file_search_streaming() -> None: """Test OpenAI Assistants Client response.""" async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client: - assert isinstance(openai_assistants_client, ChatClientProtocol) + assert isinstance(openai_assistants_client, SupportsChatGetResponse) - messages: list[ChatMessage] = [] - messages.append(ChatMessage(role="user", text="What's the weather like today?")) + messages: list[Message] = [] + messages.append(Message(role="user", text="What's the weather like today?")) file_id, vector_store = await create_vector_store(openai_assistants_client) response = openai_assistants_client.get_response( stream=True, messages=messages, options={ - "tools": [HostedFileSearchTool()], + "tools": [OpenAIAssistantsClient.get_file_search_tool()], "tool_resources": {"file_search": {"vector_store_ids": [vector_store.vector_store_id]}}, }, ) @@ -1232,9 +1228,9 @@ async def test_file_search_streaming() -> None: @pytest.mark.flaky @skip_if_openai_integration_tests_disabled async def test_openai_assistants_agent_basic_run(): - """Test ChatAgent basic run functionality with OpenAIAssistantsClient.""" - async with ChatAgent( - chat_client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), + """Test Agent basic run functionality with OpenAIAssistantsClient.""" + async with Agent( + client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), ) as agent: # Run a simple query response = await agent.run("Hello! Please respond with 'Hello World' exactly.") @@ -1249,9 +1245,9 @@ async def test_openai_assistants_agent_basic_run(): @pytest.mark.flaky @skip_if_openai_integration_tests_disabled async def test_openai_assistants_agent_basic_run_streaming(): - """Test ChatAgent basic streaming functionality with OpenAIAssistantsClient.""" - async with ChatAgent( - chat_client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), + """Test Agent basic streaming functionality with OpenAIAssistantsClient.""" + async with Agent( + client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), ) as agent: # Run streaming query full_message: str = "" @@ -1268,70 +1264,70 @@ async def test_openai_assistants_agent_basic_run_streaming(): @pytest.mark.flaky @skip_if_openai_integration_tests_disabled -async def test_openai_assistants_agent_thread_persistence(): - """Test ChatAgent thread persistence across runs with OpenAIAssistantsClient.""" - async with ChatAgent( - chat_client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), +async def test_openai_assistants_agent_session_persistence(): + """Test Agent session persistence across runs with OpenAIAssistantsClient.""" + async with Agent( + client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), instructions="You are a helpful assistant with good memory.", ) as agent: - # Create a new thread that will be reused - thread = agent.get_new_thread() + # Create a new session that will be reused + session = agent.create_session() # First message - establish context first_response = await agent.run( - "Remember this number: 42. What number did I just tell you to remember?", thread=thread + "Remember this number: 42. What number did I just tell you to remember?", session=session ) assert isinstance(first_response, AgentResponse) assert "42" in first_response.text # Second message - test conversation memory second_response = await agent.run( - "What number did I tell you to remember in my previous message?", thread=thread + "What number did I tell you to remember in my previous message?", session=session ) assert isinstance(second_response, AgentResponse) assert "42" in second_response.text - # Verify thread has been populated with conversation ID - assert thread.service_thread_id is not None + # Verify session has been populated with conversation ID + assert session.service_session_id is not None @pytest.mark.flaky @skip_if_openai_integration_tests_disabled -async def test_openai_assistants_agent_existing_thread_id(): - """Test ChatAgent with existing thread ID to continue conversations across agent instances.""" - # First, create a conversation and capture the thread ID - existing_thread_id = None +async def test_openai_assistants_agent_existing_session_id(): + """Test Agent with existing session ID to continue conversations across agent instances.""" + # First, create a conversation and capture the session ID + existing_session_id = None - async with ChatAgent( - chat_client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), + async with Agent( + client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), instructions="You are a helpful weather agent.", tools=[get_weather], ) as agent: - # Start a conversation and get the thread ID - thread = agent.get_new_thread() - response1 = await agent.run("What's the weather in Paris?", thread=thread) + # Start a conversation and get the session ID + session = agent.create_session() + response1 = await agent.run("What's the weather in Paris?", session=session) # Validate first response assert isinstance(response1, AgentResponse) assert response1.text is not None assert any(word in response1.text.lower() for word in ["weather", "paris"]) - # The thread ID is set after the first response - existing_thread_id = thread.service_thread_id - assert existing_thread_id is not None + # The session ID is set after the first response + existing_session_id = session.service_session_id + assert existing_session_id is not None - # Now continue with the same thread ID in a new agent instance + # Now continue with the same session ID in a new agent instance - async with ChatAgent( - chat_client=OpenAIAssistantsClient(thread_id=existing_thread_id), + async with Agent( + client=OpenAIAssistantsClient(thread_id=existing_session_id), instructions="You are a helpful weather agent.", tools=[get_weather], ) as agent: - # Create a thread with the existing ID - thread = AgentThread(service_thread_id=existing_thread_id) + # Create a session with the existing ID + session = AgentSession(service_session_id=existing_session_id) # Ask about the previous conversation - response2 = await agent.run("What was the last city I asked about?", thread=thread) + response2 = await agent.run("What was the last city I asked about?", session=session) # Validate that the agent remembers the previous conversation assert isinstance(response2, AgentResponse) @@ -1343,12 +1339,12 @@ async def test_openai_assistants_agent_existing_thread_id(): @pytest.mark.flaky @skip_if_openai_integration_tests_disabled async def test_openai_assistants_agent_code_interpreter(): - """Test ChatAgent with code interpreter through OpenAIAssistantsClient.""" + """Test Agent with code interpreter through OpenAIAssistantsClient.""" - async with ChatAgent( - chat_client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), + async with Agent( + client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), instructions="You are a helpful assistant that can write and execute Python code.", - tools=[HostedCodeInterpreterTool()], + tools=[OpenAIAssistantsClient.get_code_interpreter_tool()], ) as agent: # Request code execution response = await agent.run("Write Python code to calculate the factorial of 5 and show the result.") @@ -1365,8 +1361,8 @@ async def test_openai_assistants_agent_code_interpreter(): async def test_agent_level_tool_persistence(): """Test that agent-level tools persist across multiple runs with OpenAI Assistants Client.""" - async with ChatAgent( - chat_client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), + async with Agent( + client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), instructions="You are a helpful assistant that uses available tools.", tools=[get_weather], # Agent-level tool ) as agent: diff --git a/python/packages/core/tests/openai/test_openai_chat_client.py b/python/packages/core/tests/openai/test_openai_chat_client.py index 7b5f0cde13..e6e5de8314 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client.py +++ b/python/packages/core/tests/openai/test_openai_chat_client.py @@ -13,13 +13,10 @@ from pydantic import BaseModel from pytest import param from agent_framework import ( - ChatClientProtocol, - ChatMessage, ChatResponse, Content, - HostedWebSearchTool, - ToolProtocol, - prepare_function_call_results, + Message, + SupportsChatGetResponse, tool, ) from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException @@ -40,7 +37,7 @@ def test_init(openai_unit_test_env: dict[str, str]) -> None: open_ai_chat_completion = OpenAIChatClient() assert open_ai_chat_completion.model_id == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"] - assert isinstance(open_ai_chat_completion, ChatClientProtocol) + assert isinstance(open_ai_chat_completion, SupportsChatGetResponse) def test_init_validation_fail() -> None: @@ -55,7 +52,7 @@ def test_init_model_id_constructor(openai_unit_test_env: dict[str, str]) -> None open_ai_chat_completion = OpenAIChatClient(model_id=model_id) assert open_ai_chat_completion.model_id == model_id - assert isinstance(open_ai_chat_completion, ChatClientProtocol) + assert isinstance(open_ai_chat_completion, SupportsChatGetResponse) def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None: @@ -67,7 +64,7 @@ def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None: ) assert open_ai_chat_completion.model_id == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"] - assert isinstance(open_ai_chat_completion, ChatClientProtocol) + assert isinstance(open_ai_chat_completion, SupportsChatGetResponse) # Assert that the default header we added is present in the client's default headers for key, value in default_headers.items(): @@ -154,7 +151,7 @@ def test_serialize_with_org_id(openai_unit_test_env: dict[str, str]) -> None: async def test_content_filter_exception_handling(openai_unit_test_env: dict[str, str]) -> None: """Test that content filter errors are properly handled.""" client = OpenAIChatClient() - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] # Create a mock BadRequestError with content_filter code mock_response = MagicMock() @@ -172,18 +169,22 @@ async def test_content_filter_exception_handling(openai_unit_test_env: dict[str, def test_unsupported_tool_handling(openai_unit_test_env: dict[str, str]) -> None: - """Test that unsupported tool types are handled correctly.""" + """Test that unsupported tool types are passed through unchanged.""" client = OpenAIChatClient() - # Create a mock ToolProtocol that's not a FunctionTool - unsupported_tool = MagicMock(spec=ToolProtocol) - unsupported_tool.__class__.__name__ = "UnsupportedAITool" + # Create a random object that's not a FunctionTool, dict, or callable + # This simulates an unsupported tool type that gets passed through + class UnsupportedTool: + pass - # This should ignore the unsupported ToolProtocol and return empty list + unsupported_tool = UnsupportedTool() + + # Unsupported tools are passed through for the API to handle/reject result = client._prepare_tools_for_openai([unsupported_tool]) # type: ignore - assert result == {} + assert "tools" in result + assert len(result["tools"]) == 1 - # Also test with a non-ToolProtocol that should be converted to dict + # Also test with a dict-based tool that should be passed through dict_tool = {"type": "function", "name": "test"} result = client._prepare_tools_for_openai([dict_tool]) # type: ignore assert result["tools"] == [dict_tool] @@ -209,7 +210,7 @@ def get_weather(location: str) -> str: async def test_exception_message_includes_original_error_details() -> None: """Test that exception messages include original error details in the new format.""" client = OpenAIChatClient(model_id="test-model", api_key="test-key") - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] mock_response = MagicMock() original_error_message = "Invalid API request format" @@ -279,20 +280,24 @@ def test_chat_response_content_order_text_before_tool_calls(openai_unit_test_env def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, str]): - """Test that falsy values (like empty list) in function result are properly handled.""" + """Test that falsy values (like empty list) in function result are properly handled. + + Note: In practice, FunctionTool.invoke() always returns a pre-parsed string. + These tests verify that the OpenAI client correctly passes through string results. + """ client = OpenAIChatClient() - # Test with empty list (falsy but not None) - message_with_empty_list = ChatMessage( - role="tool", contents=[Content.from_function_result(call_id="call-123", result=[])] + # Test with empty list serialized as JSON string (as FunctionTool.invoke would produce) + message_with_empty_list = Message( + role="tool", contents=[Content.from_function_result(call_id="call-123", result="[]")] ) openai_messages = client._prepare_message_for_openai(message_with_empty_list) assert len(openai_messages) == 1 - assert openai_messages[0]["content"] == "[]" # Empty list should be JSON serialized + assert openai_messages[0]["content"] == "[]" # Empty list JSON string # Test with empty string (falsy but not None) - message_with_empty_string = ChatMessage( + message_with_empty_string = Message( role="tool", contents=[Content.from_function_result(call_id="call-456", result="")] ) @@ -300,14 +305,14 @@ def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, s assert len(openai_messages) == 1 assert openai_messages[0]["content"] == "" # Empty string should be preserved - # Test with False (falsy but not None) - message_with_false = ChatMessage( - role="tool", contents=[Content.from_function_result(call_id="call-789", result=False)] + # Test with False serialized as JSON string (as FunctionTool.invoke would produce) + message_with_false = Message( + role="tool", contents=[Content.from_function_result(call_id="call-789", result="false")] ) openai_messages = client._prepare_message_for_openai(message_with_false) assert len(openai_messages) == 1 - assert openai_messages[0]["content"] == "false" # False should be JSON serialized + assert openai_messages[0]["content"] == "false" # False JSON string def test_function_result_exception_handling(openai_unit_test_env: dict[str, str]): @@ -319,7 +324,7 @@ def test_function_result_exception_handling(openai_unit_test_env: dict[str, str] # Test with exception (no result) test_exception = ValueError("Test error message") - message_with_exception = ChatMessage( + message_with_exception = Message( role="tool", contents=[ Content.from_function_result(call_id="call-123", result="Error: Function failed.", exception=test_exception) @@ -332,9 +337,11 @@ def test_function_result_exception_handling(openai_unit_test_env: dict[str, str] assert openai_messages[0]["tool_call_id"] == "call-123" -def test_prepare_function_call_results_string_passthrough(): +def test_parse_result_string_passthrough(): """Test that string values are passed through directly without JSON encoding.""" - result = prepare_function_call_results("simple string") + from agent_framework import FunctionTool + + result = FunctionTool.parse_result("simple string") assert result == "simple string" assert isinstance(result, str) @@ -609,7 +616,7 @@ def test_prepare_message_with_text_reasoning_content(openai_unit_test_env: dict[ reasoning_content = Content.from_text_reasoning(text=None, protected_data=json.dumps(mock_reasoning_data)) # Message must have other content first for reasoning to attach to - message = ChatMessage( + message = Message( role="assistant", contents=[ Content.from_text(text="The answer is 42."), @@ -652,17 +659,17 @@ def test_function_approval_content_is_skipped_in_preparation(openai_unit_test_en ) # Test that approval request is skipped - message_with_request = ChatMessage(role="assistant", contents=[approval_request]) + message_with_request = Message(role="assistant", contents=[approval_request]) prepared_request = client._prepare_message_for_openai(message_with_request) assert len(prepared_request) == 0 # Should be empty - approval content is skipped # Test that approval response is skipped - message_with_response = ChatMessage(role="user", contents=[approval_response]) + message_with_response = Message(role="user", contents=[approval_response]) prepared_response = client._prepare_message_for_openai(message_with_response) assert len(prepared_response) == 0 # Should be empty - approval content is skipped # Test with mixed content - approval should be skipped, text should remain - mixed_message = ChatMessage( + mixed_message = Message( role="assistant", contents=[ Content.from_text(text="I need approval for this action."), @@ -752,7 +759,7 @@ def test_prepare_options_without_model_id(openai_unit_test_env: dict[str, str]) client = OpenAIChatClient() client.model_id = None # Remove model_id - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] with pytest.raises(ValueError, match="model_id must be a non-empty string"): client._prepare_options(messages, {}) @@ -772,8 +779,8 @@ def test_prepare_tools_with_web_search_no_location(openai_unit_test_env: dict[st """Test preparing web search tool without user location.""" client = OpenAIChatClient() - # Web search tool without additional_properties - web_search_tool = HostedWebSearchTool() + # Web search tool using static method + web_search_tool = OpenAIChatClient.get_web_search_tool() result = client._prepare_tools_for_openai([web_search_tool]) @@ -786,7 +793,7 @@ def test_prepare_options_with_instructions(openai_unit_test_env: dict[str, str]) """Test that instructions are prepended as system message.""" client = OpenAIChatClient() - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] options = {"instructions": "You are a helpful assistant."} prepared_options = client._prepare_options(messages, options) @@ -802,7 +809,7 @@ def test_prepare_message_with_author_name(openai_unit_test_env: dict[str, str]) """Test that author_name is included in prepared message.""" client = OpenAIChatClient() - message = ChatMessage( + message = Message( role="user", author_name="TestUser", contents=[Content.from_text(text="Hello")], @@ -819,7 +826,7 @@ def test_prepare_message_with_tool_result_author_name(openai_unit_test_env: dict client = OpenAIChatClient() # Tool messages should not have 'name' field (it's for function name instead) - message = ChatMessage( + message = Message( role="tool", author_name="ShouldNotAppear", contents=[Content.from_function_result(call_id="call_123", result="result")], @@ -836,7 +843,7 @@ def test_tool_choice_required_with_function_name(openai_unit_test_env: dict[str, """Test that tool_choice with required mode and function name is correctly prepared.""" client = OpenAIChatClient() - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] options = { "tools": [get_weather], "tool_choice": {"mode": "required", "required_function_name": "get_weather"}, @@ -854,7 +861,7 @@ def test_response_format_dict_passthrough(openai_unit_test_env: dict[str, str]) """Test that response_format as dict is passed through directly.""" client = OpenAIChatClient() - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] custom_format = { "type": "json_schema", "json_schema": {"name": "Test", "schema": {"type": "object"}}, @@ -872,7 +879,7 @@ def test_multiple_function_calls_in_single_message(openai_unit_test_env: dict[st client = OpenAIChatClient() # Create message with multiple function calls - message = ChatMessage( + message = Message( role="assistant", contents=[ Content.from_function_call(call_id="call_1", name="func_1", arguments='{"a": 1}'), @@ -894,7 +901,7 @@ def test_prepare_options_removes_parallel_tool_calls_when_no_tools(openai_unit_t """Test that parallel_tool_calls is removed when no tools are present.""" client = OpenAIChatClient() - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] options = {"allow_multiple_tool_calls": True} prepared_options = client._prepare_options(messages, options) @@ -906,7 +913,7 @@ def test_prepare_options_removes_parallel_tool_calls_when_no_tools(openai_unit_t async def test_streaming_exception_handling(openai_unit_test_env: dict[str, str]) -> None: """Test that streaming errors are properly handled.""" client = OpenAIChatClient() - messages = [ChatMessage(role="user", text="test")] + messages = [Message(role="user", text="test")] # Create a mock error during streaming mock_error = Exception("Streaming error") @@ -1004,14 +1011,14 @@ async def test_integration_options( # Prepare test message if option_name.startswith("tools") or option_name.startswith("tool_choice"): # Use weather-related prompt for tool tests - messages = [ChatMessage(role="user", text="What is the weather in Seattle?")] + messages = [Message(role="user", text="What is the weather in Seattle?")] elif option_name.startswith("response_format"): # Use prompt that works well with structured output - messages = [ChatMessage(role="user", text="The weather in Seattle is sunny")] - messages.append(ChatMessage(role="user", text="What is the weather in Seattle?")) + messages = [Message(role="user", text="The weather in Seattle is sunny")] + messages.append(Message(role="user", text="What is the weather in Seattle?")) else: # Generic prompt for simple options - messages = [ChatMessage(role="user", text="Say 'Hello World' briefly.")] + messages = [Message(role="user", text="Say 'Hello World' briefly.")] # Build options dict options: dict[str, Any] = {option_name: option_value} @@ -1073,11 +1080,13 @@ async def test_integration_web_search() -> None: client = OpenAIChatClient(model_id="gpt-4o-search-preview") for streaming in [False, True]: + # Use static method for web search tool + web_search_tool = OpenAIChatClient.get_web_search_tool() content = { "messages": "Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", "options": { "tool_choice": "auto", - "tools": [HostedWebSearchTool()], + "tools": [web_search_tool], }, } if streaming: @@ -1092,17 +1101,19 @@ async def test_integration_web_search() -> None: assert "Zoey" in response.text # Test that the client will use the web search tool with location - additional_properties = { - "user_location": { - "country": "US", - "city": "Seattle", + web_search_tool_with_location = OpenAIChatClient.get_web_search_tool( + web_search_options={ + "user_location": { + "type": "approximate", + "approximate": {"country": "US", "city": "Seattle"}, + }, } - } + ) content = { "messages": "What is the current weather? Do not ask for my current location.", "options": { "tool_choice": "auto", - "tools": [HostedWebSearchTool(additional_properties=additional_properties)], + "tools": [web_search_tool_with_location], }, } if streaming: diff --git a/python/packages/core/tests/openai/test_openai_chat_client_base.py b/python/packages/core/tests/openai/test_openai_chat_client_base.py index 51a7ae0bc3..4c31394fb6 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client_base.py +++ b/python/packages/core/tests/openai/test_openai_chat_client_base.py @@ -14,7 +14,7 @@ from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDe from openai.types.chat.chat_completion_message import ChatCompletionMessage from pydantic import BaseModel -from agent_framework import ChatMessage, ChatResponseUpdate +from agent_framework import ChatResponseUpdate, Message from agent_framework.exceptions import ( ServiceResponseException, ) @@ -27,7 +27,7 @@ async def mock_async_process_chat_stream_response(_): @pytest.fixture(scope="function") -def chat_history() -> list[ChatMessage]: +def chat_history() -> list[Message]: return [] @@ -64,12 +64,12 @@ def mock_streaming_chat_completion_response() -> AsyncStream[ChatCompletionChunk @patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) async def test_cmc( mock_create: AsyncMock, - chat_history: list[ChatMessage], + chat_history: list[Message], mock_chat_completion_response: ChatCompletion, openai_unit_test_env: dict[str, str], ): mock_create.return_value = mock_chat_completion_response - chat_history.append(ChatMessage(role="user", text="hello world")) + chat_history.append(Message(role="user", text="hello world")) openai_chat_completion = OpenAIChatClient() await openai_chat_completion.get_response(messages=chat_history) @@ -83,12 +83,12 @@ async def test_cmc( @patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) async def test_cmc_chat_options( mock_create: AsyncMock, - chat_history: list[ChatMessage], + chat_history: list[Message], mock_chat_completion_response: ChatCompletion, openai_unit_test_env: dict[str, str], ): mock_create.return_value = mock_chat_completion_response - chat_history.append(ChatMessage(role="user", text="hello world")) + chat_history.append(Message(role="user", text="hello world")) openai_chat_completion = OpenAIChatClient() await openai_chat_completion.get_response( @@ -104,12 +104,12 @@ async def test_cmc_chat_options( @patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) async def test_cmc_no_fcc_in_response( mock_create: AsyncMock, - chat_history: list[ChatMessage], + chat_history: list[Message], mock_chat_completion_response: ChatCompletion, openai_unit_test_env: dict[str, str], ): mock_create.return_value = mock_chat_completion_response - chat_history.append(ChatMessage(role="user", text="hello world")) + chat_history.append(Message(role="user", text="hello world")) orig_chat_history = deepcopy(chat_history) openai_chat_completion = OpenAIChatClient() @@ -126,12 +126,12 @@ async def test_cmc_no_fcc_in_response( @patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) async def test_cmc_structured_output_no_fcc( mock_create: AsyncMock, - chat_history: list[ChatMessage], + chat_history: list[Message], mock_chat_completion_response: ChatCompletion, openai_unit_test_env: dict[str, str], ): mock_create.return_value = mock_chat_completion_response - chat_history.append(ChatMessage(role="user", text="hello world")) + chat_history.append(Message(role="user", text="hello world")) # Define a mock response format class Test(BaseModel): @@ -148,12 +148,12 @@ async def test_cmc_structured_output_no_fcc( @patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) async def test_scmc_chat_options( mock_create: AsyncMock, - chat_history: list[ChatMessage], + chat_history: list[Message], mock_streaming_chat_completion_response: AsyncStream[ChatCompletionChunk], openai_unit_test_env: dict[str, str], ): mock_create.return_value = mock_streaming_chat_completion_response - chat_history.append(ChatMessage(role="user", text="hello world")) + chat_history.append(Message(role="user", text="hello world")) openai_chat_completion = OpenAIChatClient() async for msg in openai_chat_completion.get_response( @@ -174,12 +174,12 @@ async def test_scmc_chat_options( @patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock, side_effect=Exception) async def test_cmc_general_exception( mock_create: AsyncMock, - chat_history: list[ChatMessage], + chat_history: list[Message], mock_chat_completion_response: ChatCompletion, openai_unit_test_env: dict[str, str], ): mock_create.return_value = mock_chat_completion_response - chat_history.append(ChatMessage(role="user", text="hello world")) + chat_history.append(Message(role="user", text="hello world")) openai_chat_completion = OpenAIChatClient() with pytest.raises(ServiceResponseException): @@ -191,12 +191,12 @@ async def test_cmc_general_exception( @patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) async def test_cmc_additional_properties( mock_create: AsyncMock, - chat_history: list[ChatMessage], + chat_history: list[Message], mock_chat_completion_response: ChatCompletion, openai_unit_test_env: dict[str, str], ): mock_create.return_value = mock_chat_completion_response - chat_history.append(ChatMessage(role="user", text="hello world")) + chat_history.append(Message(role="user", text="hello world")) openai_chat_completion = OpenAIChatClient() await openai_chat_completion.get_response(messages=chat_history, options={"reasoning_effort": "low"}) @@ -214,7 +214,7 @@ async def test_cmc_additional_properties( @patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) async def test_get_streaming( mock_create: AsyncMock, - chat_history: list[ChatMessage], + chat_history: list[Message], openai_unit_test_env: dict[str, str], ): content1 = ChatCompletionChunk( @@ -234,7 +234,7 @@ async def test_get_streaming( stream = MagicMock(spec=AsyncStream) stream.__aiter__.return_value = [content1, content2] mock_create.return_value = stream - chat_history.append(ChatMessage(role="user", text="hello world")) + chat_history.append(Message(role="user", text="hello world")) orig_chat_history = deepcopy(chat_history) openai_chat_completion = OpenAIChatClient() @@ -254,7 +254,7 @@ async def test_get_streaming( @patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) async def test_get_streaming_singular( mock_create: AsyncMock, - chat_history: list[ChatMessage], + chat_history: list[Message], openai_unit_test_env: dict[str, str], ): content1 = ChatCompletionChunk( @@ -274,7 +274,7 @@ async def test_get_streaming_singular( stream = MagicMock(spec=AsyncStream) stream.__aiter__.return_value = [content1, content2] mock_create.return_value = stream - chat_history.append(ChatMessage(role="user", text="hello world")) + chat_history.append(Message(role="user", text="hello world")) orig_chat_history = deepcopy(chat_history) openai_chat_completion = OpenAIChatClient() @@ -294,7 +294,7 @@ async def test_get_streaming_singular( @patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) async def test_get_streaming_structured_output_no_fcc( mock_create: AsyncMock, - chat_history: list[ChatMessage], + chat_history: list[Message], openai_unit_test_env: dict[str, str], ): content1 = ChatCompletionChunk( @@ -314,7 +314,7 @@ async def test_get_streaming_structured_output_no_fcc( stream = MagicMock(spec=AsyncStream) stream.__aiter__.return_value = [content1, content2] mock_create.return_value = stream - chat_history.append(ChatMessage(role="user", text="hello world")) + chat_history.append(Message(role="user", text="hello world")) # Define a mock response format class Test(BaseModel): @@ -333,12 +333,12 @@ async def test_get_streaming_structured_output_no_fcc( @patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) async def test_get_streaming_no_fcc_in_response( mock_create: AsyncMock, - chat_history: list[ChatMessage], + chat_history: list[Message], mock_streaming_chat_completion_response: ChatCompletion, openai_unit_test_env: dict[str, str], ): mock_create.return_value = mock_streaming_chat_completion_response - chat_history.append(ChatMessage(role="user", text="hello world")) + chat_history.append(Message(role="user", text="hello world")) orig_chat_history = deepcopy(chat_history) openai_chat_completion = OpenAIChatClient() diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index 88a20285d2..a83c4a398b 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -27,17 +27,12 @@ from pydantic import BaseModel from pytest import param from agent_framework import ( - ChatClientProtocol, - ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate, Content, - HostedCodeInterpreterTool, - HostedFileSearchTool, - HostedImageGenerationTool, - HostedMCPTool, - HostedWebSearchTool, + Message, + SupportsChatGetResponse, tool, ) from agent_framework.exceptions import ( @@ -106,7 +101,7 @@ def test_init(openai_unit_test_env: dict[str, str]) -> None: openai_responses_client = OpenAIResponsesClient() assert openai_responses_client.model_id == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"] - assert isinstance(openai_responses_client, ChatClientProtocol) + assert isinstance(openai_responses_client, SupportsChatGetResponse) def test_init_validation_fail() -> None: @@ -121,7 +116,7 @@ def test_init_model_id_constructor(openai_unit_test_env: dict[str, str]) -> None openai_responses_client = OpenAIResponsesClient(model_id=model_id) assert openai_responses_client.model_id == model_id - assert isinstance(openai_responses_client, ChatClientProtocol) + assert isinstance(openai_responses_client, SupportsChatGetResponse) def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None: @@ -133,7 +128,7 @@ def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None: ) assert openai_responses_client.model_id == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"] - assert isinstance(openai_responses_client, ChatClientProtocol) + assert isinstance(openai_responses_client, SupportsChatGetResponse) # Assert that the default header we added is present in the client's default headers for key, value in default_headers.items(): @@ -211,7 +206,7 @@ async def test_get_response_with_all_parameters() -> None: # Test with comprehensive parameter set - should fail due to invalid API key with pytest.raises(ServiceResponseException): await client.get_response( - messages=[ChatMessage(role="user", text="Test message")], + messages=[Message(role="user", text="Test message")], options={ "include": ["message.output_text.logprobs"], "instructions": "You are a helpful assistant", @@ -236,66 +231,48 @@ async def test_get_response_with_all_parameters() -> None: ) +@pytest.mark.asyncio async def test_web_search_tool_with_location() -> None: - """Test HostedWebSearchTool with location parameters.""" + """Test web search tool with location parameters.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - # Test web search tool with location - web_search_tool = HostedWebSearchTool( - additional_properties={ - "user_location": { - "country": "US", - "city": "Seattle", - "region": "WA", - "timezone": "America/Los_Angeles", - } + # Test web search tool with location using static method + web_search_tool = OpenAIResponsesClient.get_web_search_tool( + user_location={ + "city": "Seattle", + "country": "US", + "region": "WA", + "timezone": "America/Los_Angeles", } ) # Should raise an authentication error due to invalid API key with pytest.raises(ServiceResponseException): await client.get_response( - messages=[ChatMessage(role="user", text="What's the weather?")], + messages=[Message(role="user", text="What's the weather?")], options={"tools": [web_search_tool], "tool_choice": "auto"}, ) -async def test_file_search_tool_with_invalid_inputs() -> None: - """Test HostedFileSearchTool with invalid vector store inputs.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - - # Test with invalid inputs type (should trigger ValueError) - file_search_tool = HostedFileSearchTool(inputs=[Content.from_hosted_file(file_id="invalid")]) - - # Should raise an error due to invalid inputs - with pytest.raises(ValueError, match="HostedFileSearchTool requires inputs to be of type"): - await client.get_response( - messages=[ChatMessage(role="user", text="Search files")], - options={"tools": [file_search_tool]}, - ) - - async def test_code_interpreter_tool_variations() -> None: """Test HostedCodeInterpreterTool with and without file inputs.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - # Test code interpreter without files - code_tool_empty = HostedCodeInterpreterTool() + # Test code interpreter using static method + code_tool = OpenAIResponsesClient.get_code_interpreter_tool() with pytest.raises(ServiceResponseException): await client.get_response( - messages=[ChatMessage(role="user", text="Run some code")], - options={"tools": [code_tool_empty]}, + messages=[Message("user", ["Run some code"])], + options={"tools": [code_tool]}, ) - # Test code interpreter with files - code_tool_with_files = HostedCodeInterpreterTool( - inputs=[Content.from_hosted_file(file_id="file1"), Content.from_hosted_file(file_id="file2")] - ) + # Test code interpreter with files using static method + code_tool_with_files = OpenAIResponsesClient.get_code_interpreter_tool(file_ids=["file1", "file2"]) with pytest.raises(ServiceResponseException): await client.get_response( - messages=[ChatMessage(role="user", text="Process these files")], + messages=[Message(role="user", text="Process these files")], options={"tools": [code_tool_with_files]}, ) @@ -314,23 +291,25 @@ async def test_content_filter_exception() -> None: with patch.object(client.client.responses, "create", side_effect=mock_error): with pytest.raises(OpenAIContentFilterException) as exc_info: - await client.get_response(messages=[ChatMessage(role="user", text="Test message")]) + await client.get_response(messages=[Message(role="user", text="Test message")]) assert "content error" in str(exc_info.value) +@pytest.mark.asyncio async def test_hosted_file_search_tool_validation() -> None: """Test get_response HostedFileSearchTool validation.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - # Test HostedFileSearchTool without inputs (should raise ValueError) - empty_file_search_tool = HostedFileSearchTool() + # Test file search tool with vector store IDs + file_search_tool = OpenAIResponsesClient.get_file_search_tool(vector_store_ids=["vs_123"]) - with pytest.raises((ValueError, ServiceInvalidRequestError)): + # Test using file search tool - may raise various exceptions depending on API response + with pytest.raises((ValueError, ServiceInvalidRequestError, ServiceResponseException)): await client.get_response( - messages=[ChatMessage(role="user", text="Test")], - options={"tools": [empty_file_search_tool]}, + messages=[Message("user", ["Test"])], + options={"tools": [file_search_tool]}, ) @@ -349,9 +328,9 @@ async def test_chat_message_parsing_with_function_calls() -> None: function_result = Content.from_function_result(call_id="test-call-id", result="Function executed successfully") messages = [ - ChatMessage(role="user", text="Call a function"), - ChatMessage(role="assistant", contents=[function_call]), - ChatMessage(role="tool", contents=[function_result]), + Message(role="user", text="Call a function"), + Message(role="assistant", contents=[function_call]), + Message(role="tool", contents=[function_result]), ] # This should exercise the message parsing logic - will fail due to invalid API key @@ -377,7 +356,7 @@ async def test_response_format_parse_path() -> None: with patch.object(client.client.responses, "parse", return_value=mock_parsed_response): response = await client.get_response( - messages=[ChatMessage(role="user", text="Test message")], + messages=[Message(role="user", text="Test message")], options={"response_format": OutputStruct, "store": True}, ) assert response.response_id == "parsed_response_123" @@ -404,7 +383,7 @@ async def test_response_format_parse_path_with_conversation_id() -> None: with patch.object(client.client.responses, "parse", return_value=mock_parsed_response): response = await client.get_response( - messages=[ChatMessage(role="user", text="Test message")], + messages=[Message(role="user", text="Test message")], options={"response_format": OutputStruct, "store": True}, ) assert response.response_id == "parsed_response_123" @@ -427,7 +406,7 @@ async def test_bad_request_error_non_content_filter() -> None: with patch.object(client.client.responses, "parse", side_effect=mock_error): with pytest.raises(ServiceResponseException) as exc_info: await client.get_response( - messages=[ChatMessage(role="user", text="Test message")], + messages=[Message(role="user", text="Test message")], options={"response_format": OutputStruct}, ) @@ -448,7 +427,7 @@ async def test_streaming_content_filter_exception_handling() -> None: mock_create.side_effect.code = "content_filter" with pytest.raises(OpenAIContentFilterException, match="service encountered a content error"): - response_stream = client.get_response(stream=True, messages=[ChatMessage(role="user", text="Test")]) + response_stream = client.get_response(stream=True, messages=[Message(role="user", text="Test")]) async for _ in response_stream: break @@ -698,6 +677,40 @@ def test_prepare_content_for_openai_hosted_vector_store_content() -> None: assert result == {} +def test_prepare_content_for_openai_text_uses_role_specific_type() -> None: + """Text content should use input_text for user and output_text for assistant.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + text_content = Content.from_text(text="hello") + + user_result = client._prepare_content_for_openai("user", text_content, {}) + assistant_result = client._prepare_content_for_openai("assistant", text_content, {}) + + assert user_result["type"] == "input_text" + assert assistant_result["type"] == "output_text" + assert assistant_result["annotations"] == [] + assert user_result["text"] == "hello" + assert assistant_result["text"] == "hello" + + +def test_prepare_messages_for_openai_assistant_history_uses_output_text_with_annotations() -> None: + """Assistant history should be output_text and include required annotations.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + messages = [ + Message(role="user", text="What is async/await?"), + Message(role="assistant", text="Async/await enables non-blocking concurrency."), + ] + + prepared = client._prepare_messages_for_openai(messages) + + assert prepared[0]["role"] == "user" + assert prepared[0]["content"][0]["type"] == "input_text" + assert prepared[1]["role"] == "assistant" + assert prepared[1]["content"][0]["type"] == "output_text" + assert prepared[1]["content"][0]["annotations"] == [] + + def test_parse_response_from_openai_with_mcp_server_tool_result() -> None: """Test _parse_response_from_openai with MCP server tool result.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") @@ -792,7 +805,7 @@ def test_prepare_message_for_openai_with_function_approval_response() -> None: function_call=function_call, ) - message = ChatMessage(role="user", contents=[approval_response]) + message = Message(role="user", contents=[approval_response]) call_id_to_id: dict[str, str] = {} result = client._prepare_message_for_openai(message, call_id_to_id) @@ -814,17 +827,13 @@ def test_chat_message_with_error_content() -> None: error_code="TEST_ERR", ) - message = ChatMessage(role="assistant", contents=[error_content]) + message = Message(role="assistant", contents=[error_content]) call_id_to_id: dict[str, str] = {} result = client._prepare_message_for_openai(message, call_id_to_id) - # Message should be prepared with empty content list since ErrorContent returns {} - assert len(result) == 1 - prepared_message = result[0] - assert prepared_message["role"] == "assistant" - # Content should be a list with empty dict since ErrorContent returns {} - assert prepared_message.get("content") == [{}] + # Message should be empty since ErrorContent is filtered out + assert len(result) == 0 def test_chat_message_with_usage_content() -> None: @@ -839,17 +848,13 @@ def test_chat_message_with_usage_content() -> None: } ) - message = ChatMessage(role="assistant", contents=[usage_content]) + message = Message(role="assistant", contents=[usage_content]) call_id_to_id: dict[str, str] = {} result = client._prepare_message_for_openai(message, call_id_to_id) - # Message should be prepared with empty content list since UsageContent returns {} - assert len(result) == 1 - prepared_message = result[0] - assert prepared_message["role"] == "assistant" - # Content should be a list with empty dict since UsageContent returns {} - assert prepared_message.get("content") == [{}] + # Message should be empty since UsageContent is filtered out + assert len(result) == 0 def test_hosted_file_content_preparation() -> None: @@ -1074,18 +1079,17 @@ def test_streaming_chunk_with_usage_only() -> None: assert update.contents[0].usage_details["total_token_count"] == 75 -def test_prepare_tools_for_openai_with_hosted_mcp() -> None: - """Test that HostedMCPTool is converted to the correct response tool dict.""" +def test_prepare_tools_for_openai_with_mcp() -> None: + """Test that MCP tool dict is converted to the correct response tool dict.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - tool = HostedMCPTool( - name="My MCP", + # Use static method to create MCP tool + tool = OpenAIResponsesClient.get_mcp_tool( + name="My_MCP", url="https://mcp.example", - description="An MCP server", - approval_mode={"always_require_approval": ["tool_a", "tool_b"]}, - allowed_tools={"tool_a", "tool_b"}, + allowed_tools=["tool_a", "tool_b"], headers={"X-Test": "yes"}, - additional_properties={"custom": "value"}, + approval_mode={"always_require_approval": ["tool_a", "tool_b"]}, ) resp_tools = client._prepare_tools_for_openai([tool]) @@ -1097,7 +1101,6 @@ def test_prepare_tools_for_openai_with_hosted_mcp() -> None: assert mcp["server_label"] == "My_MCP" # server_url may be normalized to include a trailing slash by the client assert str(mcp["server_url"]).rstrip("/") == "https://mcp.example" - assert mcp["server_description"] == "An MCP server" assert mcp["headers"]["X-Test"] == "yes" assert set(mcp["allowed_tools"]) == {"tool_a", "tool_b"} # approval mapping created from approval_mode dict @@ -1258,13 +1261,15 @@ def test_prepare_tools_for_openai_with_raw_image_generation_minimal() -> None: assert len(image_tool) == 1 -def test_prepare_tools_for_openai_with_hosted_image_generation() -> None: - """Test HostedImageGenerationTool conversion.""" +def test_prepare_tools_for_openai_with_image_generation_options() -> None: + """Test image generation tool conversion with options.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - tool = HostedImageGenerationTool( - description="Generate images", - options={"output_format": "png", "size": "512x512"}, - additional_properties={"quality": "high"}, + + # Use static method to create image generation tool + tool = OpenAIResponsesClient.get_image_generation_tool( + output_format="png", + size="512x512", + quality="high", ) resp_tools = client._prepare_tools_for_openai([tool]) @@ -1343,14 +1348,14 @@ async def test_end_to_end_mcp_approval_flow(span_exporter) -> None: # Patch the create call to return the two mocked responses in sequence with patch.object(client.client.responses, "create", side_effect=[mock_response1, mock_response2]) as mock_create: # First call: get the approval request - response = await client.get_response(messages=[ChatMessage(role="user", text="Trigger approval")]) + response = await client.get_response(messages=[Message(role="user", text="Trigger approval")]) assert response.messages[0].contents[0].type == "function_approval_request" req = response.messages[0].contents[0] assert req.id == "approval-1" # Build a user approval and send it (include required function_call) approval = Content.from_function_approval_response(approved=True, id=req.id, function_call=req.function_call) - approval_message = ChatMessage(role="user", contents=[approval]) + approval_message = Message(role="user", contents=[approval]) _ = await client.get_response(messages=[approval_message]) # After approval is processed, the model is called again to get the final response @@ -1595,7 +1600,7 @@ def test_streaming_annotation_added_with_unknown_type() -> None: async def test_service_response_exception_includes_original_error_details() -> None: """Test that ServiceResponseException messages include original error details in the new format.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - messages = [ChatMessage(role="user", text="test message")] + messages = [Message(role="user", text="test message")] mock_response = MagicMock() original_error_message = "Request rate limit exceeded" @@ -1620,7 +1625,7 @@ async def test_service_response_exception_includes_original_error_details() -> N async def test_get_response_streaming_with_response_format() -> None: """Test get_response streaming with response_format.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - messages = [ChatMessage(role="user", text="Test streaming with format")] + messages = [Message(role="user", text="Test streaming with format")] # It will fail due to invalid API key, but exercises the code path with pytest.raises(ServiceResponseException): @@ -1715,6 +1720,64 @@ def test_parse_chunk_from_openai_code_interpreter() -> None: assert any(out.type == "uri" and out.uri == "https://example.com/plot.png" for out in result.contents[0].outputs) +def test_parse_chunk_from_openai_code_interpreter_delta() -> None: + """Test _parse_chunk_from_openai with code_interpreter_call_code delta events.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + + # Test delta event + mock_delta_event = MagicMock() + mock_delta_event.type = "response.code_interpreter_call_code.delta" + mock_delta_event.item_id = "ci_123" + mock_delta_event.delta = "import pandas as pd\n" + mock_delta_event.output_index = 0 + mock_delta_event.sequence_number = 1 + mock_delta_event.call_id = None # Ensure fallback to item_id + mock_delta_event.id = None + + result = client._parse_chunk_from_openai(mock_delta_event, chat_options, function_call_ids) # type: ignore + assert len(result.contents) == 1 + assert result.contents[0].type == "code_interpreter_tool_call" + assert result.contents[0].call_id == "ci_123" + assert result.contents[0].inputs + assert result.contents[0].inputs[0].type == "text" + assert result.contents[0].inputs[0].text == "import pandas as pd\n" + # Verify additional_properties for stream ordering + assert result.contents[0].additional_properties["output_index"] == 0 + assert result.contents[0].additional_properties["sequence_number"] == 1 + assert result.contents[0].additional_properties["item_id"] == "ci_123" + + +def test_parse_chunk_from_openai_code_interpreter_done() -> None: + """Test _parse_chunk_from_openai with code_interpreter_call_code done event.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + + # Test done event + mock_done_event = MagicMock() + mock_done_event.type = "response.code_interpreter_call_code.done" + mock_done_event.item_id = "ci_456" + mock_done_event.code = "import pandas as pd\ndf = pd.DataFrame({'a': [1, 2, 3]})\nprint(df)" + mock_done_event.output_index = 0 + mock_done_event.sequence_number = 5 + mock_done_event.call_id = None # Ensure fallback to item_id + mock_done_event.id = None + + result = client._parse_chunk_from_openai(mock_done_event, chat_options, function_call_ids) # type: ignore + assert len(result.contents) == 1 + assert result.contents[0].type == "code_interpreter_tool_call" + assert result.contents[0].call_id == "ci_456" + assert result.contents[0].inputs + assert result.contents[0].inputs[0].type == "text" + assert "import pandas as pd" in result.contents[0].inputs[0].text + # Verify additional_properties for stream ordering + assert result.contents[0].additional_properties["output_index"] == 0 + assert result.contents[0].additional_properties["sequence_number"] == 5 + assert result.contents[0].additional_properties["item_id"] == "ci_456" + + def test_parse_chunk_from_openai_reasoning() -> None: """Test _parse_chunk_from_openai with reasoning content.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") @@ -2068,7 +2131,7 @@ def test_parse_response_from_openai_image_generation_fallback(): async def test_prepare_options_store_parameter_handling() -> None: client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - messages = [ChatMessage(role="user", text="Test message")] + messages = [Message(role="user", text="Test message")] test_conversation_id = "test-conversation-123" chat_options = ChatOptions(store=True, conversation_id=test_conversation_id) @@ -2094,7 +2157,7 @@ async def test_prepare_options_store_parameter_handling() -> None: async def test_conversation_id_precedence_kwargs_over_options() -> None: """When both kwargs and options contain conversation_id, kwargs wins.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - messages = [ChatMessage(role="user", text="Hello")] + messages = [Message(role="user", text="Hello")] # options has a stale response id, kwargs carries the freshest one opts = {"conversation_id": "resp_old_123"} @@ -2201,14 +2264,14 @@ async def test_integration_options( # Prepare test message if option_name.startswith("tools") or option_name.startswith("tool_choice"): # Use weather-related prompt for tool tests - messages = [ChatMessage(role="user", text="What is the weather in Seattle?")] + messages = [Message(role="user", text="What is the weather in Seattle?")] elif option_name.startswith("response_format"): # Use prompt that works well with structured output - messages = [ChatMessage(role="user", text="The weather in Seattle is sunny")] - messages.append(ChatMessage(role="user", text="What is the weather in Seattle?")) + messages = [Message(role="user", text="The weather in Seattle is sunny")] + messages.append(Message(role="user", text="What is the weather in Seattle?")) else: # Generic prompt for simple options - messages = [ChatMessage(role="user", text="Say 'Hello World' briefly.")] + messages = [Message(role="user", text="Say 'Hello World' briefly.")] # Build options dict options: dict[str, Any] = {option_name: option_value} @@ -2266,11 +2329,13 @@ async def test_integration_web_search() -> None: client = OpenAIResponsesClient(model_id="gpt-5") for streaming in [False, True]: + # Use static method for web search tool + web_search_tool = OpenAIResponsesClient.get_web_search_tool() content = { "messages": "Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", "options": { "tool_choice": "auto", - "tools": [HostedWebSearchTool()], + "tools": [web_search_tool], }, } if streaming: @@ -2285,17 +2350,14 @@ async def test_integration_web_search() -> None: assert "Zoey" in response.text # Test that the client will use the web search tool with location - additional_properties = { - "user_location": { - "country": "US", - "city": "Seattle", - } - } + web_search_tool_with_location = OpenAIResponsesClient.get_web_search_tool( + user_location={"country": "US", "city": "Seattle"}, + ) content = { "messages": "What is the current weather? Do not ask for my current location.", "options": { "tool_choice": "auto", - "tools": [HostedWebSearchTool(additional_properties=additional_properties)], + "tools": [web_search_tool_with_location], }, } if streaming: @@ -2314,20 +2376,22 @@ async def test_integration_web_search() -> None: async def test_integration_file_search() -> None: openai_responses_client = OpenAIResponsesClient() - assert isinstance(openai_responses_client, ChatClientProtocol) + assert isinstance(openai_responses_client, SupportsChatGetResponse) file_id, vector_store = await create_vector_store(openai_responses_client) - # Test that the client will use the web search tool + # Use static method for file search tool + file_search_tool = OpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id]) + # Test that the client will use the file search tool response = await openai_responses_client.get_response( messages=[ - ChatMessage( + Message( role="user", text="What is the weather today? Do a file search to find the answer.", ) ], options={ "tool_choice": "auto", - "tools": [HostedFileSearchTool(inputs=vector_store)], + "tools": [file_search_tool], }, ) @@ -2345,21 +2409,22 @@ async def test_integration_file_search() -> None: async def test_integration_streaming_file_search() -> None: openai_responses_client = OpenAIResponsesClient() - assert isinstance(openai_responses_client, ChatClientProtocol) + assert isinstance(openai_responses_client, SupportsChatGetResponse) file_id, vector_store = await create_vector_store(openai_responses_client) + # Use static method for file search tool + file_search_tool = OpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id]) # Test that the client will use the web search tool - response = openai_responses_client.get_response( - stream=True, + response = openai_responses_client.get_streaming_response( messages=[ - ChatMessage( + Message( role="user", text="What is the weather today? Do a file search to find the answer.", ) ], options={ "tool_choice": "auto", - "tools": [HostedFileSearchTool(inputs=vector_store)], + "tools": [file_search_tool], }, ) @@ -2376,3 +2441,263 @@ async def test_integration_streaming_file_search() -> None: assert "sunny" in full_message.lower() assert "75" in full_message + + +# region Background Response / ContinuationToken Tests + + +def test_continuation_token_json_serializable() -> None: + """Test that OpenAIContinuationToken is a plain dict and JSON-serializable.""" + from agent_framework.openai import OpenAIContinuationToken + + token = OpenAIContinuationToken(response_id="resp_abc123") + assert token["response_id"] == "resp_abc123" + + # JSON round-trip + serialized = json.dumps(token) + restored = json.loads(serialized) + assert restored["response_id"] == "resp_abc123" + + +def test_chat_response_with_continuation_token() -> None: + """Test that ChatResponse accepts and stores continuation_token.""" + from agent_framework.openai import OpenAIContinuationToken + + token = OpenAIContinuationToken(response_id="resp_123") + response = ChatResponse( + messages=Message(role="assistant", contents=[Content.from_text(text="Hello")]), + response_id="resp_123", + continuation_token=token, + ) + assert response.continuation_token is not None + assert response.continuation_token["response_id"] == "resp_123" + + +def test_chat_response_without_continuation_token() -> None: + """Test that ChatResponse defaults continuation_token to None.""" + response = ChatResponse( + messages=Message(role="assistant", contents=[Content.from_text(text="Hello")]), + ) + assert response.continuation_token is None + + +def test_chat_response_update_with_continuation_token() -> None: + """Test that ChatResponseUpdate accepts and stores continuation_token.""" + from agent_framework.openai import OpenAIContinuationToken + + token = OpenAIContinuationToken(response_id="resp_456") + update = ChatResponseUpdate( + contents=[Content.from_text(text="chunk")], + role="assistant", + continuation_token=token, + ) + assert update.continuation_token is not None + assert update.continuation_token["response_id"] == "resp_456" + + +def test_agent_response_with_continuation_token() -> None: + """Test that AgentResponse accepts and stores continuation_token.""" + from agent_framework import AgentResponse + from agent_framework.openai import OpenAIContinuationToken + + token = OpenAIContinuationToken(response_id="resp_789") + response = AgentResponse( + messages=Message(role="assistant", contents=[Content.from_text(text="done")]), + continuation_token=token, + ) + assert response.continuation_token is not None + assert response.continuation_token["response_id"] == "resp_789" + + +def test_agent_response_update_with_continuation_token() -> None: + """Test that AgentResponseUpdate accepts and stores continuation_token.""" + from agent_framework import AgentResponseUpdate + from agent_framework.openai import OpenAIContinuationToken + + token = OpenAIContinuationToken(response_id="resp_012") + update = AgentResponseUpdate( + contents=[Content.from_text(text="streaming")], + role="assistant", + continuation_token=token, + ) + assert update.continuation_token is not None + assert update.continuation_token["response_id"] == "resp_012" + + +def test_parse_response_from_openai_with_background_in_progress() -> None: + """Test that _parse_response_from_openai sets continuation_token when status is in_progress.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "resp_bg_123" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.status = "in_progress" + + mock_message = MagicMock() + mock_message.type = "message" + mock_message.content = [] + mock_response.output = [mock_message] + + options: dict[str, Any] = {"store": False} + result = client._parse_response_from_openai(mock_response, options=options) + + assert result.continuation_token is not None + assert result.continuation_token["response_id"] == "resp_bg_123" + + +def test_parse_response_from_openai_with_background_queued() -> None: + """Test that _parse_response_from_openai sets continuation_token when status is queued.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "resp_bg_456" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.status = "queued" + + mock_message = MagicMock() + mock_message.type = "message" + mock_message.content = [] + mock_response.output = [mock_message] + + options: dict[str, Any] = {"store": False} + result = client._parse_response_from_openai(mock_response, options=options) + + assert result.continuation_token is not None + assert result.continuation_token["response_id"] == "resp_bg_456" + + +def test_parse_response_from_openai_with_background_completed() -> None: + """Test that _parse_response_from_openai does NOT set continuation_token when status is completed.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "resp_bg_789" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.status = "completed" + + mock_text_content = MagicMock() + mock_text_content.type = "output_text" + mock_text_content.text = "Final answer" + mock_text_content.annotations = [] + mock_text_content.logprobs = None + + mock_message = MagicMock() + mock_message.type = "message" + mock_message.content = [mock_text_content] + mock_response.output = [mock_message] + + options: dict[str, Any] = {"store": False} + result = client._parse_response_from_openai(mock_response, options=options) + + assert result.continuation_token is None + + +def test_streaming_response_in_progress_sets_continuation_token() -> None: + """Test that _parse_chunk_from_openai sets continuation_token for in_progress events.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options: dict[str, Any] = {} + function_call_ids: dict[int, tuple[str, str]] = {} + + mock_event = MagicMock() + mock_event.type = "response.in_progress" + mock_event.response = MagicMock() + mock_event.response.id = "resp_stream_123" + mock_event.response.conversation = MagicMock() + mock_event.response.conversation.id = "conv_456" + mock_event.response.status = "in_progress" + + update = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids) + + assert update.continuation_token is not None + assert update.continuation_token["response_id"] == "resp_stream_123" + + +def test_streaming_response_created_with_in_progress_status_sets_continuation_token() -> None: + """Test that response.created with in_progress status sets continuation_token.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options: dict[str, Any] = {} + function_call_ids: dict[int, tuple[str, str]] = {} + + mock_event = MagicMock() + mock_event.type = "response.created" + mock_event.response = MagicMock() + mock_event.response.id = "resp_created_123" + mock_event.response.conversation = MagicMock() + mock_event.response.conversation.id = "conv_789" + mock_event.response.status = "in_progress" + + update = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids) + + assert update.continuation_token is not None + assert update.continuation_token["response_id"] == "resp_created_123" + + +def test_streaming_response_completed_no_continuation_token() -> None: + """Test that response.completed does NOT set continuation_token.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options: dict[str, Any] = {} + function_call_ids: dict[int, tuple[str, str]] = {} + + mock_event = MagicMock() + mock_event.type = "response.completed" + mock_event.response = MagicMock() + mock_event.response.id = "resp_done_123" + mock_event.response.conversation = MagicMock() + mock_event.response.conversation.id = "conv_done" + mock_event.response.model = "test-model" + mock_event.response.usage = None + + update = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids) + + assert update.continuation_token is None + + +def test_map_chat_to_agent_update_preserves_continuation_token() -> None: + """Test that map_chat_to_agent_update propagates continuation_token.""" + from agent_framework._types import map_chat_to_agent_update + + token = {"response_id": "resp_map_123"} + chat_update = ChatResponseUpdate( + contents=[Content.from_text(text="chunk")], + role="assistant", + response_id="resp_map_123", + continuation_token=token, + ) + + agent_update = map_chat_to_agent_update(chat_update, agent_name="test-agent") + + assert agent_update.continuation_token is not None + assert agent_update.continuation_token["response_id"] == "resp_map_123" + + +async def test_prepare_options_excludes_continuation_token() -> None: + """Test that _prepare_options does not pass continuation_token to OpenAI API.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] + options: dict[str, Any] = { + "model_id": "test-model", + "continuation_token": {"response_id": "resp_123"}, + "background": True, + } + + run_options = await client._prepare_options(messages, options) + + assert "continuation_token" not in run_options + assert "background" in run_options + assert run_options["background"] is True + + +# endregion diff --git a/python/samples/demos/chatkit-integration/__init__.py b/python/packages/core/tests/workflow/__init__.py similarity index 100% rename from python/samples/demos/chatkit-integration/__init__.py rename to python/packages/core/tests/workflow/__init__.py diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index 841ef84b85..7c2e6fc356 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -7,11 +7,10 @@ from agent_framework import ( AgentExecutor, AgentResponse, AgentResponseUpdate, - AgentThread, + AgentSession, BaseAgent, - ChatMessage, - ChatMessageStore, Content, + Message, ResponseStream, WorkflowRunState, ) @@ -21,7 +20,7 @@ from agent_framework.orchestrations import SequentialBuilder class _CountingAgent(BaseAgent): - """Agent that echoes messages with a counter to verify thread state persistence.""" + """Agent that echoes messages with a counter to verify session state persistence.""" def __init__(self, **kwargs: Any): super().__init__(**kwargs) @@ -29,10 +28,10 @@ class _CountingAgent(BaseAgent): def run( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: self.call_count += 1 @@ -46,28 +45,79 @@ class _CountingAgent(BaseAgent): return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) async def _run() -> AgentResponse: - return AgentResponse(messages=[ChatMessage("assistant", [f"Response #{self.call_count}: {self.name}"])]) + return AgentResponse(messages=[Message("assistant", [f"Response #{self.call_count}: {self.name}"])]) return _run() +class _StreamingHookAgent(BaseAgent): + """Agent that exposes whether its streaming result hook was executed.""" + + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) + self.result_hook_called = False + + def run( + self, + messages: str | Message | list[str] | list[Message] | None = None, + *, + stream: bool = False, + **kwargs: Any, + ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: + if stream: + + async def _stream() -> AsyncIterable[AgentResponseUpdate]: + yield AgentResponseUpdate( + contents=[Content.from_text(text="hook test")], + role="assistant", + ) + + async def _mark_result_hook_called(response: AgentResponse) -> AgentResponse: + self.result_hook_called = True + return response + + return ResponseStream(_stream(), finalizer=AgentResponse.from_updates).with_result_hook( + _mark_result_hook_called + ) + + async def _run() -> AgentResponse: + return AgentResponse(messages=[Message("assistant", ["hook test"])]) + + return _run() + + +async def test_agent_executor_streaming_finalizes_stream_and_runs_result_hooks() -> None: + """AgentExecutor should call get_final_response() so stream result hooks execute.""" + agent = _StreamingHookAgent(id="hook_agent", name="HookAgent") + executor = AgentExecutor(agent, id="hook_exec") + workflow = SequentialBuilder(participants=[executor]).build() + + output_events: list[Any] = [] + async for event in workflow.run("run hook test", stream=True): + if event.type == "output": + output_events.append(event) + + assert output_events + assert agent.result_hook_called + + async def test_agent_executor_checkpoint_stores_and_restores_state() -> None: - """Test that workflow checkpoint stores AgentExecutor's cache and thread states and restores them correctly.""" + """Test that workflow checkpoint stores AgentExecutor's cache and session states and restores them correctly.""" storage = InMemoryCheckpointStorage() - # Create initial agent with a custom thread that has a message store + # Create initial agent with a custom session initial_agent = _CountingAgent(id="test_agent", name="TestAgent") - initial_thread = AgentThread(message_store=ChatMessageStore()) + initial_session = AgentSession() - # Add some initial messages to the thread to verify thread state persistence + # Add some initial messages to the session state to verify session state persistence initial_messages = [ - ChatMessage(role="user", text="Initial message 1"), - ChatMessage(role="assistant", text="Initial response 1"), + Message(role="user", text="Initial message 1"), + Message(role="assistant", text="Initial response 1"), ] - await initial_thread.on_new_messages(initial_messages) + initial_session.state["history"] = {"messages": initial_messages} - # Create AgentExecutor with the thread - executor = AgentExecutor(initial_agent, agent_thread=initial_thread) + # Create AgentExecutor with the session + executor = AgentExecutor(initial_agent, session=initial_session) # Build workflow with checkpointing enabled wf = SequentialBuilder(participants=[executor], checkpoint_storage=storage).build() @@ -84,17 +134,18 @@ 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." ) - # Verify checkpoint contains executor state with both cache and thread + # 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 session assert "_executor_state" in restore_checkpoint.state executor_states = restore_checkpoint.state["_executor_state"] assert isinstance(executor_states, dict) @@ -102,13 +153,12 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None: executor_state = executor_states[executor.id] # type: ignore[index] assert "cache" in executor_state, "Checkpoint should store executor cache state" - assert "agent_thread" in executor_state, "Checkpoint should store executor thread state" + assert "agent_session" in executor_state, "Checkpoint should store executor session state" - # Verify thread state includes message store - thread_state = executor_state["agent_thread"] # type: ignore[index] - assert "chat_message_store_state" in thread_state, "Thread state should include message store" - chat_store_state = thread_state["chat_message_store_state"] # type: ignore[index] - assert "messages" in chat_store_state, "Message store state should include messages" + # Verify session state structure + session_state = executor_state["agent_session"] # type: ignore[index] + assert "session_id" in session_state, "Session state should include session_id" + assert "state" in session_state, "Session state should include state dict" # Verify checkpoint contains pending requests from agents and responses to be sent assert "pending_agent_requests" in executor_state @@ -117,8 +167,8 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None: # Create a new agent and executor for restoration # This simulates starting from a fresh state and restoring from checkpoint restored_agent = _CountingAgent(id="test_agent", name="TestAgent") - restored_thread = AgentThread(message_store=ChatMessageStore()) - restored_executor = AgentExecutor(restored_agent, agent_thread=restored_thread) + restored_session = AgentSession() + restored_executor = AgentExecutor(restored_agent, session=restored_session) # Verify the restored agent starts with a fresh state assert restored_agent.call_count == 0 @@ -139,70 +189,55 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None: assert resumed_output is not None - # Verify the restored executor's state matches the original - # The cache should be restored (though it may be cleared after processing) - # The thread should have all messages including those from the initial state - message_store = restored_executor._agent_thread.message_store # type: ignore[reportPrivateUsage] - assert message_store is not None - thread_messages = await message_store.list_messages() - - # Thread should contain: - # 1. Initial messages from before the checkpoint (2 messages) - # 2. User message from first run (1 message) - # 3. Assistant response from first run (1 message) - assert len(thread_messages) >= 2, "Thread should preserve initial messages from before checkpoint" - - # Verify initial messages are preserved - assert thread_messages[0].text == "Initial message 1" - assert thread_messages[1].text == "Initial response 1" + # Verify the restored executor's session state was restored + restored_session_obj = restored_executor._session # type: ignore[reportPrivateUsage] + assert restored_session_obj is not None + assert restored_session_obj.session_id == initial_session.session_id async def test_agent_executor_save_and_restore_state_directly() -> None: """Test AgentExecutor's on_checkpoint_save and on_checkpoint_restore methods directly.""" - # Create agent with thread containing messages + # Create agent with session containing state agent = _CountingAgent(id="direct_test_agent", name="DirectTestAgent") - thread = AgentThread(message_store=ChatMessageStore()) + session = AgentSession() - # Add messages to thread - thread_messages = [ - ChatMessage(role="user", text="Message in thread 1"), - ChatMessage(role="assistant", text="Thread response 1"), - ChatMessage(role="user", text="Message in thread 2"), + # Add messages to session state + session_messages = [ + Message(role="user", text="Message in session 1"), + Message(role="assistant", text="Session response 1"), + Message(role="user", text="Message in session 2"), ] - await thread.on_new_messages(thread_messages) + session.state["history"] = {"messages": session_messages} - executor = AgentExecutor(agent, agent_thread=thread) + executor = AgentExecutor(agent, session=session) # Add messages to executor cache cache_messages = [ - ChatMessage(role="user", text="Cached user message"), - ChatMessage(role="assistant", text="Cached assistant response"), + Message(role="user", text="Cached user message"), + Message(role="assistant", text="Cached assistant response"), ] executor._cache = list(cache_messages) # type: ignore[reportPrivateUsage] # Snapshot the state state = await executor.on_checkpoint_save() - # Verify snapshot contains both cache and thread + # Verify snapshot contains both cache and session assert "cache" in state - assert "agent_thread" in state + assert "agent_session" in state - # Verify thread state structure - thread_state = state["agent_thread"] # type: ignore[index] - assert "chat_message_store_state" in thread_state - assert "messages" in thread_state["chat_message_store_state"] + # Verify session state structure + session_state = state["agent_session"] # type: ignore[index] + assert "session_id" in session_state + assert "state" in session_state # Create new executor to restore into new_agent = _CountingAgent(id="direct_test_agent", name="DirectTestAgent") - new_thread = AgentThread(message_store=ChatMessageStore()) - new_executor = AgentExecutor(new_agent, agent_thread=new_thread) + new_session = AgentSession() + new_executor = AgentExecutor(new_agent, session=new_session) # Verify new executor starts empty assert len(new_executor._cache) == 0 # type: ignore[reportPrivateUsage] - initial_message_store = new_thread.message_store - assert initial_message_store is not None - initial_thread_msgs = await initial_message_store.list_messages() - assert len(initial_thread_msgs) == 0 + assert len(new_session.state) == 0 # Restore state await new_executor.on_checkpoint_restore(state) @@ -213,11 +248,6 @@ async def test_agent_executor_save_and_restore_state_directly() -> None: assert restored_cache[0].text == "Cached user message" assert restored_cache[1].text == "Cached assistant response" - # Verify thread messages are restored - restored_message_store = new_executor._agent_thread.message_store # type: ignore[reportPrivateUsage] - assert restored_message_store is not None - restored_thread_msgs = await restored_message_store.list_messages() - assert len(restored_thread_msgs) == len(thread_messages) - assert restored_thread_msgs[0].text == "Message in thread 1" - assert restored_thread_msgs[1].text == "Thread response 1" - assert restored_thread_msgs[2].text == "Message in thread 2" + # Verify session was restored with correct session_id + restored_session = new_executor._session # type: ignore[reportPrivateUsage] + assert restored_session.session_id == session.session_id diff --git a/python/packages/core/tests/workflow/test_agent_executor_tool_calls.py b/python/packages/core/tests/workflow/test_agent_executor_tool_calls.py index 2d4e3ecf39..47356638a6 100644 --- a/python/packages/core/tests/workflow/test_agent_executor_tool_calls.py +++ b/python/packages/core/tests/workflow/test_agent_executor_tool_calls.py @@ -8,18 +8,18 @@ from typing import Any from typing_extensions import Never from agent_framework import ( + Agent, AgentExecutor, AgentExecutorResponse, AgentResponse, AgentResponseUpdate, - AgentThread, + AgentSession, BaseAgent, - ChatAgent, - ChatMessage, ChatResponse, ChatResponseUpdate, Content, FunctionTool, + Message, ResponseStream, WorkflowBuilder, WorkflowContext, @@ -39,17 +39,17 @@ class _ToolCallingAgent(BaseAgent): def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: if stream: return ResponseStream(self._run_stream_impl(), finalizer=AgentResponse.from_updates) async def _run() -> AgentResponse: - return AgentResponse(messages=[ChatMessage("assistant", ["done"])]) + return AgentResponse(messages=[Message("assistant", ["done"])]) return _run() @@ -156,7 +156,7 @@ class MockChatClient(FunctionInvocationLayer[Any], BaseChatClient[Any]): def _inner_get_response( self, *, - messages: Sequence[ChatMessage], + messages: Sequence[Message], stream: bool, options: Mapping[str, Any], **kwargs: Any, @@ -175,7 +175,7 @@ class MockChatClient(FunctionInvocationLayer[Any], BaseChatClient[Any]): if self._iteration == 0: if self._parallel_request: response = ChatResponse( - messages=ChatMessage( + messages=Message( "assistant", [ Content.from_function_call( @@ -189,7 +189,7 @@ class MockChatClient(FunctionInvocationLayer[Any], BaseChatClient[Any]): ) else: response = ChatResponse( - messages=ChatMessage( + messages=Message( "assistant", [ Content.from_function_call( @@ -199,7 +199,7 @@ class MockChatClient(FunctionInvocationLayer[Any], BaseChatClient[Any]): ) ) else: - response = ChatResponse(messages=ChatMessage("assistant", ["Tool executed successfully."])) + response = ChatResponse(messages=Message("assistant", ["Tool executed successfully."])) self._iteration += 1 return response @@ -243,8 +243,8 @@ async def test_executor(agent_executor_response: AgentExecutorResponse, ctx: Wor async def test_agent_executor_tool_call_with_approval() -> None: """Test that AgentExecutor handles tool calls requiring approval.""" # Arrange - agent = ChatAgent( - chat_client=MockChatClient(), + agent = Agent( + client=MockChatClient(), name="ApprovalAgent", tools=[mock_tool_requiring_approval], ) @@ -277,8 +277,8 @@ async def test_agent_executor_tool_call_with_approval() -> None: async def test_agent_executor_tool_call_with_approval_streaming() -> None: """Test that AgentExecutor handles tool calls requiring approval in streaming mode.""" # Arrange - agent = ChatAgent( - chat_client=MockChatClient(), + agent = Agent( + client=MockChatClient(), name="ApprovalAgent", tools=[mock_tool_requiring_approval], ) @@ -314,8 +314,8 @@ async def test_agent_executor_tool_call_with_approval_streaming() -> None: async def test_agent_executor_parallel_tool_call_with_approval() -> None: """Test that AgentExecutor handles parallel tool calls requiring approval.""" # Arrange - agent = ChatAgent( - chat_client=MockChatClient(parallel_request=True), + agent = Agent( + client=MockChatClient(parallel_request=True), name="ApprovalAgent", tools=[mock_tool_requiring_approval], ) @@ -350,8 +350,8 @@ async def test_agent_executor_parallel_tool_call_with_approval() -> None: async def test_agent_executor_parallel_tool_call_with_approval_streaming() -> None: """Test that AgentExecutor handles parallel tool calls requiring approval in streaming mode.""" # Arrange - agent = ChatAgent( - chat_client=MockChatClient(parallel_request=True), + agent = Agent( + client=MockChatClient(parallel_request=True), name="ApprovalAgent", tools=[mock_tool_requiring_approval], ) @@ -409,7 +409,7 @@ class DeclarationOnlyMockChatClient(FunctionInvocationLayer[Any], BaseChatClient def _inner_get_response( self, *, - messages: Sequence[ChatMessage], + messages: Sequence[Message], stream: bool, options: Mapping[str, Any], **kwargs: Any, @@ -426,7 +426,7 @@ class DeclarationOnlyMockChatClient(FunctionInvocationLayer[Any], BaseChatClient if self._iteration == 0: if self._parallel_request: response = ChatResponse( - messages=ChatMessage( + messages=Message( "assistant", [ Content.from_function_call( @@ -440,7 +440,7 @@ class DeclarationOnlyMockChatClient(FunctionInvocationLayer[Any], BaseChatClient ) else: response = ChatResponse( - messages=ChatMessage( + messages=Message( "assistant", [ Content.from_function_call( @@ -450,7 +450,7 @@ class DeclarationOnlyMockChatClient(FunctionInvocationLayer[Any], BaseChatClient ) ) else: - response = ChatResponse(messages=ChatMessage("assistant", ["Tool executed successfully."])) + response = ChatResponse(messages=Message("assistant", ["Tool executed successfully."])) self._iteration += 1 return response @@ -483,8 +483,8 @@ class DeclarationOnlyMockChatClient(FunctionInvocationLayer[Any], BaseChatClient async def test_agent_executor_declaration_only_tool_emits_request_info() -> None: """Test that AgentExecutor emits request_info when agent calls a declaration-only tool.""" - agent = ChatAgent( - chat_client=DeclarationOnlyMockChatClient(), + agent = Agent( + client=DeclarationOnlyMockChatClient(), name="DeclarationOnlyAgent", tools=[declaration_only_tool], ) @@ -519,8 +519,8 @@ async def test_agent_executor_declaration_only_tool_emits_request_info() -> None async def test_agent_executor_declaration_only_tool_emits_request_info_streaming() -> None: """Test that AgentExecutor emits request_info for declaration-only tools in streaming mode.""" - agent = ChatAgent( - chat_client=DeclarationOnlyMockChatClient(), + agent = Agent( + client=DeclarationOnlyMockChatClient(), name="DeclarationOnlyAgent", tools=[declaration_only_tool], ) @@ -558,8 +558,8 @@ async def test_agent_executor_declaration_only_tool_emits_request_info_streaming async def test_agent_executor_parallel_declaration_only_tool_emits_request_info() -> None: """Test that AgentExecutor emits request_info for parallel declaration-only tool calls.""" - agent = ChatAgent( - chat_client=DeclarationOnlyMockChatClient(parallel_request=True), + agent = Agent( + client=DeclarationOnlyMockChatClient(parallel_request=True), name="DeclarationOnlyAgent", tools=[declaration_only_tool], ) diff --git a/python/packages/core/tests/workflow/test_agent_run_event_typing.py b/python/packages/core/tests/workflow/test_agent_run_event_typing.py index 410f57f962..ff8ef99893 100644 --- a/python/packages/core/tests/workflow/test_agent_run_event_typing.py +++ b/python/packages/core/tests/workflow/test_agent_run_event_typing.py @@ -2,13 +2,13 @@ """Tests for WorkflowEvent[T] generic type annotations.""" -from agent_framework import AgentResponse, AgentResponseUpdate, ChatMessage +from agent_framework import AgentResponse, AgentResponseUpdate, Message from agent_framework._workflows._events import WorkflowEvent def test_workflow_event_with_agent_response_data_type() -> None: """Verify WorkflowEvent[AgentResponse].data is typed as AgentResponse.""" - response = AgentResponse(messages=[ChatMessage(role="assistant", text="Hello")]) + response = AgentResponse(messages=[Message(role="assistant", text="Hello")]) event: WorkflowEvent[AgentResponse] = WorkflowEvent.emit(executor_id="test", data=response) # This assignment should pass type checking without a cast @@ -29,7 +29,7 @@ def test_workflow_event_with_agent_response_update_data_type() -> None: def test_workflow_event_repr() -> None: """Verify WorkflowEvent.__repr__ uses consistent format.""" - response = AgentResponse(messages=[ChatMessage(role="assistant", text="Hello")]) + response = AgentResponse(messages=[Message(role="assistant", text="Hello")]) event: WorkflowEvent[AgentResponse] = WorkflowEvent.emit(executor_id="test", data=response) repr_str = repr(event) diff --git a/python/packages/core/tests/workflow/test_agent_utils.py b/python/packages/core/tests/workflow/test_agent_utils.py index c26ecda04c..d3889b4d3b 100644 --- a/python/packages/core/tests/workflow/test_agent_utils.py +++ b/python/packages/core/tests/workflow/test_agent_utils.py @@ -3,7 +3,7 @@ from collections.abc import AsyncIterable from typing import Any -from agent_framework import AgentResponse, AgentResponseUpdate, AgentThread, ChatMessage +from agent_framework import AgentResponse, AgentResponseUpdate, AgentSession, Message from agent_framework._workflows._agent_utils import resolve_agent_id @@ -34,15 +34,15 @@ class MockAgent: def run( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> AgentResponse | AsyncIterable[AgentResponseUpdate]: ... - def get_new_thread(self, **kwargs: Any) -> AgentThread: - """Creates a new conversation thread for the agent.""" + def create_session(self, **kwargs: Any) -> AgentSession: + """Creates a new conversation session for the agent.""" ... 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 c028a94b40..a9c748a324 100644 --- a/python/packages/core/tests/workflow/test_checkpoint_validation.py +++ b/python/packages/core/tests/workflow/test_checkpoint_validation.py @@ -7,6 +7,7 @@ from agent_framework import ( WorkflowBuilder, WorkflowCheckpointException, WorkflowContext, + WorkflowExecutor, WorkflowRunState, handler, ) @@ -43,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] @@ -66,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") @@ -81,3 +82,87 @@ async def test_resume_succeeds_when_graph_matches() -> None: ] assert any(event.type == "status" and event.state == WorkflowRunState.IDLE for event in events) + + +# -- Sub-workflow checkpoint validation tests -- + + +class SubStartExecutor(Executor): + @handler + async def run(self, message: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(message) + + +class SubFinishExecutor(Executor): + @handler + async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None: + await ctx.yield_output(message) + + +def build_sub_workflow(sub_finish_id: str = "sub_finish"): + sub_start = SubStartExecutor(id="sub_start") + sub_finish = SubFinishExecutor(id=sub_finish_id) + return WorkflowBuilder(start_executor=sub_start).add_edge(sub_start, sub_finish).build() + + +def build_parent_workflow(storage: InMemoryCheckpointStorage, sub_finish_id: str = "sub_finish"): + sub_workflow = build_sub_workflow(sub_finish_id=sub_finish_id) + sub_executor = WorkflowExecutor(sub_workflow, id="sub_wf", allow_direct_output=True) + + start = StartExecutor(id="start") + finish = FinishExecutor(id="finish") + + builder = ( + WorkflowBuilder(max_iterations=3, start_executor=start, checkpoint_storage=storage) + .add_edge(start, sub_executor) + .add_edge(sub_executor, finish) + ) + return builder.build() + + +async def test_resume_succeeds_when_sub_workflow_matches() -> None: + storage = InMemoryCheckpointStorage() + workflow = build_parent_workflow(storage, sub_finish_id="sub_finish") + + _ = [event async for event in workflow.run("hello", stream=True)] + + checkpoints = await storage.list_checkpoints(workflow_name=workflow.name) + assert checkpoints, "expected at least one checkpoint to be created" + target_checkpoint = checkpoints[-1] + + resumed_workflow = build_parent_workflow(storage, sub_finish_id="sub_finish") + + events = [ + event + async for event in resumed_workflow.run( + checkpoint_id=target_checkpoint.checkpoint_id, + checkpoint_storage=storage, + stream=True, + ) + ] + + assert any(event.type == "status" and event.state == WorkflowRunState.IDLE for event in events) + + +async def test_resume_fails_when_sub_workflow_changes() -> None: + storage = InMemoryCheckpointStorage() + workflow = build_parent_workflow(storage, sub_finish_id="sub_finish") + + _ = [event async for event in workflow.run("hello", stream=True)] + + checkpoints = await storage.list_checkpoints(workflow_name=workflow.name) + assert checkpoints, "expected at least one checkpoint to be created" + target_checkpoint = checkpoints[-1] + + # Build parent with a structurally different sub-workflow (different executor id inside) + mismatched_workflow = build_parent_workflow(storage, sub_finish_id="sub_finish_alt") + + with pytest.raises(WorkflowCheckpointException, match="Workflow graph has changed"): + _ = [ + event + async for event in mismatched_workflow.run( + checkpoint_id=target_checkpoint.checkpoint_id, + checkpoint_storage=storage, + stream=True, + ) + ] diff --git a/python/packages/core/tests/workflow/test_edge.py b/python/packages/core/tests/workflow/test_edge.py index 42ff6e5d36..f63cf9b45b 100644 --- a/python/packages/core/tests/workflow/test_edge.py +++ b/python/packages/core/tests/workflow/test_edge.py @@ -9,8 +9,8 @@ import pytest from agent_framework import ( Executor, InProcRunnerContext, - Message, WorkflowContext, + WorkflowMessage, handler, ) from agent_framework._workflows._edge import ( @@ -193,7 +193,7 @@ async def test_single_edge_group_send_message() -> None: ctx = InProcRunnerContext() data = MockMessage(data="test") - message = Message(data=data, source_id=source.id) + message = WorkflowMessage(data=data, source_id=source.id) success = await edge_runner.send_message(message, state, ctx) assert success is True @@ -212,7 +212,7 @@ async def test_single_edge_group_send_message_with_target() -> None: ctx = InProcRunnerContext() data = MockMessage(data="test") - message = Message(data=data, source_id=source.id, target_id=target.id) + message = WorkflowMessage(data=data, source_id=source.id, target_id=target.id) success = await edge_runner.send_message(message, state, ctx) assert success is True @@ -231,7 +231,7 @@ async def test_single_edge_group_send_message_with_invalid_target() -> None: ctx = InProcRunnerContext() data = MockMessage(data="test") - message = Message(data=data, source_id=source.id, target_id="invalid_target") + message = WorkflowMessage(data=data, source_id=source.id, target_id="invalid_target") success = await edge_runner.send_message(message, state, ctx) assert success is False @@ -250,7 +250,7 @@ async def test_single_edge_group_send_message_with_invalid_data() -> None: ctx = InProcRunnerContext() data = "invalid_data" - message = Message(data=data, source_id=source.id) + message = WorkflowMessage(data=data, source_id=source.id) success = await edge_runner.send_message(message, state, ctx) assert success is False @@ -270,7 +270,7 @@ async def test_single_edge_group_send_message_with_condition_pass() -> None: ctx = InProcRunnerContext() data = MockMessage(data="test") - message = Message(data=data, source_id=source.id) + message = WorkflowMessage(data=data, source_id=source.id) success = await edge_runner.send_message(message, state, ctx) assert success is True @@ -292,7 +292,7 @@ async def test_single_edge_group_send_message_with_condition_fail() -> None: ctx = InProcRunnerContext() data = MockMessage(data="different") - message = Message(data=data, source_id=source.id) + message = WorkflowMessage(data=data, source_id=source.id) success = await edge_runner.send_message(message, state, ctx) # Should return True because message was processed, but condition failed @@ -318,7 +318,9 @@ async def test_single_edge_group_tracing_success(span_exporter) -> None: source_span_ids = ["00f067aa0ba902b7"] data = MockMessage(data="test") - message = Message(data=data, source_id=source.id, trace_contexts=trace_contexts, source_span_ids=source_span_ids) + message = WorkflowMessage( + data=data, source_id=source.id, trace_contexts=trace_contexts, source_span_ids=source_span_ids + ) # Clear any build spans span_exporter.clear() @@ -363,7 +365,7 @@ async def test_single_edge_group_tracing_condition_failure(span_exporter) -> Non ctx = InProcRunnerContext() data = MockMessage(data="fail") - message = Message(data=data, source_id=source.id) + message = WorkflowMessage(data=data, source_id=source.id) # Clear any build spans span_exporter.clear() @@ -398,7 +400,7 @@ async def test_single_edge_group_tracing_type_mismatch(span_exporter) -> None: # Send incompatible data type data = "invalid_data" - message = Message(data=data, source_id=source.id) + message = WorkflowMessage(data=data, source_id=source.id) # Clear any build spans span_exporter.clear() @@ -432,7 +434,7 @@ async def test_single_edge_group_tracing_target_mismatch(span_exporter) -> None: ctx = InProcRunnerContext() data = MockMessage(data="test") - message = Message(data=data, source_id=source.id, target_id="wrong_target") + message = WorkflowMessage(data=data, source_id=source.id, target_id="wrong_target") # Clear any build spans span_exporter.clear() @@ -500,7 +502,7 @@ async def test_source_edge_group_send_message() -> None: ctx = InProcRunnerContext() data = MockMessage(data="test") - message = Message(data=data, source_id=source.id) + message = WorkflowMessage(data=data, source_id=source.id) success = await edge_runner.send_message(message, state, ctx) @@ -523,7 +525,7 @@ async def test_source_edge_group_send_message_with_target() -> None: ctx = InProcRunnerContext() data = MockMessage(data="test") - message = Message(data=data, source_id=source.id, target_id=target1.id) + message = WorkflowMessage(data=data, source_id=source.id, target_id=target1.id) success = await edge_runner.send_message(message, state, ctx) @@ -546,7 +548,7 @@ async def test_source_edge_group_send_message_with_invalid_target() -> None: ctx = InProcRunnerContext() data = MockMessage(data="test") - message = Message(data=data, source_id=source.id, target_id="invalid_target") + message = WorkflowMessage(data=data, source_id=source.id, target_id="invalid_target") success = await edge_runner.send_message(message, state, ctx) assert success is False @@ -566,7 +568,7 @@ async def test_source_edge_group_send_message_with_invalid_data() -> None: ctx = InProcRunnerContext() data = "invalid_data" - message = Message(data=data, source_id=source.id) + message = WorkflowMessage(data=data, source_id=source.id) success = await edge_runner.send_message(message, state, ctx) assert success is False @@ -586,7 +588,7 @@ async def test_source_edge_group_send_message_only_one_successful_send() -> None ctx = InProcRunnerContext() data = MockMessage(data="test") - message = Message(data=data, source_id=source.id) + message = WorkflowMessage(data=data, source_id=source.id) success = await edge_runner.send_message(message, state, ctx) @@ -635,7 +637,7 @@ async def test_source_edge_group_with_selection_func_send_message() -> None: ctx = InProcRunnerContext() data = MockMessage(data="test") - message = Message(data=data, source_id=source.id) + message = WorkflowMessage(data=data, source_id=source.id) with patch("agent_framework._workflows._edge_runner.EdgeRunner._execute_on_target") as mock_send: success = await edge_runner.send_message(message, state, ctx) @@ -663,7 +665,7 @@ async def test_source_edge_group_with_selection_func_send_message_with_invalid_s ctx = InProcRunnerContext() data = MockMessage(data="test") - message = Message(data=data, source_id=source.id) + message = WorkflowMessage(data=data, source_id=source.id) with pytest.raises(RuntimeError): await edge_runner.send_message(message, state, ctx) @@ -688,7 +690,7 @@ async def test_source_edge_group_with_selection_func_send_message_with_target() ctx = InProcRunnerContext() data = MockMessage(data="test") - message = Message(data=data, source_id=source.id, target_id=target1.id) + message = WorkflowMessage(data=data, source_id=source.id, target_id=target1.id) with patch("agent_framework._workflows._edge_runner.EdgeRunner._execute_on_target") as mock_send: success = await edge_runner.send_message(message, state, ctx) @@ -717,7 +719,7 @@ async def test_source_edge_group_with_selection_func_send_message_with_target_no ctx = InProcRunnerContext() data = MockMessage(data="test") - message = Message(data=data, source_id=source.id, target_id=target2.id) + message = WorkflowMessage(data=data, source_id=source.id, target_id=target2.id) success = await edge_runner.send_message(message, state, ctx) assert success is False @@ -742,7 +744,7 @@ async def test_source_edge_group_with_selection_func_send_message_with_invalid_d ctx = InProcRunnerContext() data = "invalid_data" - message = Message(data=data, source_id=source.id) + message = WorkflowMessage(data=data, source_id=source.id) success = await edge_runner.send_message(message, state, ctx) assert success is False @@ -767,7 +769,7 @@ async def test_source_edge_group_with_selection_func_send_message_with_target_in ctx = InProcRunnerContext() data = "invalid_data" - message = Message(data=data, source_id=source.id, target_id=target1.id) + message = WorkflowMessage(data=data, source_id=source.id, target_id=target1.id) success = await edge_runner.send_message(message, state, ctx) assert success is False @@ -791,7 +793,9 @@ async def test_fan_out_edge_group_tracing_success(span_exporter) -> None: source_span_ids = ["00f067aa0ba902b7"] data = MockMessage(data="test") - message = Message(data=data, source_id=source.id, trace_contexts=trace_contexts, source_span_ids=source_span_ids) + message = WorkflowMessage( + data=data, source_id=source.id, trace_contexts=trace_contexts, source_span_ids=source_span_ids + ) # Clear any build spans span_exporter.clear() @@ -841,7 +845,7 @@ async def test_fan_out_edge_group_tracing_with_target(span_exporter) -> None: source_span_ids = ["00f067aa0ba902b7"] data = MockMessage(data="test") - message = Message( + message = WorkflowMessage( data=data, source_id=source.id, target_id=target1.id, @@ -927,7 +931,7 @@ async def test_target_edge_group_send_message_buffer() -> None: with patch("agent_framework._workflows._edge_runner.EdgeRunner._execute_on_target") as mock_send: success = await edge_runner.send_message( - Message(data=data, source_id=source1.id), + WorkflowMessage(data=data, source_id=source1.id), state, ctx, ) @@ -937,7 +941,7 @@ async def test_target_edge_group_send_message_buffer() -> None: assert len(edge_runner._buffer[source1.id]) == 1 # type: ignore success = await edge_runner.send_message( - Message(data=data, source_id=source2.id), + WorkflowMessage(data=data, source_id=source2.id), state, ctx, ) @@ -963,7 +967,7 @@ async def test_target_edge_group_send_message_with_invalid_target() -> None: ctx = InProcRunnerContext() data = MockMessage(data="test") - message = Message(data=data, source_id=source1.id, target_id="invalid_target") + message = WorkflowMessage(data=data, source_id=source1.id, target_id="invalid_target") success = await edge_runner.send_message(message, state, ctx) assert success is False @@ -984,7 +988,7 @@ async def test_target_edge_group_send_message_with_invalid_data() -> None: ctx = InProcRunnerContext() data = "invalid_data" - message = Message(data=data, source_id=source1.id) + message = WorkflowMessage(data=data, source_id=source1.id) success = await edge_runner.send_message(message, state, ctx) assert success is False @@ -1017,7 +1021,9 @@ async def test_fan_in_edge_group_tracing_buffered(span_exporter) -> None: # Send first message (should be buffered) success = await edge_runner.send_message( - Message(data=data, source_id=source1.id, trace_contexts=trace_contexts1, source_span_ids=source_span_ids1), + WorkflowMessage( + data=data, source_id=source1.id, trace_contexts=trace_contexts1, source_span_ids=source_span_ids1 + ), state, ctx, ) @@ -1049,7 +1055,9 @@ async def test_fan_in_edge_group_tracing_buffered(span_exporter) -> None: span_exporter.clear() success = await edge_runner.send_message( - Message(data=data, source_id=source2.id, trace_contexts=trace_contexts2, source_span_ids=source_span_ids2), + WorkflowMessage( + data=data, source_id=source2.id, trace_contexts=trace_contexts2, source_span_ids=source_span_ids2 + ), state, ctx, ) @@ -1093,7 +1101,7 @@ async def test_fan_in_edge_group_tracing_type_mismatch(span_exporter) -> None: # Send incompatible data type data = "invalid_data" - message = Message(data=data, source_id=source1.id) + message = WorkflowMessage(data=data, source_id=source1.id) # Clear any build spans span_exporter.clear() @@ -1130,7 +1138,7 @@ async def test_fan_in_edge_group_with_multiple_message_types() -> None: data = MockMessage(data="test") success = await edge_runner.send_message( - Message(data=data, source_id=source1.id), + WorkflowMessage(data=data, source_id=source1.id), state, ctx, ) @@ -1138,7 +1146,7 @@ async def test_fan_in_edge_group_with_multiple_message_types() -> None: data2 = MockMessageSecondary(data="test") success = await edge_runner.send_message( - Message(data=data2, source_id=source2.id), + WorkflowMessage(data=data2, source_id=source2.id), state, ctx, ) @@ -1161,7 +1169,7 @@ async def test_fan_in_edge_group_with_multiple_message_types_failed() -> None: data = MockMessage(data="test") success = await edge_runner.send_message( - Message(data=data, source_id=source1.id), + WorkflowMessage(data=data, source_id=source1.id), state, ctx, ) @@ -1175,7 +1183,7 @@ async def test_fan_in_edge_group_with_multiple_message_types_failed() -> None: # source executors as a union. data2 = MockMessageSecondary(data="test") _ = await edge_runner.send_message( - Message(data=data2, source_id=source2.id), + WorkflowMessage(data=data2, source_id=source2.id), state, ctx, ) @@ -1275,7 +1283,7 @@ async def test_switch_case_edge_group_send_message() -> None: ctx = InProcRunnerContext() data = MockMessage(data=-1) - message = Message(data=data, source_id=source.id) + message = WorkflowMessage(data=data, source_id=source.id) with patch("agent_framework._workflows._edge_runner.EdgeRunner._execute_on_target") as mock_send: success = await edge_runner.send_message(message, state, ctx) @@ -1285,7 +1293,7 @@ async def test_switch_case_edge_group_send_message() -> None: # Default condition should data = MockMessage(data=1) - message = Message(data=data, source_id=source.id) + message = WorkflowMessage(data=data, source_id=source.id) with patch("agent_framework._workflows._edge_runner.EdgeRunner._execute_on_target") as mock_send: success = await edge_runner.send_message(message, state, ctx) @@ -1314,7 +1322,7 @@ async def test_switch_case_edge_group_send_message_with_invalid_target() -> None ctx = InProcRunnerContext() data = MockMessage(data=-1) - message = Message(data=data, source_id=source.id, target_id="invalid_target") + message = WorkflowMessage(data=data, source_id=source.id, target_id="invalid_target") success = await edge_runner.send_message(message, state, ctx) assert success is False @@ -1341,13 +1349,13 @@ async def test_switch_case_edge_group_send_message_with_valid_target() -> None: ctx = InProcRunnerContext() data = MockMessage(data=1) # Condition will fail - message = Message(data=data, source_id=source.id, target_id=target1.id) + message = WorkflowMessage(data=data, source_id=source.id, target_id=target1.id) success = await edge_runner.send_message(message, state, ctx) assert success is False data = MockMessage(data=-1) # Condition will pass - message = Message(data=data, source_id=source.id, target_id=target1.id) + message = WorkflowMessage(data=data, source_id=source.id, target_id=target1.id) success = await edge_runner.send_message(message, state, ctx) assert success is True @@ -1373,7 +1381,7 @@ async def test_switch_case_edge_group_send_message_with_invalid_data() -> None: ctx = InProcRunnerContext() data = "invalid_data" - message = Message(data=data, source_id=source.id) + message = WorkflowMessage(data=data, source_id=source.id) success = await edge_runner.send_message(message, state, ctx) assert success is False diff --git a/python/packages/core/tests/workflow/test_executor.py b/python/packages/core/tests/workflow/test_executor.py index 507b798e96..06d027f19d 100644 --- a/python/packages/core/tests/workflow/test_executor.py +++ b/python/packages/core/tests/workflow/test_executor.py @@ -6,12 +6,12 @@ import pytest from typing_extensions import Never from agent_framework import ( - ChatMessage, Executor, Message, WorkflowBuilder, WorkflowContext, WorkflowEvent, + WorkflowMessage, executor, handler, response_handler, @@ -98,9 +98,9 @@ def test_executor_with_valid_handlers(): executor = MockExecutorWithValidHandlers(id="test") assert executor.id is not None assert len(executor._handlers) == 2 # type: ignore - assert executor.can_handle(Message(data="text", source_id="mock")) is True - assert executor.can_handle(Message(data=42, source_id="mock")) is True - assert executor.can_handle(Message(data=3.14, source_id="mock")) is False + assert executor.can_handle(WorkflowMessage(data="text", source_id="mock")) is True + assert executor.can_handle(WorkflowMessage(data=42, source_id="mock")) is True + assert executor.can_handle(WorkflowMessage(data=3.14, source_id="mock")) is False def test_executor_handlers_with_output_types(): @@ -531,10 +531,10 @@ async def test_executor_invoked_event_data_not_mutated_by_handler(): """Test that executor_invoked event (type='executor_invoked').data captures original input, not mutated input.""" @executor(id="Mutator") - async def mutator(messages: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None: + async def mutator(messages: list[Message], ctx: WorkflowContext[list[Message]]) -> None: # The handler mutates the input list by appending new messages original_len = len(messages) - messages.append(ChatMessage(role="assistant", text="Added by executor")) + messages.append(Message(role="assistant", text="Added by executor")) await ctx.send_message(messages) # Verify mutation happened assert len(messages) == original_len + 1 @@ -542,7 +542,7 @@ async def test_executor_invoked_event_data_not_mutated_by_handler(): workflow = WorkflowBuilder(start_executor=mutator).build() # Run with a single user message - input_messages = [ChatMessage(role="user", text="hello")] + input_messages = [Message(role="user", text="hello")] events = await workflow.run(input_messages) # Find the invoked event for the Mutator executor @@ -581,9 +581,9 @@ class TestHandlerExplicitTypes: assert len(exec_instance._handlers) == 1 # Can handle str messages - assert exec_instance.can_handle(Message(data="hello", source_id="mock")) + assert exec_instance.can_handle(WorkflowMessage(data="hello", source_id="mock")) # Cannot handle int messages (since explicit type is str) - assert not exec_instance.can_handle(Message(data=42, source_id="mock")) + assert not exec_instance.can_handle(WorkflowMessage(data=42, source_id="mock")) def test_handler_with_explicit_output_type(self): """Test that explicit output works when input is also specified.""" @@ -623,8 +623,8 @@ class TestHandlerExplicitTypes: assert handler_func._handler_spec["output_types"] == [list] # Verify can_handle - assert exec_instance.can_handle(Message(data={"key": "value"}, source_id="mock")) - assert not exec_instance.can_handle(Message(data="string", source_id="mock")) + assert exec_instance.can_handle(WorkflowMessage(data={"key": "value"}, source_id="mock")) + assert not exec_instance.can_handle(WorkflowMessage(data="string", source_id="mock")) def test_handler_with_explicit_union_input_type(self): """Test that explicit union input_type is handled correctly.""" @@ -642,10 +642,10 @@ class TestHandlerExplicitTypes: assert len(exec_instance._handlers) == 1 # Can handle both str and int messages - assert exec_instance.can_handle(Message(data="hello", source_id="mock")) - assert exec_instance.can_handle(Message(data=42, source_id="mock")) + assert exec_instance.can_handle(WorkflowMessage(data="hello", source_id="mock")) + assert exec_instance.can_handle(WorkflowMessage(data=42, source_id="mock")) # Cannot handle float - assert not exec_instance.can_handle(Message(data=3.14, source_id="mock")) + assert not exec_instance.can_handle(WorkflowMessage(data=3.14, source_id="mock")) def test_handler_with_explicit_union_output_type(self): """Test that explicit union output is normalized to a list.""" @@ -736,7 +736,7 @@ class TestHandlerExplicitTypes: # Should work with explicit input_type assert str in exec_instance._handlers - assert exec_instance.can_handle(Message(data="hello", source_id="mock")) + assert exec_instance.can_handle(WorkflowMessage(data="hello", source_id="mock")) def test_handler_multiple_handlers_mixed_explicit_and_introspected(self): """Test executor with multiple handlers, some with explicit types and some introspected.""" @@ -773,7 +773,7 @@ class TestHandlerExplicitTypes: # Should resolve the string to the actual type assert ForwardRefMessage in exec_instance._handlers - assert exec_instance.can_handle(Message(data=ForwardRefMessage("hello"), source_id="mock")) + assert exec_instance.can_handle(WorkflowMessage(data=ForwardRefMessage("hello"), source_id="mock")) def test_handler_with_string_forward_reference_union(self): """Test that string forward references work with union types.""" @@ -786,8 +786,8 @@ class TestHandlerExplicitTypes: exec_instance = StringUnionExecutor(id="string_union") # Should handle both types - assert exec_instance.can_handle(Message(data=ForwardRefTypeA("hello"), source_id="mock")) - assert exec_instance.can_handle(Message(data=ForwardRefTypeB(42), source_id="mock")) + assert exec_instance.can_handle(WorkflowMessage(data=ForwardRefTypeA("hello"), source_id="mock")) + assert exec_instance.can_handle(WorkflowMessage(data=ForwardRefTypeB(42), source_id="mock")) def test_handler_with_string_forward_reference_output_type(self): """Test that string forward references work for output_type.""" @@ -851,7 +851,7 @@ class TestHandlerExplicitTypes: # Check input type assert str in exec_instance._handlers - assert exec_instance.can_handle(Message(data="hello", source_id="mock")) + assert exec_instance.can_handle(WorkflowMessage(data="hello", source_id="mock")) # Check output_type assert int in exec_instance.output_types diff --git a/python/packages/core/tests/workflow/test_full_conversation.py b/python/packages/core/tests/workflow/test_full_conversation.py index c29dd61fe5..80a10347b6 100644 --- a/python/packages/core/tests/workflow/test_full_conversation.py +++ b/python/packages/core/tests/workflow/test_full_conversation.py @@ -8,14 +8,15 @@ from typing_extensions import Never from agent_framework import ( AgentExecutor, + AgentExecutorRequest, AgentExecutorResponse, AgentResponse, AgentResponseUpdate, - AgentThread, + AgentSession, BaseAgent, - ChatMessage, Content, Executor, + Message, ResponseStream, WorkflowBuilder, WorkflowContext, @@ -34,10 +35,10 @@ class _SimpleAgent(BaseAgent): def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: if stream: @@ -48,7 +49,7 @@ class _SimpleAgent(BaseAgent): return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) async def _run() -> AgentResponse: - return AgentResponse(messages=[ChatMessage("assistant", [self._reply_text])]) + return AgentResponse(messages=[Message("assistant", [self._reply_text])]) return _run() @@ -96,7 +97,7 @@ async def test_agent_executor_populates_full_conversation_non_streaming() -> Non class _CaptureAgent(BaseAgent): """Streaming-capable agent that records the messages it received.""" - _last_messages: list[ChatMessage] = PrivateAttr(default_factory=list) # type: ignore + _last_messages: list[Message] = PrivateAttr(default_factory=list) # type: ignore def __init__(self, *, reply_text: str, **kwargs: Any) -> None: super().__init__(**kwargs) @@ -104,20 +105,20 @@ class _CaptureAgent(BaseAgent): def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: # Normalize and record messages for verification - norm: list[ChatMessage] = [] + norm: list[Message] = [] if messages: for m in messages: # type: ignore[iteration-over-optional] - if isinstance(m, ChatMessage): + if isinstance(m, Message): norm.append(m) elif isinstance(m, str): - norm.append(ChatMessage("user", [m])) + norm.append(Message("user", [m])) self._last_messages = norm if stream: @@ -128,7 +129,7 @@ class _CaptureAgent(BaseAgent): return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) async def _run() -> AgentResponse: - return AgentResponse(messages=[ChatMessage("assistant", [self._reply_text])]) + return AgentResponse(messages=[Message("assistant", [self._reply_text])]) return _run() @@ -150,3 +151,64 @@ async def test_sequential_adapter_uses_full_conversation() -> None: assert len(seen) == 2 assert seen[0].role == "user" and "hello seq" in (seen[0].text or "") assert seen[1].role == "assistant" and "A1 reply" in (seen[1].text or "") + + +class _RoundTripCoordinator(Executor): + """Loops once back to the same agent with full conversation + feedback.""" + + def __init__(self, *, target_agent_id: str, id: str = "round_trip_coordinator") -> None: + super().__init__(id=id) + self._target_agent_id = target_agent_id + self._seen = 0 + + @handler + async def handle_response( + self, + response: AgentExecutorResponse, + ctx: WorkflowContext[Never, dict[str, Any]], + ) -> None: + self._seen += 1 + if self._seen == 1: + assert response.full_conversation is not None + await ctx.send_message( + AgentExecutorRequest( + messages=list(response.full_conversation) + [Message(role="user", text="apply feedback")], + should_respond=True, + ), + target_id=self._target_agent_id, + ) + return + + assert response.full_conversation is not None + await ctx.yield_output({ + "roles": [m.role for m in response.full_conversation], + "texts": [m.text for m in response.full_conversation], + }) + + +async def test_agent_executor_full_conversation_round_trip_does_not_duplicate_history() -> None: + """When full history is replayed, AgentExecutor should not duplicate prior turns.""" + agent = _SimpleAgent(id="writer_agent", name="Writer", reply_text="draft reply") + agent_exec = AgentExecutor(agent, id="writer_agent") + coordinator = _RoundTripCoordinator(target_agent_id="writer_agent") + + wf = ( + WorkflowBuilder(start_executor=agent_exec, output_executors=[coordinator]) + .add_edge(agent_exec, coordinator) + .add_edge(coordinator, agent_exec) + .build() + ) + + result = await wf.run("initial prompt") + outputs = result.get_outputs() + assert len(outputs) == 1 + payload = outputs[0] + assert isinstance(payload, dict) + + # Expected conversation after one loop: + # user(initial), assistant(first reply), user(feedback), assistant(second reply) + assert payload["roles"] == ["user", "assistant", "user", "assistant"] + assert payload["texts"][0] == "initial prompt" + assert payload["texts"][1] == "draft reply" + assert payload["texts"][2] == "apply feedback" + assert payload["texts"][3] == "draft reply" diff --git a/python/packages/core/tests/workflow/test_function_executor.py b/python/packages/core/tests/workflow/test_function_executor.py index 3d274f8cd7..c0b73156ff 100644 --- a/python/packages/core/tests/workflow/test_function_executor.py +++ b/python/packages/core/tests/workflow/test_function_executor.py @@ -8,9 +8,9 @@ from typing_extensions import Never from agent_framework import ( FunctionExecutor, - Message, WorkflowBuilder, WorkflowContext, + WorkflowMessage, executor, ) @@ -253,9 +253,9 @@ class TestFunctionExecutor: async def string_processor(text: str, ctx: WorkflowContext[str]) -> None: await ctx.send_message(text) - assert string_processor.can_handle(Message(data="hello", source_id="Mock")) - assert not string_processor.can_handle(Message(data=123, source_id="Mock")) - assert not string_processor.can_handle(Message(data=[], source_id="Mock")) + assert string_processor.can_handle(WorkflowMessage(data="hello", source_id="Mock")) + assert not string_processor.can_handle(WorkflowMessage(data=123, source_id="Mock")) + assert not string_processor.can_handle(WorkflowMessage(data=[], source_id="Mock")) def test_duplicate_handler_registration(self): """Test that registering duplicate handlers raises an error.""" @@ -332,9 +332,9 @@ class TestFunctionExecutor: async def int_processor(value: int): return value * 2 - assert int_processor.can_handle(Message(data=42, source_id="mock")) - assert not int_processor.can_handle(Message(data="hello", source_id="mock")) - assert not int_processor.can_handle(Message(data=[], source_id="mock")) + assert int_processor.can_handle(WorkflowMessage(data=42, source_id="mock")) + assert not int_processor.can_handle(WorkflowMessage(data="hello", source_id="mock")) + assert not int_processor.can_handle(WorkflowMessage(data=[], source_id="mock")) async def test_single_parameter_execution(self): """Test that single-parameter functions can be executed properly.""" @@ -348,7 +348,7 @@ class TestFunctionExecutor: WorkflowBuilder(start_executor=double_value).build() # For testing purposes, we can check that the handler is registered correctly - assert double_value.can_handle(Message(data=5, source_id="mock")) + assert double_value.can_handle(WorkflowMessage(data=5, source_id="mock")) assert int in double_value._handlers def test_sync_function_basic(self): @@ -392,9 +392,9 @@ class TestFunctionExecutor: def string_handler(text: str): return text.strip() - assert string_handler.can_handle(Message(data="hello", source_id="mock")) - assert not string_handler.can_handle(Message(data=123, source_id="mock")) - assert not string_handler.can_handle(Message(data=[], source_id="mock")) + assert string_handler.can_handle(WorkflowMessage(data="hello", source_id="mock")) + assert not string_handler.can_handle(WorkflowMessage(data=123, source_id="mock")) + assert not string_handler.can_handle(WorkflowMessage(data=[], source_id="mock")) def test_sync_function_validation(self): """Test validation for synchronous functions.""" @@ -436,8 +436,8 @@ class TestFunctionExecutor: assert isinstance(async_func, FunctionExecutor) # Both should handle strings - assert sync_func.can_handle(Message(data="test", source_id="mock")) - assert async_func.can_handle(Message(data="test", source_id="mock")) + assert sync_func.can_handle(WorkflowMessage(data="test", source_id="mock")) + assert async_func.can_handle(WorkflowMessage(data="test", source_id="mock")) # Both should be different instances assert sync_func is not async_func @@ -466,8 +466,8 @@ class TestFunctionExecutor: assert async_spec["workflow_output_types"] == [str] # Second parameter is str # Verify the executors can handle their input types - assert to_upper_sync.can_handle(Message(data="hello", source_id="mock")) - assert reverse_async.can_handle(Message(data="HELLO", source_id="mock")) + assert to_upper_sync.can_handle(WorkflowMessage(data="hello", source_id="mock")) + assert reverse_async.can_handle(WorkflowMessage(data="HELLO", source_id="mock")) # For integration testing, we mainly verify that the handlers are properly registered # and the functions are wrapped correctly @@ -574,9 +574,9 @@ class TestExecutorExplicitTypes: assert len(process._handlers) == 1 # Can handle str messages - assert process.can_handle(Message(data="hello", source_id="mock")) + assert process.can_handle(WorkflowMessage(data="hello", source_id="mock")) # Cannot handle int messages - assert not process.can_handle(Message(data=42, source_id="mock")) + assert not process.can_handle(WorkflowMessage(data=42, source_id="mock")) def test_executor_with_explicit_output_type(self): """Test that explicit output_type takes precedence over introspection.""" @@ -609,8 +609,8 @@ class TestExecutorExplicitTypes: assert spec["output_types"] == [list] # Verify can_handle - assert process.can_handle(Message(data={"key": "value"}, source_id="mock")) - assert not process.can_handle(Message(data="string", source_id="mock")) + assert process.can_handle(WorkflowMessage(data={"key": "value"}, source_id="mock")) + assert not process.can_handle(WorkflowMessage(data="string", source_id="mock")) def test_executor_with_explicit_union_input_type(self): """Test that explicit union input_type is handled correctly.""" @@ -623,10 +623,10 @@ class TestExecutorExplicitTypes: assert len(process._handlers) == 1 # Can handle both str and int messages - assert process.can_handle(Message(data="hello", source_id="mock")) - assert process.can_handle(Message(data=42, source_id="mock")) + assert process.can_handle(WorkflowMessage(data="hello", source_id="mock")) + assert process.can_handle(WorkflowMessage(data=42, source_id="mock")) # Cannot handle float - assert not process.can_handle(Message(data=3.14, source_id="mock")) + assert not process.can_handle(WorkflowMessage(data=3.14, source_id="mock")) def test_executor_with_explicit_union_output_type(self): """Test that explicit union output_type is normalized to a list.""" @@ -695,7 +695,7 @@ class TestExecutorExplicitTypes: # Should work with explicit input_type assert str in process._handlers - assert process.can_handle(Message(data="hello", source_id="mock")) + assert process.can_handle(WorkflowMessage(data="hello", source_id="mock")) def test_executor_explicit_types_with_id(self): """Test that explicit types work together with id parameter.""" @@ -717,8 +717,8 @@ class TestExecutorExplicitTypes: # Should work with explicit input_type assert str in process._handlers - assert process.can_handle(Message(data="hello", source_id="mock")) - assert not process.can_handle(Message(data=42, source_id="mock")) + assert process.can_handle(WorkflowMessage(data="hello", source_id="mock")) + assert not process.can_handle(WorkflowMessage(data=42, source_id="mock")) def test_executor_explicit_types_with_sync_function(self): """Test that explicit types work with synchronous functions.""" @@ -752,8 +752,8 @@ class TestExecutorExplicitTypes: pass # Can handle both str and int - assert process.can_handle(Message(data="hello", source_id="mock")) - assert process.can_handle(Message(data=42, source_id="mock")) + assert process.can_handle(WorkflowMessage(data="hello", source_id="mock")) + assert process.can_handle(WorkflowMessage(data=42, source_id="mock")) # Output types should include both assert set(process.output_types) == {bool, float} @@ -767,7 +767,7 @@ class TestExecutorExplicitTypes: # Should resolve the string to the actual type assert FuncExecForwardRefMessage in process._handlers - assert process.can_handle(Message(data=FuncExecForwardRefMessage("hello"), source_id="mock")) + assert process.can_handle(WorkflowMessage(data=FuncExecForwardRefMessage("hello"), source_id="mock")) def test_executor_with_string_forward_reference_union(self): """Test that string forward references work with union types.""" @@ -777,8 +777,8 @@ class TestExecutorExplicitTypes: pass # Should handle both types - assert process.can_handle(Message(data=FuncExecForwardRefTypeA("hello"), source_id="mock")) - assert process.can_handle(Message(data=FuncExecForwardRefTypeB(42), source_id="mock")) + assert process.can_handle(WorkflowMessage(data=FuncExecForwardRefTypeA("hello"), source_id="mock")) + assert process.can_handle(WorkflowMessage(data=FuncExecForwardRefTypeB(42), source_id="mock")) def test_executor_with_string_forward_reference_output_type(self): """Test that string forward references work for output_type.""" @@ -827,7 +827,7 @@ class TestExecutorExplicitTypes: # Check input type assert str in process._handlers - assert process.can_handle(Message(data="hello", source_id="mock")) + assert process.can_handle(WorkflowMessage(data="hello", source_id="mock")) # Check output_type assert int in process.output_types 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..9400084692 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, # type: ignore + 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 7af722e45a..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,12 +20,13 @@ 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 ( InProcRunnerContext, - Message, RunnerContext, + WorkflowMessage, ) from agent_framework._workflows._state import State @@ -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,10 +188,10 @@ 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( - Message( + WorkflowMessage( data=AgentExecutorResponse("agent", AgentResponse()), source_id="agent", ) @@ -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_typing_utils.py b/python/packages/core/tests/workflow/test_typing_utils.py index ab483e05e9..4dc8d8c917 100644 --- a/python/packages/core/tests/workflow/test_typing_utils.py +++ b/python/packages/core/tests/workflow/test_typing_utils.py @@ -378,12 +378,12 @@ def test_type_compatibility_collections() -> None: # List compatibility - key use case @dataclass - class ChatMessage: + class Message: text: str - assert is_type_compatible(list[ChatMessage], list[Union[str, ChatMessage]]) - assert is_type_compatible(list[str], list[Union[str, ChatMessage]]) - assert not is_type_compatible(list[Union[str, ChatMessage]], list[ChatMessage]) + assert is_type_compatible(list[Message], list[Union[str, Message]]) + assert is_type_compatible(list[str], list[Union[str, Message]]) + assert not is_type_compatible(list[Union[str, Message]], list[Message]) # Dict compatibility assert is_type_compatible(dict[str, int], dict[str, Union[int, float]]) diff --git a/python/packages/core/tests/workflow/test_workflow.py b/python/packages/core/tests/workflow/test_workflow.py index 1e98ff08c5..fb90df6b39 100644 --- a/python/packages/core/tests/workflow/test_workflow.py +++ b/python/packages/core/tests/workflow/test_workflow.py @@ -13,9 +13,8 @@ from agent_framework import ( AgentExecutor, AgentResponse, AgentResponseUpdate, - AgentThread, + AgentSession, BaseAgent, - ChatMessage, Content, Executor, FileCheckpointStorage, @@ -26,6 +25,7 @@ from agent_framework import ( WorkflowContext, WorkflowConvergenceException, WorkflowEvent, + WorkflowMessage, WorkflowRunState, handler, response_handler, @@ -275,7 +275,7 @@ async def test_workflow_with_checkpointing_enabled(simple_executor: Executor): ) # Verify workflow was created and can run - test_message = Message(data="test message", source_id="test", target_id=None) + test_message = WorkflowMessage(data="test message", source_id="test", target_id=None) result = await workflow.run(test_message) assert result is not None @@ -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] = [] @@ -536,13 +538,13 @@ async def test_workflow_checkpoint_runtime_only_configuration( workflow = WorkflowBuilder(start_executor=simple_executor).add_edge(simple_executor, simple_executor).build() # Run with runtime checkpoint storage - should create checkpoints - test_message = Message(data="runtime checkpoint test", source_id="test", target_id=None) + test_message = WorkflowMessage(data="runtime checkpoint test", source_id="test", target_id=None) result = await workflow.run(test_message, checkpoint_storage=storage) assert result is not None 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 @@ -587,13 +589,13 @@ async def test_workflow_checkpoint_runtime_overrides_buildtime( ) # Run with runtime checkpoint storage override - test_message = Message(data="override test", source_id="test", target_id=None) + test_message = WorkflowMessage(data="override test", source_id="test", target_id=None) result = await workflow.run(test_message, checkpoint_storage=runtime_storage) 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" @@ -833,10 +835,10 @@ class _StreamingTestAgent(BaseAgent): def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: if stream: @@ -849,7 +851,7 @@ class _StreamingTestAgent(BaseAgent): return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) async def _run() -> AgentResponse: - return AgentResponse(messages=[ChatMessage("assistant", [self._reply_text])]) + return AgentResponse(messages=[Message("assistant", [self._reply_text])]) return _run() @@ -911,7 +913,7 @@ async def test_workflow_run_parameter_validation(simple_executor: Executor) -> N """Test that stream properly validate parameter combinations.""" workflow = WorkflowBuilder(start_executor=simple_executor).add_edge(simple_executor, simple_executor).build() - test_message = Message(data="test", source_id="test", target_id=None) + test_message = WorkflowMessage(data="test", source_id="test", target_id=None) # Valid: message only (new run) result = await workflow.run(test_message) @@ -942,7 +944,7 @@ async def test_workflow_run_stream_parameter_validation( """Test stream=True specific parameter validation scenarios.""" workflow = WorkflowBuilder(start_executor=simple_executor).add_edge(simple_executor, simple_executor).build() - test_message = Message(data="test", source_id="test", target_id=None) + test_message = WorkflowMessage(data="test", source_id="test", target_id=None) # Valid: message only (new run) events: list[WorkflowEvent] = [] diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index c121f369fa..5adf82dd57 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -11,11 +11,10 @@ from agent_framework import ( AgentExecutorRequest, AgentResponse, AgentResponseUpdate, - AgentThread, - ChatMessage, - ChatMessageStore, + AgentSession, Content, Executor, + Message, ResponseStream, SupportsAgentRun, UsageDetails, @@ -39,14 +38,14 @@ class SimpleExecutor(Executor): @handler async def handle_message( self, - message: list[ChatMessage], - ctx: WorkflowContext[list[ChatMessage], AgentResponseUpdate | AgentResponse], + message: list[Message], + ctx: WorkflowContext[list[Message], AgentResponseUpdate | AgentResponse], ) -> None: input_text = message[0].contents[0].text if message and message[0].contents[0].type == "text" else "no input" response_text = f"{self.response_text}: {input_text}" # Create response message for both streaming and non-streaming cases - response_message = ChatMessage(role="assistant", contents=[Content.from_text(text=response_text)]) + response_message = Message(role="assistant", contents=[Content.from_text(text=response_text)]) if self.streaming: # Emit update event. @@ -70,7 +69,7 @@ class RequestingExecutor(Executor): self.streaming = streaming @handler - async def handle_message(self, _: list[ChatMessage], ctx: WorkflowContext) -> None: + async def handle_message(self, _: list[Message], ctx: WorkflowContext) -> None: # Send a RequestInfoMessage to trigger the request info process await ctx.request_info("Mock request data", str) @@ -79,7 +78,7 @@ class RequestingExecutor(Executor): self, original_request: str, response: str, - ctx: WorkflowContext[ChatMessage, AgentResponseUpdate | AgentResponse], + ctx: WorkflowContext[Message, AgentResponseUpdate | AgentResponse], ) -> None: # Handle the response and emit completion response content = Content.from_text(text=f"Request completed with response: {response}") @@ -96,7 +95,7 @@ class RequestingExecutor(Executor): await ctx.yield_output( AgentResponse( messages=[ - ChatMessage( + Message( role="assistant", contents=[content], ) @@ -110,14 +109,14 @@ class ConversationHistoryCapturingExecutor(Executor): def __init__(self, id: str, streaming: bool = False): super().__init__(id=id) - self.received_messages: list[ChatMessage] = [] + self.received_messages: list[Message] = [] self.streaming = streaming @handler async def handle_message( self, - messages: list[ChatMessage], - ctx: WorkflowContext[list[ChatMessage], AgentResponseUpdate | AgentResponse], + messages: list[Message], + ctx: WorkflowContext[list[Message], AgentResponseUpdate | AgentResponse], ) -> None: # Capture all received messages self.received_messages = list(messages) @@ -126,7 +125,7 @@ class ConversationHistoryCapturingExecutor(Executor): message_count = len(messages) response_text = f"Received {message_count} messages" - response_message = ChatMessage(role="assistant", contents=[Content.from_text(text=response_text)]) + response_message = Message(role="assistant", contents=[Content.from_text(text=response_text)]) if self.streaming: # Emit streaming update @@ -162,8 +161,8 @@ class TestWorkflowAgent: assert len(result.messages) >= 2, f"Expected at least 2 messages, got {len(result.messages)}" # Find messages from each executor - step1_messages: list[ChatMessage] = [] - step2_messages: list[ChatMessage] = [] + step1_messages: list[Message] = [] + step2_messages: list[Message] = [] for message in result.messages: first_content = message.contents[0] @@ -281,7 +280,7 @@ class TestWorkflowAgent: ), ) - response_message = ChatMessage(role="user", contents=[approval_response]) + response_message = Message(role="user", contents=[approval_response]) # Continue the workflow with the response continuation_result = await agent.run(response_message) @@ -325,7 +324,7 @@ class TestWorkflowAgent: workflow = WorkflowBuilder(start_executor=executor).build() # Try to create an agent with unsupported input types - with pytest.raises(ValueError, match="Workflow's start executor cannot handle list\\[ChatMessage\\]"): + with pytest.raises(ValueError, match="Workflow's start executor cannot handle list\\[Message\\]"): workflow.as_agent() async def test_workflow_as_agent_yield_output_surfaces_as_agent_response(self) -> None: @@ -336,7 +335,7 @@ class TestWorkflowAgent: """ @executor - async def yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext[Never, str]) -> None: + async def yielding_executor(messages: list[Message], ctx: WorkflowContext[Never, str]) -> None: # Extract text from input for demonstration input_text = messages[0].text if messages else "no input" await ctx.yield_output(f"processed: {input_text}") @@ -344,7 +343,7 @@ class TestWorkflowAgent: workflow = WorkflowBuilder(start_executor=yielding_executor).build() # Run directly - should return output event (type='output') in result - direct_result = await workflow.run([ChatMessage(role="user", text="hello")]) + direct_result = await workflow.run([Message(role="user", text="hello")]) direct_outputs = direct_result.get_outputs() assert len(direct_outputs) == 1 assert direct_outputs[0] == "processed: hello" @@ -361,7 +360,7 @@ class TestWorkflowAgent: """Test that ctx.yield_output() surfaces as AgentResponseUpdate when streaming.""" @executor - async def yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext[Never, str]) -> None: + async def yielding_executor(messages: list[Message], ctx: WorkflowContext[Never, str]) -> None: await ctx.yield_output("first output") await ctx.yield_output("second output") @@ -381,7 +380,7 @@ class TestWorkflowAgent: """Test that yield_output preserves different content types (Content, Content, etc.).""" @executor - async def content_yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext[Never, Content]) -> None: + async def content_yielding_executor(messages: list[Message], ctx: WorkflowContext[Never, Content]) -> None: # Yield different content types await ctx.yield_output(Content.from_text(text="text content")) await ctx.yield_output(Content.from_data(data=b"binary data", media_type="application/octet-stream")) @@ -406,11 +405,11 @@ class TestWorkflowAgent: assert result.messages[2].contents[0].uri == "https://example.com/image.png" async def test_workflow_as_agent_yield_output_with_chat_message(self) -> None: - """Test that yield_output with ChatMessage preserves the message structure.""" + """Test that yield_output with Message preserves the message structure.""" @executor - async def chat_message_executor(messages: list[ChatMessage], ctx: WorkflowContext[Never, ChatMessage]) -> None: - msg = ChatMessage( + async def chat_message_executor(messages: list[Message], ctx: WorkflowContext[Never, Message]) -> None: + msg = Message( role="assistant", contents=[Content.from_text(text="response text")], author_name="custom-author", @@ -440,7 +439,7 @@ class TestWorkflowAgent: @executor async def raw_yielding_executor( - messages: list[ChatMessage], ctx: WorkflowContext[Never, Content | CustomData | str] + messages: list[Message], ctx: WorkflowContext[Never, Content | CustomData | str] ) -> None: # Yield different types of data await ctx.yield_output("simple string") @@ -469,21 +468,19 @@ class TestWorkflowAgent: assert updates[2].raw_representation.value == 42 async def test_workflow_as_agent_yield_output_with_list_of_chat_messages(self) -> None: - """Test that yield_output with list[ChatMessage] extracts contents from all messages. + """Test that yield_output with list[Message] extracts contents from all messages. Note: Content items are coalesced by _finalize_response, so multiple text contents become a single merged Content in the final response. """ @executor - async def list_yielding_executor( - messages: list[ChatMessage], ctx: WorkflowContext[Never, list[ChatMessage]] - ) -> None: - # Yield a list of ChatMessages (as SequentialBuilder does) + async def list_yielding_executor(messages: list[Message], ctx: WorkflowContext[Never, list[Message]]) -> None: + # Yield a list of Messages (as SequentialBuilder does) msg_list = [ - ChatMessage(role="user", text="first message"), - ChatMessage(role="assistant", text="second message"), - ChatMessage( + Message(role="user", text="first message"), + Message(role="assistant", text="second message"), + Message( role="assistant", contents=[Content.from_text(text="third"), Content.from_text(text="fourth")], ), @@ -513,80 +510,53 @@ class TestWorkflowAgent: texts = [message.text for message in result.messages] assert texts == ["first message", "second message", "third fourth"] - async def test_thread_conversation_history_included_in_workflow_run(self) -> None: - """Test that conversation history from thread is included when running WorkflowAgent. - - This verifies that when a thread with existing messages is provided to agent.run(), - the workflow receives the complete conversation history (thread history + new messages). - """ + async def test_session_conversation_history_included_in_workflow_run(self) -> None: + """Test that messages provided to agent.run() are passed through to the workflow.""" # Create an executor that captures all received messages capturing_executor = ConversationHistoryCapturingExecutor(id="capturing", streaming=False) workflow = WorkflowBuilder(start_executor=capturing_executor).build() - agent = WorkflowAgent(workflow=workflow, name="Thread History Test Agent") + agent = WorkflowAgent(workflow=workflow, name="Session History Test Agent") - # Create a thread with existing conversation history - history_messages = [ - ChatMessage(role="user", text="Previous user message"), - ChatMessage(role="assistant", text="Previous assistant response"), - ] - message_store = ChatMessageStore(messages=history_messages) - thread = AgentThread(message_store=message_store) + # Create a session + session = AgentSession() - # Run the agent with the thread and a new message + # Run the agent with the session and a new message new_message = "New user question" - await agent.run(new_message, thread=thread) + await agent.run(new_message, session=session) - # Verify the executor received both history AND new message - assert len(capturing_executor.received_messages) == 3 + # Verify the executor received the message + assert len(capturing_executor.received_messages) == 1 + assert capturing_executor.received_messages[0].text == "New user question" - # Verify the order: history first, then new message - assert capturing_executor.received_messages[0].text == "Previous user message" - assert capturing_executor.received_messages[1].text == "Previous assistant response" - assert capturing_executor.received_messages[2].text == "New user question" - - async def test_thread_conversation_history_included_in_workflow_stream(self) -> None: - """Test that conversation history from thread is included when streaming WorkflowAgent. - - This verifies that stream=True also includes thread history. - """ + async def test_session_conversation_history_included_in_workflow_stream(self) -> None: + """Test that messages provided to agent.run() are passed through when streaming WorkflowAgent.""" # Create an executor that captures all received messages capturing_executor = ConversationHistoryCapturingExecutor(id="capturing_stream") workflow = WorkflowBuilder(start_executor=capturing_executor).build() - agent = WorkflowAgent(workflow=workflow, name="Thread Stream Test Agent") + agent = WorkflowAgent(workflow=workflow, name="Session Stream Test Agent") - # Create a thread with existing conversation history - history_messages = [ - ChatMessage(role="system", text="You are a helpful assistant"), - ChatMessage(role="user", text="Hello"), - ChatMessage("assistant", ["Hi there!"]), - ] - message_store = ChatMessageStore(messages=history_messages) - thread = AgentThread(message_store=message_store) + # Create a session + session = AgentSession() - # Stream from the agent with the thread and a new message - async for _ in agent.run("How are you?", stream=True, thread=thread): + # Stream from the agent with the session and a new message + async for _ in agent.run("How are you?", stream=True, session=session): pass - # Verify the executor received all messages (3 from history + 1 new) - assert len(capturing_executor.received_messages) == 4 + # Verify the executor received the message + assert len(capturing_executor.received_messages) == 1 + assert capturing_executor.received_messages[0].text == "How are you?" - # Verify the order - assert capturing_executor.received_messages[0].text == "You are a helpful assistant" - assert capturing_executor.received_messages[1].text == "Hello" - assert capturing_executor.received_messages[2].text == "Hi there!" - assert capturing_executor.received_messages[3].text == "How are you?" - - async def test_empty_thread_works_correctly(self) -> None: - """Test that an empty thread (no message store) works correctly.""" - capturing_executor = ConversationHistoryCapturingExecutor(id="empty_thread_test") + async def test_empty_session_works_correctly(self) -> None: + """Test that an empty session (no message store) works correctly.""" + capturing_executor = ConversationHistoryCapturingExecutor(id="empty_session_test") workflow = WorkflowBuilder(start_executor=capturing_executor).build() - agent = WorkflowAgent(workflow=workflow, name="Empty Thread Test Agent") + agent = WorkflowAgent(workflow=workflow, name="Empty Session Test Agent") - # Create an empty thread - thread = AgentThread() + # Create an empty session + session = AgentSession() - # Run with the empty thread - await agent.run("Just a new message", thread=thread) + # Run with the empty session + await agent.run("Just a new message", session=session) # Should only receive the new message assert len(capturing_executor.received_messages) == 1 @@ -609,7 +579,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): @@ -624,39 +594,39 @@ class TestWorkflowAgent: self.description: str | None = None self._response_text = response_text - def get_new_thread(self, **kwargs: Any) -> AgentThread: - return AgentThread() + def create_session(self, **kwargs: Any) -> AgentSession: + return AgentSession() def run( self, - messages: str | Content | ChatMessage | Sequence[str | Content | ChatMessage] | None = None, + messages: str | Content | Message | Sequence[str | Content | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: if stream: - return self._run_stream(messages=messages, thread=thread, **kwargs) - return self._run(messages=messages, thread=thread, **kwargs) + return self._run_stream(messages=messages, session=session, **kwargs) + return self._run(messages=messages, session=session, **kwargs) async def _run( self, - messages: str | Content | ChatMessage | Sequence[str | Content | ChatMessage] | None = None, + messages: str | Content | Message | Sequence[str | Content | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> AgentResponse: return AgentResponse( - messages=[ChatMessage("assistant", [self._response_text])], + messages=[Message("assistant", [self._response_text])], ) def _run_stream( self, - messages: str | Content | ChatMessage | Sequence[str | Content | ChatMessage] | None = None, + messages: str | Content | Message | Sequence[str | Content | Message] | None = None, *, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: async def _iter(): @@ -670,16 +640,20 @@ class TestWorkflowAgent: return ResponseStream(_iter(), finalizer=AgentResponse.from_updates) @executor - async def start_executor(messages: list[ChatMessage], ctx: WorkflowContext[AgentExecutorRequest, str]) -> None: + async def start_exec(messages: list[Message], ctx: WorkflowContext[AgentExecutorRequest, str]) -> None: await ctx.yield_output("Start output") await ctx.send_message(AgentExecutorRequest(messages=messages, should_respond=True)) - # Build workflow: start -> agent1 (no output) -> agent2 (output_response=True) - builder = WorkflowBuilder(start_executor="start", output_executors=["start", "agent2"]) - builder.register_executor(lambda: start_executor, "start") - builder.register_agent(lambda: MockAgent("agent1", "Agent1 output - should NOT appear"), "agent1") - builder.register_agent(lambda: MockAgent("agent2", "Agent2 output - SHOULD appear"), "agent2") - workflow = builder.add_edge("start", "agent1").add_edge("agent1", "agent2").build() + agent1 = MockAgent("agent1", "Agent1 output - should NOT appear") + agent2 = MockAgent("agent2", "Agent2 output - SHOULD appear") + + # Build workflow: start -> agent1 (no output) -> agent2 (output visible) + workflow = ( + WorkflowBuilder(start_executor=start_exec, output_executors=[start_exec, agent2]) + .add_edge(start_exec, agent1) + .add_edge(agent1, agent2) + .build() + ) agent = WorkflowAgent(workflow=workflow, name="Test Agent") result = await agent.run("Test input") @@ -708,39 +682,39 @@ class TestWorkflowAgent: self.description: str | None = None self._response_text = response_text - def get_new_thread(self, **kwargs: Any) -> AgentThread: - return AgentThread() + def create_session(self, **kwargs: Any) -> AgentSession: + return AgentSession() def run( self, - messages: str | Content | ChatMessage | Sequence[str | Content | ChatMessage] | None = None, + messages: str | Content | Message | Sequence[str | Content | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: if stream: - return self._run_stream(messages=messages, thread=thread, **kwargs) - return self._run(messages=messages, thread=thread, **kwargs) + return self._run_stream(messages=messages, session=session, **kwargs) + return self._run(messages=messages, session=session, **kwargs) async def _run( self, - messages: str | Content | ChatMessage | Sequence[str | Content | ChatMessage] | None = None, + messages: str | Content | Message | Sequence[str | Content | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> AgentResponse: return AgentResponse( - messages=[ChatMessage("assistant", [self._response_text])], + messages=[Message("assistant", [self._response_text])], ) def _run_stream( self, - messages: str | Content | ChatMessage | Sequence[str | Content | ChatMessage] | None = None, + messages: str | Content | Message | Sequence[str | Content | Message] | None = None, *, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: async def _iter(): @@ -754,17 +728,13 @@ class TestWorkflowAgent: return ResponseStream(_iter(), finalizer=AgentResponse.from_updates) @executor - async def start_executor(messages: list[ChatMessage], ctx: WorkflowContext[AgentExecutorRequest]) -> None: + async def start_exec(messages: list[Message], ctx: WorkflowContext[AgentExecutorRequest]) -> None: await ctx.send_message(AgentExecutorRequest(messages=messages, should_respond=True)) + mock_agent = MockAgent("agent", "Unique response text") + # Build workflow with single agent - workflow = ( - WorkflowBuilder(start_executor="start") - .register_executor(lambda: start_executor, "start") - .register_agent(lambda: MockAgent("agent", "Unique response text"), "agent") - .add_edge("start", "agent") - .build() - ) + workflow = WorkflowBuilder(start_executor=start_exec).add_edge(start_exec, mock_agent).build() agent = WorkflowAgent(workflow=workflow, name="Test Agent") result = await agent.run("Test input") @@ -810,8 +780,8 @@ class TestWorkflowAgentAuthorName: @handler async def handle_message( self, - message: list[ChatMessage], - ctx: WorkflowContext[list[ChatMessage], AgentResponseUpdate], + message: list[Message], + ctx: WorkflowContext[list[Message], AgentResponseUpdate], ) -> None: # Emit update with explicit author_name update = AgentResponseUpdate( @@ -1039,7 +1009,7 @@ class TestWorkflowAgentMergeUpdates: def test_merge_updates_function_result_ordering_github_2977(self): """Test that FunctionResultContent updates are placed after their FunctionCallContent. - This test reproduces GitHub issue #2977: When using a thread with WorkflowAgent, + This test reproduces GitHub issue #2977: When using a session with WorkflowAgent, FunctionResultContent updates without response_id were being added to global_dangling and placed at the end of messages. This caused OpenAI to reject the conversation because "An assistant message with 'tool_calls' must be followed by tool messages responding diff --git a/python/packages/core/tests/workflow/test_workflow_builder.py b/python/packages/core/tests/workflow/test_workflow_builder.py index 39c60717c2..073a24e5a3 100644 --- a/python/packages/core/tests/workflow/test_workflow_builder.py +++ b/python/packages/core/tests/workflow/test_workflow_builder.py @@ -9,10 +9,10 @@ from agent_framework import ( AgentExecutor, AgentResponse, AgentResponseUpdate, - AgentThread, + AgentSession, BaseAgent, - ChatMessage, Executor, + Message, WorkflowBuilder, WorkflowContext, WorkflowValidationError, @@ -21,19 +21,19 @@ from agent_framework import ( class DummyAgent(BaseAgent): - def run(self, messages=None, *, stream: bool = False, thread: AgentThread | None = None, **kwargs): # type: ignore[override] + def run(self, messages=None, *, stream: bool = False, session: AgentSession | None = None, **kwargs): # type: ignore[override] if stream: return self._run_stream_impl() return self._run_impl(messages) async def _run_impl(self, messages=None) -> AgentResponse: - norm: list[ChatMessage] = [] + norm: list[Message] = [] if messages: for m in messages: # type: ignore[iteration-over-optional] - if isinstance(m, ChatMessage): + if isinstance(m, Message): norm.append(m) elif isinstance(m, str): - norm.append(ChatMessage(role="user", text=m)) + norm.append(Message(role="user", text=m)) return AgentResponse(messages=norm) async def _run_stream_impl(self): # type: ignore[override] @@ -134,322 +134,65 @@ def test_add_agent_duplicate_id_raises_error(): builder.add_edge(agent1, agent2).build() -# Tests for new executor registration patterns +def test_fan_out_edges_with_direct_instances(): + """Test fan-out edges with direct executor instances.""" + source = MockExecutor(id="Source") + target1 = MockExecutor(id="Target1") + target2 = MockExecutor(id="Target2") + workflow = WorkflowBuilder(start_executor=source).add_fan_out_edges(source, [target1, target2]).build() -def test_register_executor_basic(): - """Test basic executor registration with lazy initialization.""" - builder = WorkflowBuilder(start_executor="TestExecutor") - - # Register an executor factory - ID must match the registered name - result = builder.register_executor(lambda: MockExecutor(id="TestExecutor"), name="TestExecutor") - - # Verify that register returns the builder for chaining - assert result is builder - - # Build workflow and verify executor is instantiated - workflow = builder.build() - assert "TestExecutor" in workflow.executors - assert isinstance(workflow.executors["TestExecutor"], MockExecutor) - - -def test_register_multiple_executors(): - """Test registering multiple executors and connecting them with edges.""" - builder = WorkflowBuilder(start_executor="ExecutorA") - - # Register multiple executors - IDs must match registered names - builder.register_executor(lambda: MockExecutor(id="ExecutorA"), name="ExecutorA") - builder.register_executor(lambda: MockExecutor(id="ExecutorB"), name="ExecutorB") - builder.register_executor(lambda: MockExecutor(id="ExecutorC"), name="ExecutorC") - - # Build workflow with edges using registered names - workflow = builder.add_edge("ExecutorA", "ExecutorB").add_edge("ExecutorB", "ExecutorC").build() - - # Verify all executors are present - assert "ExecutorA" in workflow.executors - assert "ExecutorB" in workflow.executors - assert "ExecutorC" in workflow.executors - assert workflow.start_executor_id == "ExecutorA" - - -def test_register_with_multiple_names(): - """Test registering the same factory function under multiple names.""" - builder = WorkflowBuilder(start_executor="ExecutorA") - - # Register same executor factory under multiple names - # Note: Each call creates a new instance, so IDs won't conflict - counter = {"val": 0} - - def make_executor(): - counter["val"] += 1 - return MockExecutor(id="ExecutorA" if counter["val"] == 1 else "ExecutorB") - - builder.register_executor(make_executor, name=["ExecutorA", "ExecutorB"]) - - # Set up workflow - workflow = builder.add_edge("ExecutorA", "ExecutorB").build() - - # Verify both executors are present - assert "ExecutorA" in workflow.executors - assert "ExecutorB" in workflow.executors - assert workflow.start_executor_id == "ExecutorA" - - -def test_register_duplicate_name_raises_error(): - """Test that registering duplicate names raises an error.""" - builder = WorkflowBuilder(start_executor="MyExecutor") - - # Register first executor - builder.register_executor(lambda: MockExecutor(id="executor_1"), name="MyExecutor") - - # Registering second executor with same name should raise ValueError - with pytest.raises(ValueError, match="already registered"): - builder.register_executor(lambda: MockExecutor(id="executor_2"), name="MyExecutor") - - -def test_register_duplicate_id_raises_error(): - """Test that registering duplicate id raises an error.""" - builder = WorkflowBuilder(start_executor="MyExecutor1") - - # Register first executor - builder.register_executor(lambda: MockExecutor(id="executor"), name="MyExecutor1") - builder.register_executor(lambda: MockExecutor(id="executor"), name="MyExecutor2") - - # Registering second executor with same ID should raise ValueError - with pytest.raises(ValueError, match="Executor with ID 'executor' has already been registered."): - builder.build() - - -def test_register_agent_basic(): - """Test basic agent registration with lazy initialization.""" - builder = WorkflowBuilder(start_executor="TestAgent") - - # Register an agent factory - result = builder.register_agent(lambda: DummyAgent(id="agent_test", name="test_agent"), name="TestAgent") - - # Verify that register_agent returns the builder for chaining - assert result is builder - - # Build workflow and verify agent is wrapped in AgentExecutor - workflow = builder.build() - assert "test_agent" in workflow.executors - assert isinstance(workflow.executors["test_agent"], AgentExecutor) - - -def test_register_agent_with_thread(): - """Test registering an agent with a custom thread.""" - builder = WorkflowBuilder(start_executor="ThreadedAgent") - custom_thread = AgentThread() - - # Register agent with custom thread - builder.register_agent( - lambda: DummyAgent(id="agent_with_thread", name="threaded_agent"), - name="ThreadedAgent", - agent_thread=custom_thread, - ) - - # Build workflow and verify agent executor configuration - workflow = builder.build() - executor = workflow.executors["threaded_agent"] - - assert isinstance(executor, AgentExecutor) - assert executor.id == "threaded_agent" - assert executor._agent_thread is custom_thread # type: ignore - - -def test_register_agent_duplicate_name_raises_error(): - """Test that registering agents with duplicate names raises an error.""" - builder = WorkflowBuilder(start_executor="MyAgent") - - # Register first agent - builder.register_agent(lambda: DummyAgent(id="agent1", name="first"), name="MyAgent") - - # Registering second agent with same name should raise ValueError - with pytest.raises(ValueError, match="already registered"): - builder.register_agent(lambda: DummyAgent(id="agent2", name="second"), name="MyAgent") - - -def test_register_and_add_edge_with_strings(): - """Test that registered executors can be connected using string names.""" - builder = WorkflowBuilder(start_executor="Source") - - # Register executors - builder.register_executor(lambda: MockExecutor(id="source"), name="Source") - builder.register_executor(lambda: MockExecutor(id="target"), name="Target") - - # Add edge using string names - workflow = builder.add_edge("Source", "Target").build() - - # Verify edge is created correctly - assert workflow.start_executor_id == "source" - assert "source" in workflow.executors - assert "target" in workflow.executors - - -def test_register_agent_and_add_edge_with_strings(): - """Test that registered agents can be connected using string names.""" - builder = WorkflowBuilder(start_executor="Writer") - - # Register agents - builder.register_agent(lambda: DummyAgent(id="writer_id", name="writer"), name="Writer") - builder.register_agent(lambda: DummyAgent(id="reviewer_id", name="reviewer"), name="Reviewer") - - # Add edge using string names - workflow = builder.add_edge("Writer", "Reviewer").build() - - # Verify edge is created correctly - assert workflow.start_executor_id == "writer" - assert "writer" in workflow.executors - assert "reviewer" in workflow.executors - assert all(isinstance(e, AgentExecutor) for e in workflow.executors.values()) - - -def test_register_with_fan_out_edges(): - """Test using registered names with fan-out edge groups.""" - builder = WorkflowBuilder(start_executor="Source") - - # Register executors - IDs must match registered names - builder.register_executor(lambda: MockExecutor(id="Source"), name="Source") - builder.register_executor(lambda: MockExecutor(id="Target1"), name="Target1") - builder.register_executor(lambda: MockExecutor(id="Target2"), name="Target2") - - # Add fan-out edges using registered names - workflow = builder.add_fan_out_edges("Source", ["Target1", "Target2"]).build() - - # Verify all executors are present assert "Source" in workflow.executors assert "Target1" in workflow.executors assert "Target2" in workflow.executors -def test_register_with_fan_in_edges(): - """Test using registered names with fan-in edge groups.""" - builder = WorkflowBuilder(start_executor="Source1") +def test_fan_in_edges_with_direct_instances(): + """Test fan-in edges with direct executor instances.""" + source1 = MockExecutor(id="Source1") + source2 = MockExecutor(id="Source2") + aggregator = MockAggregator(id="Aggregator") - # Register executors - IDs must match registered names - builder.register_executor(lambda: MockExecutor(id="Source1"), name="Source1") - builder.register_executor(lambda: MockExecutor(id="Source2"), name="Source2") - builder.register_executor(lambda: MockAggregator(id="Aggregator"), name="Aggregator") + workflow = ( + WorkflowBuilder(start_executor=source1) + .add_edge(source1, source2) + .add_fan_in_edges([source1, source2], aggregator) + .build() + ) - # Add fan-in edges using registered names - # Both Source1 and Source2 need to be reachable, so connect Source1 to Source2 - workflow = builder.add_edge("Source1", "Source2").add_fan_in_edges(["Source1", "Source2"], "Aggregator").build() - - # Verify all executors are present assert "Source1" in workflow.executors assert "Source2" in workflow.executors assert "Aggregator" in workflow.executors -def test_register_with_chain(): - """Test using registered names with add_chain.""" - builder = WorkflowBuilder(start_executor="Step1") +def test_chain_with_direct_instances(): + """Test add_chain with direct executor instances.""" + step1 = MockExecutor(id="Step1") + step2 = MockExecutor(id="Step2") + step3 = MockExecutor(id="Step3") - # Register executors - IDs must match registered names - builder.register_executor(lambda: MockExecutor(id="Step1"), name="Step1") - builder.register_executor(lambda: MockExecutor(id="Step2"), name="Step2") - builder.register_executor(lambda: MockExecutor(id="Step3"), name="Step3") + workflow = WorkflowBuilder(start_executor=step1).add_chain([step1, step2, step3]).build() - # Add chain using registered names - workflow = builder.add_chain(["Step1", "Step2", "Step3"]).build() - - # Verify all executors are present assert "Step1" in workflow.executors assert "Step2" in workflow.executors assert "Step3" in workflow.executors assert workflow.start_executor_id == "Step1" -def test_register_factory_called_only_once(): - """Test that registered factory functions are called only during build.""" - call_count = 0 - - def factory(): - nonlocal call_count - call_count += 1 - return MockExecutor(id="Test") - - builder = WorkflowBuilder(start_executor="Test") - builder.register_executor(factory, name="Test") - - # Factory should not be called yet - assert call_count == 0 - - # Factory should still not be called - assert call_count == 0 - - # Build workflow - workflow = builder.build() - - # Factory should now be called exactly once - assert call_count == 1 - assert "Test" in workflow.executors - - -def test_mixing_eager_and_lazy_initialization_error(): - """Test that mixing eager executor instances with lazy string names raises appropriate error.""" - builder = WorkflowBuilder(start_executor="Lazy") - - # Create an eager executor instance - eager_executor = MockExecutor(id="eager") - - # Register a lazy executor - builder.register_executor(lambda: MockExecutor(id="Lazy"), name="Lazy") - - # Mixing eager and lazy should raise an error during add_edge - with pytest.raises( - ValueError, - match=( - r"Both source and target must be either registered factory names \(str\) " - r"or Executor/SupportsAgentRun instances\." - ), - ): - builder.add_edge(eager_executor, "Lazy") - - -def test_register_with_condition(): - """Test adding edges with conditions using registered names.""" - builder = WorkflowBuilder(start_executor="Source") +def test_add_edge_with_condition(): + """Test adding edges with conditions using direct executor instances.""" + source = MockExecutor(id="Source") + target = MockExecutor(id="Target") def condition_func(msg: MockMessage) -> bool: return msg.data > 0 - # Register executors - IDs must match registered names - builder.register_executor(lambda: MockExecutor(id="Source"), name="Source") - builder.register_executor(lambda: MockExecutor(id="Target"), name="Target") + workflow = WorkflowBuilder(start_executor=source).add_edge(source, target, condition=condition_func).build() - # Add edge with condition - workflow = builder.add_edge("Source", "Target", condition=condition_func).build() - - # Verify workflow is built correctly assert "Source" in workflow.executors assert "Target" in workflow.executors -def test_register_agent_creates_unique_instances(): - """Test that registered agent factories create new instances on each build.""" - instance_ids: list[int] = [] - - def agent_factory() -> DummyAgent: - agent = DummyAgent(id=f"agent_{len(instance_ids)}", name="test") - instance_ids.append(id(agent)) - return agent - - # Build first workflow - builder1 = WorkflowBuilder(start_executor="Agent") - builder1.register_agent(agent_factory, name="Agent") - _ = builder1.build() - - # Build second workflow - builder2 = WorkflowBuilder(start_executor="Agent") - builder2.register_agent(agent_factory, name="Agent") - _ = builder2.build() - - # Verify that two different agent instances were created - assert len(instance_ids) == 2 - assert instance_ids[0] != instance_ids[1] - - # region with_output_from tests @@ -488,14 +231,17 @@ def test_with_output_from_with_agent_instances(): assert workflow._output_executors == ["reviewer"] # type: ignore -def test_with_output_from_with_registered_names(): - """Test with_output_from with registered factory names (strings).""" - builder = WorkflowBuilder(start_executor="ExecutorAFactory", output_executors=["ExecutorBFactory"]) - builder.register_executor(lambda: MockExecutor(id="ExecutorA"), name="ExecutorAFactory") - builder.register_executor(lambda: MockExecutor(id="ExecutorB"), name="ExecutorBFactory") - workflow = builder.add_edge("ExecutorAFactory", "ExecutorBFactory").build() +def test_with_output_from_with_executor_instances_by_id(): + """Test with_output_from with direct executor instances resolves to executor IDs.""" + executor_a = MockExecutor(id="ExecutorA") + executor_b = MockExecutor(id="ExecutorB") + + workflow = ( + WorkflowBuilder(start_executor=executor_a, output_executors=[executor_b]) + .add_edge(executor_a, executor_b) + .build() + ) - # Verify that the workflow was built with the correct output executors assert workflow._output_executors == ["ExecutorB"] # type: ignore @@ -531,14 +277,17 @@ def test_with_output_from_can_be_set_to_different_value(): assert workflow._output_executors == ["executor_b"] # type: ignore -def test_with_output_from_with_registered_agents(): - """Test with_output_from with registered agent factory names.""" - builder = WorkflowBuilder(start_executor="WriterAgent", output_executors=["ReviewerAgent"]) - builder.register_agent(lambda: DummyAgent(id="agent1", name="writer"), name="WriterAgent") - builder.register_agent(lambda: DummyAgent(id="agent2", name="reviewer"), name="ReviewerAgent") - workflow = builder.add_edge("WriterAgent", "ReviewerAgent").build() +def test_with_output_from_with_agent_instances_resolves_name(): + """Test with_output_from with agent instances resolves to agent names.""" + agent_writer = DummyAgent(id="agent1", name="writer") + agent_reviewer = DummyAgent(id="agent2", name="reviewer") + + workflow = ( + WorkflowBuilder(start_executor=agent_writer, output_executors=[agent_reviewer]) + .add_edge(agent_writer, agent_reviewer) + .build() + ) - # Verify that the workflow was built with the agent's resolved name assert workflow._output_executors == ["reviewer"] # type: ignore diff --git a/python/packages/core/tests/workflow/test_workflow_kwargs.py b/python/packages/core/tests/workflow/test_workflow_kwargs.py index 2e46454601..36aece0bb3 100644 --- a/python/packages/core/tests/workflow/test_workflow_kwargs.py +++ b/python/packages/core/tests/workflow/test_workflow_kwargs.py @@ -8,10 +8,10 @@ import pytest from agent_framework import ( AgentResponse, AgentResponseUpdate, - AgentThread, + AgentSession, BaseAgent, - ChatMessage, Content, + Message, ResponseStream, WorkflowRunState, tool, @@ -52,10 +52,10 @@ class _KwargsCapturingAgent(BaseAgent): def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: self.captured_kwargs.append(dict(kwargs)) @@ -67,7 +67,42 @@ class _KwargsCapturingAgent(BaseAgent): return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) async def _run() -> AgentResponse: - return AgentResponse(messages=[ChatMessage("assistant", [f"{self.name} response"])]) + return AgentResponse(messages=[Message("assistant", [f"{self.name} response"])]) + + return _run() + + +class _OptionsAwareAgent(BaseAgent): + """Test agent that captures explicit `options` and kwargs passed to run().""" + + captured_options: list[dict[str, Any] | None] + captured_kwargs: list[dict[str, Any]] + + def __init__(self, name: str = "options_agent") -> None: + super().__init__(name=name, description="Test agent for options capture") + self.captured_options = [] + self.captured_kwargs = [] + + def run( + self, + messages: str | Message | Sequence[str | Message] | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + options: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: + self.captured_options.append(dict(options) if options is not None else None) + self.captured_kwargs.append(dict(kwargs)) + if stream: + + async def _stream() -> AsyncIterable[AgentResponseUpdate]: + yield AgentResponseUpdate(contents=[Content.from_text(text=f"{self.name} response")]) + + return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) + + async def _run() -> AgentResponse: + return AgentResponse(messages=[Message("assistant", [f"{self.name} response"])]) return _run() @@ -131,6 +166,106 @@ async def test_sequential_run_kwargs_flow() -> None: assert agent.captured_kwargs[0].get("custom_data") == {"test": True} +async def test_sequential_run_options_does_not_conflict_with_agent_options() -> None: + """Test workflow.run(options=...) does not conflict with Agent.run(options=...).""" + agent = _OptionsAwareAgent(name="options_agent") + workflow = SequentialBuilder(participants=[agent]).build() + + custom_data = {"session_id": "abc123"} + user_token = {"user_name": "alice"} + provided_options = { + "store": False, + "additional_function_arguments": {"source": "workflow-options"}, + } + + async for event in workflow.run( + "test message", + stream=True, + options=provided_options, + custom_data=custom_data, + user_token=user_token, + ): + if event.type == "status" and event.state == WorkflowRunState.IDLE: + break + + assert len(agent.captured_options) >= 1 + captured_options = agent.captured_options[0] + assert captured_options is not None + assert captured_options.get("store") is False + + additional_args = captured_options.get("additional_function_arguments") + assert isinstance(additional_args, dict) + assert additional_args.get("source") == "workflow-options" + assert additional_args.get("custom_data") == custom_data + assert additional_args.get("user_token") == user_token + + # "options" should be passed once via the dedicated options parameter, + # not duplicated in **kwargs. + assert len(agent.captured_kwargs) >= 1 + captured_kwargs = agent.captured_kwargs[0] + assert "options" not in captured_kwargs + assert captured_kwargs.get("custom_data") == custom_data + assert captured_kwargs.get("user_token") == user_token + + +async def test_sequential_run_additional_function_arguments_flattened() -> None: + """Test workflow.run(additional_function_arguments=...) maps directly to tool kwargs.""" + agent = _OptionsAwareAgent(name="options_agent") + workflow = SequentialBuilder(participants=[agent]).build() + + custom_data = {"session_id": "abc123"} + user_token = {"user_name": "alice"} + + async for event in workflow.run( + "test message", + stream=True, + additional_function_arguments={"custom_data": custom_data, "user_token": user_token}, + ): + if event.type == "status" and event.state == WorkflowRunState.IDLE: + break + + assert len(agent.captured_options) >= 1 + captured_options = agent.captured_options[0] + assert captured_options is not None + + additional_args = captured_options.get("additional_function_arguments") + assert isinstance(additional_args, dict) + assert additional_args.get("custom_data") == custom_data + assert additional_args.get("user_token") == user_token + assert "additional_function_arguments" not in additional_args + + assert len(agent.captured_kwargs) >= 1 + captured_kwargs = agent.captured_kwargs[0] + assert "additional_function_arguments" not in captured_kwargs + + +async def test_sequential_run_additional_function_arguments_merges_with_options() -> None: + """Test workflow additional_function_arguments merges with workflow options.""" + agent = _OptionsAwareAgent(name="options_agent") + workflow = SequentialBuilder(participants=[agent]).build() + + async for event in workflow.run( + "test message", + stream=True, + options={"additional_function_arguments": {"source": "workflow-options"}}, + additional_function_arguments={"custom_data": {"session_id": "abc123"}}, + user_token={"user_name": "alice"}, + ): + if event.type == "status" and event.state == WorkflowRunState.IDLE: + break + + assert len(agent.captured_options) >= 1 + captured_options = agent.captured_options[0] + assert captured_options is not None + + additional_args = captured_options.get("additional_function_arguments") + assert isinstance(additional_args, dict) + assert additional_args.get("source") == "workflow-options" + assert additional_args.get("custom_data") == {"session_id": "abc123"} + assert additional_args.get("user_token") == {"user_name": "alice"} + assert "additional_function_arguments" not in additional_args + + # endregion @@ -222,7 +357,7 @@ async def test_kwargs_stored_in_state() -> None: class _StateInspector(Executor): @handler - async def inspect(self, msgs: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None: + async def inspect(self, msgs: list[Message], ctx: WorkflowContext[list[Message]]) -> None: nonlocal stored_kwargs stored_kwargs = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) await ctx.send_message(msgs) @@ -247,7 +382,7 @@ async def test_empty_kwargs_stored_as_empty_dict() -> None: class _StateChecker(Executor): @handler - async def check(self, msgs: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None: + async def check(self, msgs: list[Message], ctx: WorkflowContext[list[Message]]) -> None: nonlocal stored_kwargs stored_kwargs = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) await ctx.send_message(msgs) @@ -388,11 +523,11 @@ async def test_magentic_kwargs_flow_to_agents() -> None: super().__init__(max_stall_count=3, max_reset_count=None, max_round_count=2) self.task_ledger = None - async def plan(self, magentic_context: MagenticContext) -> ChatMessage: - return ChatMessage(role="assistant", text="Plan: Test task", author_name="manager") + async def plan(self, magentic_context: MagenticContext) -> Message: + return Message(role="assistant", text="Plan: Test task", author_name="manager") - async def replan(self, magentic_context: MagenticContext) -> ChatMessage: - return ChatMessage(role="assistant", text="Replan: Test task", author_name="manager") + async def replan(self, magentic_context: MagenticContext) -> Message: + return Message(role="assistant", text="Replan: Test task", author_name="manager") async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: # Return completed on first call @@ -404,8 +539,8 @@ async def test_magentic_kwargs_flow_to_agents() -> None: next_speaker=MagenticProgressLedgerItem(answer="agent1", reason="First"), ) - async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage: - return ChatMessage(role="assistant", text="Final answer", author_name="manager") + async def prepare_final_answer(self, magentic_context: MagenticContext) -> Message: + return Message(role="assistant", text="Final answer", author_name="manager") agent = _KwargsCapturingAgent(name="agent1") manager = _MockManager() @@ -439,11 +574,11 @@ async def test_magentic_kwargs_stored_in_state() -> None: super().__init__(max_stall_count=3, max_reset_count=None, max_round_count=1) self.task_ledger = None - async def plan(self, magentic_context: MagenticContext) -> ChatMessage: - return ChatMessage(role="assistant", text="Plan", author_name="manager") + async def plan(self, magentic_context: MagenticContext) -> Message: + return Message(role="assistant", text="Plan", author_name="manager") - async def replan(self, magentic_context: MagenticContext) -> ChatMessage: - return ChatMessage(role="assistant", text="Replan", author_name="manager") + async def replan(self, magentic_context: MagenticContext) -> Message: + return Message(role="assistant", text="Replan", author_name="manager") async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: return MagenticProgressLedger( @@ -454,8 +589,8 @@ async def test_magentic_kwargs_stored_in_state() -> None: next_speaker=MagenticProgressLedgerItem(answer="agent1", reason="First"), ) - async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage: - return ChatMessage(role="assistant", text="Final", author_name="manager") + async def prepare_final_answer(self, magentic_context: MagenticContext) -> Message: + return Message(role="assistant", text="Final", author_name="manager") agent = _KwargsCapturingAgent(name="agent1") manager = _MockManager() @@ -660,7 +795,7 @@ async def test_subworkflow_kwargs_accessible_via_state() -> None: """Executor that reads kwargs from State for verification.""" @handler - async def read_kwargs(self, msgs: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None: + async def read_kwargs(self, msgs: list[Message], ctx: WorkflowContext[list[Message]]) -> None: kwargs_from_state = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) captured_kwargs_from_state.append(kwargs_from_state or {}) await ctx.send_message(msgs) diff --git a/python/packages/core/tests/workflow/test_workflow_observability.py b/python/packages/core/tests/workflow/test_workflow_observability.py index b81e0acae0..b2260abe63 100644 --- a/python/packages/core/tests/workflow/test_workflow_observability.py +++ b/python/packages/core/tests/workflow/test_workflow_observability.py @@ -8,7 +8,7 @@ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanE from agent_framework import InMemoryCheckpointStorage, WorkflowBuilder from agent_framework._workflows._executor import Executor, handler -from agent_framework._workflows._runner_context import InProcRunnerContext, Message, MessageType +from agent_framework._workflows._runner_context import InProcRunnerContext, MessageType, WorkflowMessage from agent_framework._workflows._state import State from agent_framework._workflows._workflow import Workflow from agent_framework._workflows._workflow_context import WorkflowContext @@ -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() @@ -440,7 +440,7 @@ async def test_message_trace_context_serialization(span_exporter: InMemorySpanEx ctx = InProcRunnerContext(InMemoryCheckpointStorage()) # Create message with trace context - message = Message( + message = WorkflowMessage( data="test", source_id="source", target_id="target", @@ -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) @@ -474,8 +474,9 @@ async def test_message_trace_context_serialization(span_exporter: InMemorySpanEx async def test_workflow_build_error_tracing(span_exporter: InMemorySpanExporter) -> None: """Test that build errors are properly recorded in build spans.""" - # Test validation error by referencing a non-existent start executor - builder = WorkflowBuilder(start_executor="NonExistent") + # Create a valid builder, then clear the start executor to trigger a build-time ValueError + builder = WorkflowBuilder(start_executor=MockExecutor(id="mock")) + builder._start_executor = None # type: ignore[assignment] with pytest.raises(ValueError): builder.build() diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py index 493787350e..001f6f511e 100644 --- a/python/packages/declarative/agent_framework_declarative/_loader.py +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -5,19 +5,12 @@ from __future__ import annotations import sys from collections.abc import Callable, Mapping from pathlib import Path -from typing import Any, Literal, cast +from typing import Any, cast import yaml from agent_framework import ( - ChatAgent, - ChatClientProtocol, - Content, - HostedCodeInterpreterTool, - HostedFileSearchTool, - HostedMCPSpecificApproval, - HostedMCPTool, - HostedWebSearchTool, - ToolProtocol, + Agent, + SupportsChatGetResponse, ) from agent_framework import ( FunctionTool as AFFunctionTool, @@ -124,10 +117,10 @@ class ProviderLookupError(DeclarativeLoaderError): class AgentFactory: - """Factory for creating ChatAgent instances from declarative YAML definitions. + """Factory for creating Agent instances from declarative YAML definitions. AgentFactory parses YAML agent definitions (PromptAgent kind) and creates - configured ChatAgent instances with the appropriate chat client, tools, + configured Agent instances with the appropriate chat client, tools, and response format. Examples: @@ -150,7 +143,7 @@ class AgentFactory: # With pre-configured chat client client = AzureOpenAIChatClient() - factory = AgentFactory(chat_client=client) + factory = AgentFactory(client=client) agent = factory.create_agent_from_yaml_path("agent.yaml") .. code-block:: python @@ -174,7 +167,7 @@ class AgentFactory: def __init__( self, *, - chat_client: ChatClientProtocol | None = None, + client: SupportsChatGetResponse | None = None, bindings: Mapping[str, Any] | None = None, connections: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, @@ -187,8 +180,8 @@ class AgentFactory: """Create the agent factory. Args: - chat_client: An optional ChatClientProtocol instance to use as a dependency. - This will be passed to the ChatAgent that gets created. + client: An optional SupportsChatGetResponse instance to use as a dependency. + This will be passed to the Agent that gets created. If you need to create multiple agents with different chat clients, do not pass this and instead provide the chat client in the YAML definition. bindings: An optional dictionary of bindings to use when creating agents. @@ -210,9 +203,9 @@ class AgentFactory: Here, "Provider.ApiType" is the lookup key used when both provider and apiType are specified in the model, "Provider" is also allowed. - Package refers to which model needs to be imported, Name is the class name of the ChatClientProtocol - implementation, and model_id_field is the name of the field in the constructor - that accepts the model.id value. + Package refers to which model needs to be imported, Name is the class name of the + SupportsChatGetResponse implementation, and model_id_field is the name of the field in the + constructor that accepts the model.id value. default_provider: The default provider used when model.provider is not specified, default is "AzureAIClient". safe_mode: Whether to run in safe mode, default is True. @@ -241,7 +234,7 @@ class AgentFactory: # With shared chat client client = AzureOpenAIChatClient() factory = AgentFactory( - chat_client=client, + client=client, env_file_path=".env", ) @@ -260,7 +253,7 @@ class AgentFactory: }, ) """ - self.chat_client = chat_client + self.client = client self.bindings = bindings self.connections = connections self.client_kwargs = client_kwargs or {} @@ -269,8 +262,8 @@ class AgentFactory: self.safe_mode = safe_mode load_dotenv(dotenv_path=env_file_path, encoding=env_file_encoding) - def create_agent_from_yaml_path(self, yaml_path: str | Path) -> ChatAgent: - """Create a ChatAgent from a YAML file path. + def create_agent_from_yaml_path(self, yaml_path: str | Path) -> Agent: + """Create a Agent from a YAML file path. This method does the following things: @@ -278,13 +271,13 @@ class AgentFactory: 2. Validates that the loaded object is a PromptAgent. 3. Creates the appropriate ChatClient based on the model provider and apiType. 4. Parses the tools, options, and response format from the PromptAgent. - 5. Creates and returns a ChatAgent instance with the configured properties. + 5. Creates and returns a Agent instance with the configured properties. Args: yaml_path: Path to the YAML file representation of a PromptAgent. Returns: - The ``ChatAgent`` instance created from the YAML file. + The ``Agent`` instance created from the YAML file. Raises: DeclarativeLoaderError: If the YAML does not represent a PromptAgent. @@ -323,8 +316,8 @@ class AgentFactory: yaml_str = f.read() return self.create_agent_from_yaml(yaml_str) - def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent: - """Create a ChatAgent from a YAML string. + def create_agent_from_yaml(self, yaml_str: str) -> Agent: + """Create a Agent from a YAML string. This method does the following things: @@ -332,13 +325,13 @@ class AgentFactory: 2. Validates that the loaded object is a PromptAgent. 3. Creates the appropriate ChatClient based on the model provider and apiType. 4. Parses the tools, options, and response format from the PromptAgent. - 5. Creates and returns a ChatAgent instance with the configured properties. + 5. Creates and returns a Agent instance with the configured properties. Args: yaml_str: YAML string representation of a PromptAgent. Returns: - The ``ChatAgent`` instance created from the YAML string. + The ``Agent`` instance created from the YAML string. Raises: DeclarativeLoaderError: If the YAML does not represent a PromptAgent. @@ -396,8 +389,8 @@ class AgentFactory: """ return self.create_agent_from_dict(yaml.safe_load(yaml_str)) - def create_agent_from_dict(self, agent_def: dict[str, Any]) -> ChatAgent: - """Create a ChatAgent from a dictionary definition. + def create_agent_from_dict(self, agent_def: dict[str, Any]) -> Agent: + """Create a Agent from a dictionary definition. This method does the following things: @@ -405,13 +398,13 @@ class AgentFactory: 2. Validates that the loaded object is a PromptAgent. 3. Creates the appropriate ChatClient based on the model provider and apiType. 4. Parses the tools, options, and response format from the PromptAgent. - 5. Creates and returns a ChatAgent instance with the configured properties. + 5. Creates and returns a Agent instance with the configured properties. Args: agent_def: Dictionary representation of a PromptAgent. Returns: - The `ChatAgent` instance created from the dictionary. + The `Agent` instance created from the dictionary. Raises: DeclarativeLoaderError: If the dictionary does not represent a PromptAgent. @@ -454,16 +447,16 @@ class AgentFactory: if output_schema := prompt_agent.outputSchema: chat_options["response_format"] = _create_model_from_json_schema("agent", output_schema.to_json_schema()) # Step 3: Create the agent instance - return ChatAgent( - chat_client=client, + return Agent( + client=client, name=prompt_agent.name, description=prompt_agent.description, instructions=prompt_agent.instructions, **chat_options, ) - async def create_agent_from_yaml_path_async(self, yaml_path: str | Path) -> ChatAgent: - """Async version: Create a ChatAgent from a YAML file path. + async def create_agent_from_yaml_path_async(self, yaml_path: str | Path) -> Agent: + """Async version: Create a Agent from a YAML file path. Use this method when the provider requires async initialization, such as AzureAI.ProjectProvider which creates agents on the Azure AI Agent Service. @@ -472,7 +465,7 @@ class AgentFactory: yaml_path: Path to the YAML file representation of a PromptAgent. Returns: - The ``ChatAgent`` instance created from the YAML file. + The ``Agent`` instance created from the YAML file. Examples: .. code-block:: python @@ -492,8 +485,8 @@ class AgentFactory: yaml_str = yaml_path.read_text() return await self.create_agent_from_yaml_async(yaml_str) - async def create_agent_from_yaml_async(self, yaml_str: str) -> ChatAgent: - """Async version: Create a ChatAgent from a YAML string. + async def create_agent_from_yaml_async(self, yaml_str: str) -> Agent: + """Async version: Create a Agent from a YAML string. Use this method when the provider requires async initialization, such as AzureAI.ProjectProvider which creates agents on the Azure AI Agent Service. @@ -502,7 +495,7 @@ class AgentFactory: yaml_str: YAML string representation of a PromptAgent. Returns: - The ``ChatAgent`` instance created from the YAML string. + The ``Agent`` instance created from the YAML string. Examples: .. code-block:: python @@ -523,8 +516,8 @@ class AgentFactory: """ return await self.create_agent_from_dict_async(yaml.safe_load(yaml_str)) - async def create_agent_from_dict_async(self, agent_def: dict[str, Any]) -> ChatAgent: - """Async version: Create a ChatAgent from a dictionary definition. + async def create_agent_from_dict_async(self, agent_def: dict[str, Any]) -> Agent: + """Async version: Create a Agent from a dictionary definition. Use this method when the provider requires async initialization, such as AzureAI.ProjectProvider which creates agents on the Azure AI Agent Service. @@ -533,7 +526,7 @@ class AgentFactory: agent_def: Dictionary representation of a PromptAgent. Returns: - The ``ChatAgent`` instance created from the dictionary. + The ``Agent`` instance created from the dictionary. Examples: .. code-block:: python @@ -571,20 +564,20 @@ class AgentFactory: chat_options["tools"] = tools if output_schema := prompt_agent.outputSchema: chat_options["response_format"] = _create_model_from_json_schema("agent", output_schema.to_json_schema()) - return ChatAgent( - chat_client=client, + return Agent( + client=client, name=prompt_agent.name, description=prompt_agent.description, instructions=prompt_agent.instructions, **chat_options, ) - async def _create_agent_with_provider(self, prompt_agent: PromptAgent, mapping: ProviderTypeMapping) -> ChatAgent: - """Create a ChatAgent using AzureAIProjectAgentProvider. + async def _create_agent_with_provider(self, prompt_agent: PromptAgent, mapping: ProviderTypeMapping) -> Agent: + """Create a Agent using AzureAIProjectAgentProvider. This method handles the special case where we use a provider that creates agents on a remote service (like Azure AI Agent Service) and returns - ChatAgent instances directly. + Agent instances directly. """ # Import the provider class module_name = mapping["package"] @@ -612,31 +605,32 @@ 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 ChatAgent directly + # The provider's create_agent returns a Agent directly return cast( - ChatAgent, + Agent, await provider.create_agent( name=prompt_agent.name, model=prompt_agent.model.id if prompt_agent.model else None, instructions=prompt_agent.instructions, description=prompt_agent.description, tools=tools, - response_format=response_format, + default_options=default_options, ), ) - def _get_client(self, prompt_agent: PromptAgent) -> ChatClientProtocol: - """Create the ChatClientProtocol instance based on the PromptAgent model.""" + def _get_client(self, prompt_agent: PromptAgent) -> SupportsChatGetResponse: + """Create the SupportsChatGetResponse instance based on the PromptAgent model.""" if not prompt_agent.model: - # if no model is defined, use the supplied chat_client - if self.chat_client: - return self.chat_client + # if no model is defined, use the supplied client + if self.client: + return self.client raise DeclarativeLoaderError( "ChatClient must be provided to create agent from PromptAgent, " "alternatively define a model in the PromptAgent." @@ -670,9 +664,9 @@ class AgentFactory: # Any client we create, needs a model.id if not prompt_agent.model.id: - # if prompt_agent.model is defined, but no id, use the supplied chat_client - if self.chat_client: - return self.chat_client + # if prompt_agent.model is defined, but no id, use the supplied client + if self.client: + return self.client # or raise, since we cannot create a client without model id raise DeclarativeLoaderError( "ChatClient must be provided to create agent from PromptAgent, or define model.id in the PromptAgent." @@ -714,14 +708,14 @@ class AgentFactory: chat_options["additional_chat_options"] = options.additionalProperties return chat_options - def _parse_tools(self, tools: list[Tool] | None) -> list[ToolProtocol] | None: - """Parse tool resources into ToolProtocol instances.""" + def _parse_tools(self, tools: list[Tool] | None) -> list[AFFunctionTool | dict[str, Any]] | None: + """Parse tool resources into AFFunctionTool instances or dict-based tools.""" if not tools: return None return [self._parse_tool(tool_resource) for tool_resource in tools] - def _parse_tool(self, tool_resource: Tool) -> ToolProtocol: - """Parse a single tool resource into a ToolProtocol instance.""" + def _parse_tool(self, tool_resource: Tool) -> AFFunctionTool | dict[str, Any]: + """Parse a single tool resource into an AFFunctionTool instance.""" match tool_resource: case FunctionTool(): func: Callable[..., Any] | None = None @@ -736,88 +730,81 @@ class AgentFactory: func=func, ) case WebSearchTool(): - return HostedWebSearchTool( - description=tool_resource.description, additional_properties=tool_resource.options - ) + result: dict[str, Any] = {"type": "web_search_preview"} + if tool_resource.description: + result["description"] = tool_resource.description + if tool_resource.options: + result.update(tool_resource.options) + return result case FileSearchTool(): - add_props: dict[str, Any] = {} + result = { + "type": "file_search", + "vector_store_ids": tool_resource.vectorStoreIds or [], + } + if tool_resource.maximumResultCount is not None: + result["max_num_results"] = tool_resource.maximumResultCount + if tool_resource.description: + result["description"] = tool_resource.description if tool_resource.ranker is not None: - add_props["ranker"] = tool_resource.ranker + result["ranker"] = tool_resource.ranker if tool_resource.scoreThreshold is not None: - add_props["score_threshold"] = tool_resource.scoreThreshold + result["score_threshold"] = tool_resource.scoreThreshold if tool_resource.filters: - add_props["filters"] = tool_resource.filters - return HostedFileSearchTool( - inputs=[Content.from_hosted_vector_store(id) for id in tool_resource.vectorStoreIds or []], - description=tool_resource.description, - max_results=tool_resource.maximumResultCount, - additional_properties=add_props, - ) + result["filters"] = tool_resource.filters + return result case CodeInterpreterTool(): - return HostedCodeInterpreterTool( - inputs=[Content.from_hosted_file(file_id=file) for file in tool_resource.fileIds or []], - description=tool_resource.description, - ) + result = {"type": "code_interpreter"} + if tool_resource.fileIds: + result["file_ids"] = tool_resource.fileIds + if tool_resource.description: + result["description"] = tool_resource.description + return result case McpTool(): - approval_mode: HostedMCPSpecificApproval | Literal["always_require", "never_require"] | None = None + result = { + "type": "mcp", + "server_label": tool_resource.name.replace(" ", "_") if tool_resource.name else "", + "server_url": str(tool_resource.url) if tool_resource.url else "", + } + if tool_resource.description: + result["server_description"] = tool_resource.description + if tool_resource.allowedTools: + result["allowed_tools"] = list(tool_resource.allowedTools) + + # Handle approval mode if tool_resource.approvalMode is not None: if tool_resource.approvalMode.kind == "always": - approval_mode = "always_require" + result["require_approval"] = "always" elif tool_resource.approvalMode.kind == "never": - approval_mode = "never_require" + result["require_approval"] = "never" elif isinstance(tool_resource.approvalMode, McpServerToolSpecifyApprovalMode): - approval_mode = {} + approval_config: dict[str, Any] = {} if tool_resource.approvalMode.alwaysRequireApprovalTools: - approval_mode["always_require_approval"] = ( - tool_resource.approvalMode.alwaysRequireApprovalTools - ) + approval_config["always"] = { + "tool_names": list(tool_resource.approvalMode.alwaysRequireApprovalTools) + } if tool_resource.approvalMode.neverRequireApprovalTools: - approval_mode["never_require_approval"] = ( - tool_resource.approvalMode.neverRequireApprovalTools - ) - if not approval_mode: - approval_mode = None + approval_config["never"] = { + "tool_names": list(tool_resource.approvalMode.neverRequireApprovalTools) + } + if approval_config: + result["require_approval"] = approval_config # Handle connection settings - headers: dict[str, str] | None = None - additional_properties: dict[str, Any] | None = None - if tool_resource.connection is not None: match tool_resource.connection: case ApiKeyConnection(): if tool_resource.connection.apiKey: - headers = {"Authorization": f"Bearer {tool_resource.connection.apiKey}"} + result["headers"] = {"Authorization": f"Bearer {tool_resource.connection.apiKey}"} case RemoteConnection(): - additional_properties = { - "connection": { - "kind": tool_resource.connection.kind, - "name": tool_resource.connection.name, - "authenticationMode": tool_resource.connection.authenticationMode, - "endpoint": tool_resource.connection.endpoint, - } - } + result["project_connection_id"] = tool_resource.connection.name case ReferenceConnection(): - additional_properties = { - "connection": { - "kind": tool_resource.connection.kind, - "name": tool_resource.connection.name, - "authenticationMode": tool_resource.connection.authenticationMode, - } - } + result["project_connection_id"] = tool_resource.connection.name case AnonymousConnection(): pass case _: raise ValueError(f"Unsupported connection kind: {tool_resource.connection.kind}") - return HostedMCPTool( - name=tool_resource.name, # type: ignore - description=tool_resource.description, - url=tool_resource.url, # type: ignore - allowed_tools=tool_resource.allowedTools, - approval_mode=approval_mode, - headers=headers, - additional_properties=additional_properties, - ) + return result case _: raise ValueError(f"Unsupported tool kind: {tool_resource.kind}") diff --git a/python/packages/declarative/agent_framework_declarative/_models.py b/python/packages/declarative/agent_framework_declarative/_models.py index 107978e36b..38bcbdd855 100644 --- a/python/packages/declarative/agent_framework_declarative/_models.py +++ b/python/packages/declarative/agent_framework_declarative/_models.py @@ -232,7 +232,7 @@ class PropertySchema(SerializationMixin): return json_schema -TConnection = TypeVar("TConnection", bound="Connection") +ConnectionT = TypeVar("ConnectionT", bound="Connection") class Connection(SerializationMixin): @@ -250,12 +250,12 @@ class Connection(SerializationMixin): @classmethod def from_dict( - cls: type[TConnection], + cls: type[ConnectionT], value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None, - ) -> TConnection: + ) -> ConnectionT: """Create a Connection instance from a dictionary, dispatching to the appropriate subclass.""" # Only dispatch if we're being called on the base Connection class if cls is not Connection: @@ -507,7 +507,7 @@ class AgentDefinition(SerializationMixin): return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[attr-defined, no-any-return] -TTool = TypeVar("TTool", bound="Tool") +ToolT = TypeVar("ToolT", bound="Tool") class Tool(SerializationMixin): @@ -538,8 +538,8 @@ class Tool(SerializationMixin): @classmethod def from_dict( - cls: type[TTool], value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None - ) -> TTool: + cls: type[ToolT], value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None + ) -> ToolT: """Create a Tool instance from a dictionary, dispatching to the appropriate subclass.""" # Only dispatch if we're being called on the base Tool class if cls is not Tool: 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 1a49f9b89d..e34f6e06f8 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_actions_agents.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_actions_agents.py @@ -14,7 +14,7 @@ from collections.abc import AsyncGenerator from typing import Any, cast from agent_framework import get_logger -from agent_framework._types import AgentResponse, ChatMessage +from agent_framework._types import AgentResponse, Message from ._handlers import ( ActionContext, @@ -162,7 +162,7 @@ def _extract_json_from_response(text: str) -> Any: raise json.JSONDecodeError("No valid JSON found in response", text, 0) -def _build_messages_from_state(ctx: ActionContext) -> list[ChatMessage]: +def _build_messages_from_state(ctx: ActionContext) -> list[Message]: """Build the message list to send to an agent. This collects messages from: @@ -174,9 +174,9 @@ def _build_messages_from_state(ctx: ActionContext) -> list[ChatMessage]: ctx: The action context Returns: - List of ChatMessage objects to send to the agent + List of Message objects to send to the agent """ - messages: list[ChatMessage] = [] + messages: list[Message] = [] # Get conversation history history = ctx.state.get("conversation.messages", []) @@ -287,23 +287,23 @@ async def handle_invoke_azure_agent(ctx: ActionContext) -> AsyncGenerator[Workfl evaluated_input = ctx.state.eval_if_expression(input_messages) if evaluated_input: if isinstance(evaluated_input, str): - messages.append(ChatMessage(role="user", text=evaluated_input)) + messages.append(Message(role="user", text=evaluated_input)) elif isinstance(evaluated_input, list): for msg_item in evaluated_input: # type: ignore if isinstance(msg_item, str): - messages.append(ChatMessage(role="user", text=msg_item)) - elif isinstance(msg_item, ChatMessage): + messages.append(Message(role="user", text=msg_item)) + elif isinstance(msg_item, Message): messages.append(msg_item) elif isinstance(msg_item, dict) and "content" in msg_item: item_dict = cast(dict[str, Any], msg_item) role: str = str(item_dict.get("role", "user")) content: str = str(item_dict.get("content", "")) if role == "user": - messages.append(ChatMessage(role="user", text=content)) + messages.append(Message(role="user", text=content)) elif role == "assistant": - messages.append(ChatMessage(role="assistant", text=content)) + messages.append(Message(role="assistant", text=content)) elif role == "system": - messages.append(ChatMessage(role="system", text=content)) + messages.append(Message(role="system", text=content)) # Evaluate and include input arguments evaluated_args: dict[str, Any] = {} @@ -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 @@ -365,7 +375,7 @@ async def handle_invoke_azure_agent(ctx: ActionContext) -> AsyncGenerator[Workfl # Add to conversation history if text: - ctx.state.add_conversation_message(ChatMessage(role="assistant", text=text)) + ctx.state.add_conversation_message(Message(role="assistant", text=text)) # Store in output variables (.NET style) if output_messages_var: @@ -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 @@ -418,7 +428,7 @@ async def handle_invoke_azure_agent(ctx: ActionContext) -> AsyncGenerator[Workfl # Add to conversation history if text: - ctx.state.add_conversation_message(ChatMessage(role="assistant", text=text)) + ctx.state.add_conversation_message(Message(role="assistant", text=text)) # Store in output variables (.NET style) if output_messages_var: @@ -564,12 +574,22 @@ async def handle_invoke_prompt_agent(ctx: ActionContext) -> AsyncGenerator[Workf # Add input as user message if provided if input_value: if isinstance(input_value, str): - messages.append(ChatMessage(role="user", text=input_value)) - elif isinstance(input_value, ChatMessage): + messages.append(Message(role="user", text=input_value)) + elif isinstance(input_value, Message): messages.append(input_value) 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: @@ -594,7 +614,7 @@ async def handle_invoke_prompt_agent(ctx: ActionContext) -> AsyncGenerator[Workf ctx.state.set_agent_result(text=text, messages=response_messages) if text: - ctx.state.add_conversation_message(ChatMessage(role="assistant", text=text)) + ctx.state.add_conversation_message(Message(role="assistant", text=text)) if output_path: ctx.state.set(output_path, text) @@ -607,14 +627,14 @@ 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 ctx.state.set_agent_result(text=text, messages=response_messages) if text: - ctx.state.add_conversation_message(ChatMessage(role="assistant", text=text)) + ctx.state.add_conversation_message(Message(role="assistant", text=text)) if output_path: ctx.state.set(output_path, text) 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 11b6868ad1..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 @@ -102,7 +108,7 @@ def _make_powerfx_safe(value: Any) -> Any: """Convert a value to a PowerFx-serializable form. PowerFx can only serialize primitive types, dicts, and lists. - Custom objects (like ChatMessage) must be converted to dicts or excluded. + Custom objects (like Message) must be converted to dicts or excluded. Args: value: Any Python value @@ -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: @@ -558,8 +572,8 @@ class DeclarativeWorkflowState: # Try "text" key first (simple dict format) if "text" in last_msg: return str(last_msg["text"]) - # Try extracting from "contents" (ChatMessage dict format) - # ChatMessage.text concatenates text from all TextContent items + # Try extracting from "contents" (Message dict format) + # Message.text concatenates text from all TextContent items contents = last_msg.get("contents", []) if isinstance(contents, list): text_parts = [] diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py index 1f1069c02c..c4f9ecff59 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py @@ -148,33 +148,26 @@ class DeclarativeWorkflowBuilder: if self._validate: self._validate_workflow(actions) - # Use a placeholder for start_executor; it will be overwritten below via _set_start_executor + # Create a stable entry node as the start executor, then wire it to the first action. + # This avoids needing a placeholder since the entry executor isn't known until after + # _create_executors_for_actions runs (which itself needs the builder to add edges). + entry_node = JoinExecutor({"kind": "Entry"}, id="_workflow_entry") + self._executors[entry_node.id] = entry_node builder = WorkflowBuilder( - start_executor="_declarative_placeholder", + start_executor=entry_node, name=self._workflow_id, checkpoint_storage=self._checkpoint_storage, ) - # First pass: create all executors - entry_executor = self._create_executors_for_actions(actions, builder) + # Create all executors and wire sequential edges + first_executor = self._create_executors_for_actions(actions, builder) - # Set the entry point - if entry_executor: - # Check if entry is a control flow structure (If/Switch) - if getattr(entry_executor, "_is_if_structure", False) or getattr( - entry_executor, "_is_switch_structure", False - ): - # Create an entry passthrough node and wire to the structure's branches - entry_node = JoinExecutor({"kind": "Entry"}, id="_workflow_entry") - self._executors[entry_node.id] = entry_node - builder._set_start_executor(entry_node) - # Use _add_sequential_edge which knows how to wire to structures - self._add_sequential_edge(builder, entry_node, entry_executor) - else: - builder._set_start_executor(entry_executor) - else: + if not first_executor: raise ValueError("Failed to create any executors from actions.") + # Wire entry node to the first action (handles both regular and control flow targets) + self._add_sequential_edge(builder, entry_node, first_executor) + # Resolve pending gotos (back-edges for loops, forward-edges for jumps) self._resolve_pending_gotos(builder) @@ -223,9 +216,11 @@ class DeclarativeWorkflowBuilder: for action_def in actions: kind = action_def.get("kind", "") - # Check for duplicate explicit IDs + # Check for duplicate or reserved explicit IDs explicit_id = action_def.get("id") if explicit_id: + if explicit_id == "_workflow_entry": + raise ValueError(f"Action ID '{explicit_id}' is reserved for internal use. Choose a different ID.") if explicit_id in seen_ids: raise ValueError(f"Duplicate action ID '{explicit_id}'. Action IDs must be unique.") seen_ids.add(explicit_id) 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 d4300a9909..44c9e958c2 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py @@ -20,8 +20,8 @@ from dataclasses import dataclass, field from typing import Any, cast from agent_framework import ( - ChatMessage, Content, + Message, WorkflowContext, handler, response_handler, @@ -170,7 +170,7 @@ def _extract_json_from_response(text: str) -> Any: raise json.JSONDecodeError("No valid JSON found in response", text, 0) -def _validate_conversation_history(messages: list[ChatMessage], agent_name: str) -> None: +def _validate_conversation_history(messages: list[Message], agent_name: str) -> None: """Validate that conversation history has matching tool calls and results. This helps catch issues where tool call messages are stored without their @@ -263,7 +263,7 @@ class AgentResult: success: bool response: str agent_name: str - messages: list[ChatMessage] = field(default_factory=lambda: cast(list[ChatMessage], [])) + messages: list[Message] = field(default_factory=lambda: cast(list[Message], [])) tool_calls: list[Content] = field(default_factory=lambda: cast(list[Content], [])) error: str | None = None @@ -309,7 +309,7 @@ class AgentExternalInputRequest: agent_name: str agent_response: str iteration: int = 0 - messages: list[ChatMessage] = field(default_factory=lambda: cast(list[ChatMessage], [])) + messages: list[Message] = field(default_factory=lambda: cast(list[Message], [])) function_calls: list[Content] = field(default_factory=lambda: cast(list[Content], [])) @@ -340,7 +340,7 @@ class AgentExternalInputResponse: """ user_input: str - messages: list[ChatMessage] = field(default_factory=lambda: cast(list[ChatMessage], [])) + messages: list[Message] = field(default_factory=lambda: cast(list[Message], [])) function_results: dict[str, Content] = field(default_factory=lambda: cast(dict[str, Content], {})) @@ -637,29 +637,41 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor): Tuple of (accumulated_response, all_messages, tool_calls) """ accumulated_response = "" - all_messages: list[ChatMessage] = [] + all_messages: list[Message] = [] tool_calls: list[Content] = [] # Add user input to conversation history first (via state.append only) if input_text: - user_message = ChatMessage(role="user", text=input_text) + user_message = Message(role="user", text=input_text) state.append(messages_path, user_message) # Get conversation history from state AFTER adding user message # Note: We get a fresh copy to avoid mutation issues - conversation_history: list[ChatMessage] = state.get(messages_path) or [] + conversation_history: list[Message] = state.get(messages_path) or [] # Build messages list for agent (use history if available, otherwise just input) - messages_for_agent: list[ChatMessage] | str = conversation_history if conversation_history else input_text + messages_for_agent: list[Message] | str = conversation_history if conversation_history else input_text # Validate conversation history before invoking agent 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: @@ -672,7 +684,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor): if not isinstance(result, str): result_messages: Any = getattr(result, "messages", None) if result_messages is not None: - all_messages = list(cast(list[ChatMessage], result_messages)) + all_messages = list(cast(list[Message], result_messages)) result_tool_calls: Any = getattr(result, "tool_calls", None) if result_tool_calls is not None: tool_calls = list(cast(list[Content], result_tool_calls)) @@ -707,7 +719,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor): "Agent '%s': No messages in response, creating simple assistant message", agent_name, ) - assistant_message = ChatMessage(role="assistant", text=accumulated_response) + assistant_message = Message(role="assistant", text=accumulated_response) state.append(messages_path, assistant_message) # Store results in state - support both schema formats: diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py index c3cfff1d21..576ef73ac6 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py @@ -73,8 +73,8 @@ class WorkflowFactory: from agent_framework.declarative import WorkflowFactory # Pre-register agents for InvokeAzureAgent actions - chat_client = AzureOpenAIChatClient() - agent = chat_client.as_agent(name="MyAgent", instructions="You are helpful.") + client = AzureOpenAIChatClient() + agent = client.as_agent(name="MyAgent", instructions="You are helpful.") factory = WorkflowFactory(agents={"MyAgent": agent}) workflow = factory.create_workflow_from_yaml_path("workflow.yaml") @@ -517,7 +517,7 @@ class WorkflowFactory: Args: name: The name to register the agent under. Must match the agent name referenced in InvokeAzureAgent actions. - agent: The agent instance (typically a ChatAgent or similar). + agent: The agent instance (typically a Agent or similar). Returns: Self for method chaining. 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/agent_framework_declarative/_workflows/_state.py b/python/packages/declarative/agent_framework_declarative/_workflows/_state.py index 7d1f9e4945..31ad4124da 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_state.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_state.py @@ -314,7 +314,7 @@ class WorkflowState: """Add a message to the conversation history. Args: - message: The message to add (typically a ChatMessage or similar) + message: The message to add (typically a Message or similar) """ self._conversation["messages"].append(message) self._conversation["history"].append(message) diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index d8dcfa2f5f..f063338d33 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "powerfx>=0.0.31; python_version < '3.14'", "pyyaml>=6.0,<7.0", ] diff --git a/python/packages/declarative/tests/test_declarative_loader.py b/python/packages/declarative/tests/test_declarative_loader.py index 2d31a66d58..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 @@ -483,7 +485,7 @@ class TestAgentFactoryCreateFromDict: mock_client = MagicMock() mock_client.create_agent.return_value = MagicMock() - factory = AgentFactory(chat_client=mock_client) + factory = AgentFactory(client=mock_client) agent = factory.create_agent_from_dict(agent_def) assert agent is not None @@ -512,7 +514,7 @@ instructions: You are a helpful assistant. mock_client = MagicMock() mock_client.create_agent.return_value = MagicMock() - factory = AgentFactory(chat_client=mock_client) + factory = AgentFactory(client=mock_client) # Create from YAML string agent_from_yaml = factory.create_agent_from_yaml(yaml_content) @@ -540,7 +542,7 @@ instructions: You are a helpful assistant. factory.create_agent_from_dict(agent_def) def test_create_agent_from_dict_without_model_or_client_raises(self): - """Test that missing both model and chat_client raises DeclarativeLoaderError.""" + """Test that missing both model and client raises DeclarativeLoaderError.""" from agent_framework_declarative import AgentFactory from agent_framework_declarative._loader import DeclarativeLoaderError @@ -592,7 +594,7 @@ instructions: Hello world monkeypatch.setenv("TEST_DESCRIPTION", "Description from env") # With safe_mode=True (default), Env access should fail and return original value - factory = AgentFactory(chat_client=mock_client, safe_mode=True) + factory = AgentFactory(client=mock_client, safe_mode=True) agent = factory.create_agent_from_yaml(yaml_content) # The description should NOT be resolved from env (PowerFx fails, returns original) @@ -618,7 +620,7 @@ instructions: Hello world """ # With safe_mode=False, Env access should work - factory = AgentFactory(chat_client=mock_client, safe_mode=False) + factory = AgentFactory(client=mock_client, safe_mode=False) agent = factory.create_agent_from_yaml(yaml_content) # The description should be resolved from env @@ -698,11 +700,9 @@ class TestAgentFactoryMcpToolConnection: """Tests for MCP tool connection handling in AgentFactory._parse_tool.""" def _get_mcp_tools(self, agent): - """Helper to get MCP tools from agent's default_options.""" - from agent_framework import HostedMCPTool - + """Helper to get MCP dict tools from agent's default_options.""" tools = agent.default_options.get("tools", []) - return [t for t in tools if isinstance(t, HostedMCPTool)] + return [t for t in tools if isinstance(t, dict) and t.get("type") == "mcp"] def test_mcp_tool_with_api_key_connection_sets_headers(self): """Test that MCP tool with ApiKeyConnection sets headers correctly.""" @@ -726,7 +726,7 @@ tools: mock_client = MagicMock() mock_client.create_agent.return_value = MagicMock() - factory = AgentFactory(chat_client=mock_client) + factory = AgentFactory(client=mock_client) agent = factory.create_agent_from_yaml(yaml_content) # Find the MCP tool in the agent's tools @@ -735,11 +735,11 @@ tools: mcp_tool = mcp_tools[0] # Verify headers are set with the API key - assert mcp_tool.headers is not None - assert mcp_tool.headers == {"Authorization": "Bearer my-secret-api-key"} + assert mcp_tool.get("headers") is not None + assert mcp_tool.get("headers") == {"Authorization": "Bearer my-secret-api-key"} def test_mcp_tool_with_remote_connection_sets_additional_properties(self): - """Test that MCP tool with RemoteConnection sets additional_properties correctly.""" + """Test that MCP tool with RemoteConnection sets project_connection_id correctly.""" from unittest.mock import MagicMock from agent_framework_declarative import AgentFactory @@ -761,7 +761,7 @@ tools: mock_client = MagicMock() mock_client.create_agent.return_value = MagicMock() - factory = AgentFactory(chat_client=mock_client) + factory = AgentFactory(client=mock_client) agent = factory.create_agent_from_yaml(yaml_content) # Find the MCP tool in the agent's tools @@ -769,16 +769,11 @@ tools: assert len(mcp_tools) == 1 mcp_tool = mcp_tools[0] - # Verify additional_properties are set with connection info - assert mcp_tool.additional_properties is not None - assert "connection" in mcp_tool.additional_properties - conn = mcp_tool.additional_properties["connection"] - assert conn["kind"] == "remote" - assert conn["authenticationMode"] == "oauth" - assert conn["name"] == "github-mcp-oauth-connection" + # Verify project_connection_id is set from connection name + assert mcp_tool.get("project_connection_id") == "github-mcp-oauth-connection" def test_mcp_tool_with_reference_connection_sets_additional_properties(self): - """Test that MCP tool with ReferenceConnection sets additional_properties correctly.""" + """Test that MCP tool with ReferenceConnection sets project_connection_id correctly.""" from unittest.mock import MagicMock from agent_framework_declarative import AgentFactory @@ -800,7 +795,7 @@ tools: mock_client = MagicMock() mock_client.create_agent.return_value = MagicMock() - factory = AgentFactory(chat_client=mock_client) + factory = AgentFactory(client=mock_client) agent = factory.create_agent_from_yaml(yaml_content) # Find the MCP tool in the agent's tools @@ -808,15 +803,11 @@ tools: assert len(mcp_tools) == 1 mcp_tool = mcp_tools[0] - # Verify additional_properties are set with connection info - assert mcp_tool.additional_properties is not None - assert "connection" in mcp_tool.additional_properties - conn = mcp_tool.additional_properties["connection"] - assert conn["kind"] == "reference" - assert conn["name"] == "my-connection-ref" + # Verify project_connection_id is set from connection name + assert mcp_tool.get("project_connection_id") == "my-connection-ref" def test_mcp_tool_with_anonymous_connection_no_headers_or_properties(self): - """Test that MCP tool with AnonymousConnection doesn't set headers or additional_properties.""" + """Test that MCP tool with AnonymousConnection doesn't set headers or project_connection_id.""" from unittest.mock import MagicMock from agent_framework_declarative import AgentFactory @@ -836,7 +827,7 @@ tools: mock_client = MagicMock() mock_client.create_agent.return_value = MagicMock() - factory = AgentFactory(chat_client=mock_client) + factory = AgentFactory(client=mock_client) agent = factory.create_agent_from_yaml(yaml_content) # Find the MCP tool in the agent's tools @@ -844,9 +835,9 @@ tools: assert len(mcp_tools) == 1 mcp_tool = mcp_tools[0] - # Verify no headers or additional_properties are set - assert mcp_tool.headers is None - assert mcp_tool.additional_properties is None + # Verify no headers or project_connection_id are set + assert mcp_tool.get("headers") is None + assert mcp_tool.get("project_connection_id") is None def test_mcp_tool_without_connection_preserves_existing_behavior(self): """Test that MCP tool without connection works as before (no headers or additional_properties).""" @@ -868,7 +859,7 @@ tools: mock_client = MagicMock() mock_client.create_agent.return_value = MagicMock() - factory = AgentFactory(chat_client=mock_client) + factory = AgentFactory(client=mock_client) agent = factory.create_agent_from_yaml(yaml_content) # Find the MCP tool in the agent's tools @@ -877,14 +868,13 @@ tools: mcp_tool = mcp_tools[0] # Verify tool is created correctly without connection - assert mcp_tool.name == "simple-mcp-tool" - assert str(mcp_tool.url) == "https://api.example.com/mcp" - assert mcp_tool.approval_mode == "never_require" - assert mcp_tool.headers is None - assert mcp_tool.additional_properties is None + assert mcp_tool["server_label"] == "simple-mcp-tool" + assert mcp_tool["server_url"] == "https://api.example.com/mcp" + assert mcp_tool.get("require_approval") == "never" + assert mcp_tool.get("headers") is None def test_mcp_tool_with_remote_connection_with_endpoint(self): - """Test that MCP tool with RemoteConnection including endpoint sets it in additional_properties.""" + """Test that MCP tool with RemoteConnection including endpoint sets project_connection_id.""" from unittest.mock import MagicMock from agent_framework_declarative import AgentFactory @@ -907,7 +897,7 @@ tools: mock_client = MagicMock() mock_client.create_agent.return_value = MagicMock() - factory = AgentFactory(chat_client=mock_client) + factory = AgentFactory(client=mock_client) agent = factory.create_agent_from_yaml(yaml_content) # Find the MCP tool in the agent's tools @@ -915,7 +905,107 @@ tools: assert len(mcp_tools) == 1 mcp_tool = mcp_tools[0] - # Verify additional_properties include endpoint - assert mcp_tool.additional_properties is not None - conn = mcp_tool.additional_properties["connection"] - assert conn["endpoint"] == "https://auth.example.com" + # 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_coverage.py b/python/packages/declarative/tests/test_graph_coverage.py index fd01faf2a4..cf622f6467 100644 --- a/python/packages/declarative/tests/test_graph_coverage.py +++ b/python/packages/declarative/tests/test_graph_coverage.py @@ -1835,8 +1835,8 @@ class TestAgentExternalLoopCoverage: } executor = InvokeAzureAgentExecutor(action_def, agents={"TestAgent": mock_agent}) - # Mock the internal method to avoid storing ChatMessage objects in state - # (PowerFx cannot serialize ChatMessage) + # Mock the internal method to avoid storing Message objects in state + # (PowerFx cannot serialize Message) with patch.object( executor, "_invoke_agent_and_store_results", @@ -2012,7 +2012,9 @@ class TestBuilderControlFlowCreation: # Create builder with minimal yaml definition yaml_def = {"name": "test_workflow", "actions": []} graph_builder = DeclarativeWorkflowBuilder(yaml_def) - wb = WorkflowBuilder(start_executor="dummy") + from agent_framework_declarative._workflows._executors_control_flow import JoinExecutor + + wb = WorkflowBuilder(start_executor=JoinExecutor({"kind": "Dummy"}, id="dummy")) action_def = { "kind": "GotoAction", @@ -2036,7 +2038,9 @@ class TestBuilderControlFlowCreation: yaml_def = {"name": "test_workflow", "actions": []} graph_builder = DeclarativeWorkflowBuilder(yaml_def) - wb = WorkflowBuilder(start_executor="dummy") + from agent_framework_declarative._workflows._executors_control_flow import JoinExecutor + + wb = WorkflowBuilder(start_executor=JoinExecutor({"kind": "Dummy"}, id="dummy")) action_def = { "kind": "GotoAction", @@ -2056,7 +2060,9 @@ class TestBuilderControlFlowCreation: yaml_def = {"name": "test_workflow", "actions": []} graph_builder = DeclarativeWorkflowBuilder(yaml_def) - wb = WorkflowBuilder(start_executor="dummy") + from agent_framework_declarative._workflows._executors_control_flow import JoinExecutor + + wb = WorkflowBuilder(start_executor=JoinExecutor({"kind": "Dummy"}, id="dummy")) action_def = { "kind": "GotoAction", @@ -2094,7 +2100,9 @@ class TestBuilderControlFlowCreation: yaml_def = {"name": "test_workflow", "actions": []} graph_builder = DeclarativeWorkflowBuilder(yaml_def) - wb = WorkflowBuilder(start_executor="dummy") + from agent_framework_declarative._workflows._executors_control_flow import JoinExecutor + + wb = WorkflowBuilder(start_executor=JoinExecutor({"kind": "Dummy"}, id="dummy")) # Create a mock loop_next executor loop_next = ForeachNextExecutor( @@ -2124,7 +2132,9 @@ class TestBuilderControlFlowCreation: yaml_def = {"name": "test_workflow", "actions": []} graph_builder = DeclarativeWorkflowBuilder(yaml_def) - wb = WorkflowBuilder(start_executor="dummy") + from agent_framework_declarative._workflows._executors_control_flow import JoinExecutor + + wb = WorkflowBuilder(start_executor=JoinExecutor({"kind": "Dummy"}, id="dummy")) action_def = { "kind": "BreakLoop", @@ -2149,7 +2159,9 @@ class TestBuilderControlFlowCreation: yaml_def = {"name": "test_workflow", "actions": []} graph_builder = DeclarativeWorkflowBuilder(yaml_def) - wb = WorkflowBuilder(start_executor="dummy") + from agent_framework_declarative._workflows._executors_control_flow import JoinExecutor + + wb = WorkflowBuilder(start_executor=JoinExecutor({"kind": "Dummy"}, id="dummy")) # Create a mock loop_next executor loop_next = ForeachNextExecutor( @@ -2179,7 +2191,9 @@ class TestBuilderControlFlowCreation: yaml_def = {"name": "test_workflow", "actions": []} graph_builder = DeclarativeWorkflowBuilder(yaml_def) - wb = WorkflowBuilder(start_executor="dummy") + from agent_framework_declarative._workflows._executors_control_flow import JoinExecutor + + wb = WorkflowBuilder(start_executor=JoinExecutor({"kind": "Dummy"}, id="dummy")) action_def = { "kind": "ContinueLoop", @@ -2203,7 +2217,9 @@ class TestBuilderEdgeWiring: yaml_def = {"name": "test_workflow", "actions": []} graph_builder = DeclarativeWorkflowBuilder(yaml_def) - wb = WorkflowBuilder(start_executor="dummy") + from agent_framework_declarative._workflows._executors_control_flow import JoinExecutor + + wb = WorkflowBuilder(start_executor=JoinExecutor({"kind": "Dummy"}, id="dummy")) # Create a mock source executor source = SendActivityExecutor({"kind": "SendActivity", "activity": {"text": "test"}}, id="source") @@ -2236,7 +2252,9 @@ class TestBuilderEdgeWiring: yaml_def = {"name": "test_workflow", "actions": []} graph_builder = DeclarativeWorkflowBuilder(yaml_def) - wb = WorkflowBuilder(start_executor="dummy") + from agent_framework_declarative._workflows._executors_control_flow import JoinExecutor + + wb = WorkflowBuilder(start_executor=JoinExecutor({"kind": "Dummy"}, id="dummy")) source = SendActivityExecutor({"kind": "SendActivity", "activity": {"text": "source"}}, id="source") target = SendActivityExecutor({"kind": "SendActivity", "activity": {"text": "target"}}, id="target") 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_graph_workflow_integration.py b/python/packages/declarative/tests/test_graph_workflow_integration.py index 00ee3a154f..b7bd08e60a 100644 --- a/python/packages/declarative/tests/test_graph_workflow_integration.py +++ b/python/packages/declarative/tests/test_graph_workflow_integration.py @@ -223,11 +223,11 @@ class TestGraphWorkflowCheckpointing: builder = DeclarativeWorkflowBuilder(yaml_def) _workflow = builder.build() # noqa: F841 - # Verify multiple executors were created + # Verify multiple executors were created (+ _workflow_entry node) assert "step1" in builder._executors assert "step2" in builder._executors assert "step3" in builder._executors - assert len(builder._executors) == 3 + assert len(builder._executors) == 4 def test_workflow_executor_connectivity(self): """Test that executors are properly connected in sequence.""" @@ -243,8 +243,8 @@ class TestGraphWorkflowCheckpointing: builder = DeclarativeWorkflowBuilder(yaml_def) workflow = builder.build() - # Verify all executors exist - assert len(builder._executors) == 3 + # Verify all executors exist (+ _workflow_entry node) + assert len(builder._executors) == 4 # Verify the workflow can be inspected assert workflow is not None diff --git a/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py b/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py index 8f0cd39d31..72b2d398c5 100644 --- a/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py +++ b/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py @@ -3,7 +3,7 @@ """Tests to ensure PowerFx evaluation supports all expressions used in declarative YAML workflows. This test suite validates that all PowerFx expressions found in the sample YAML workflows -under samples/getting_started/workflows/declarative/ work correctly with our implementation. +under samples/03-workflows/declarative/ work correctly with our implementation. Coverage includes: - Built-in PowerFx functions: Concat, If, IsBlank, Not, Or, Upper, Find 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/AGENTS.md b/python/packages/devui/AGENTS.md index c478c11e2d..5213095244 100644 --- a/python/packages/devui/AGENTS.md +++ b/python/packages/devui/AGENTS.md @@ -20,7 +20,7 @@ Interactive developer UI for testing and debugging agents and workflows. ```python from agent_framework.devui import serve -agent = ChatAgent(...) +agent = Agent(...) serve(entities=[agent], port=8080, auto_open=True) ``` diff --git a/python/packages/devui/README.md b/python/packages/devui/README.md index f984c56799..eca7272cfd 100644 --- a/python/packages/devui/README.md +++ b/python/packages/devui/README.md @@ -17,7 +17,7 @@ pip install agent-framework-devui --pre You can also launch it programmatically ```python -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.openai import OpenAIChatClient from agent_framework.devui import serve @@ -26,9 +26,9 @@ def get_weather(location: str) -> str: return f"Weather in {location}: 72°F and sunny" # Create your agent -agent = ChatAgent( +agent = Agent( name="WeatherAgent", - chat_client=OpenAIChatClient(), + client=OpenAIChatClient(), tools=[get_weather] ) @@ -55,8 +55,8 @@ When DevUI starts with no discovered entities, it displays a **sample entity gal ```python # ✅ Correct - DevUI handles cleanup automatically -mcp_tool = MCPStreamableHTTPTool(url="http://localhost:8011/mcp", chat_client=chat_client) -agent = ChatAgent(tools=mcp_tool) +mcp_tool = MCPStreamableHTTPTool(url="http://localhost:8011/mcp", client=client) +agent = Agent(tools=mcp_tool) serve(entities=[agent]) ``` @@ -68,13 +68,13 @@ Register cleanup hooks to properly close credentials and resources on shutdown: ```python from azure.identity.aio import DefaultAzureCredential -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.azure import AzureOpenAIChatClient from agent_framework_devui import register_cleanup, serve credential = DefaultAzureCredential() client = AzureOpenAIChatClient() -agent = ChatAgent(name="MyAgent", chat_client=client) +agent = Agent(name="MyAgent", client=client) # Register cleanup hook - credential will be closed on shutdown register_cleanup(agent, credential.close) @@ -92,7 +92,7 @@ For your agents to be discovered by the DevUI, they must be organized in a direc ``` agents/ ├── weather_agent/ -│ ├── __init__.py # Must export: agent = ChatAgent(...) +│ ├── __init__.py # Must export: agent = Agent(...) │ ├── agent.py │ └── .env # Optional: API keys, config vars ├── my_workflow/ @@ -373,7 +373,7 @@ This restricts developer APIs (reload, deployment, entity details) and requires ## Examples -See working implementations in `python/samples/getting_started/devui/` +See working implementations in `python/samples/02-agents/devui/` ## License diff --git a/python/packages/devui/agent_framework_devui/__init__.py b/python/packages/devui/agent_framework_devui/__init__.py index 50010cd9cd..f703e85a63 100644 --- a/python/packages/devui/agent_framework_devui/__init__.py +++ b/python/packages/devui/agent_framework_devui/__init__.py @@ -41,7 +41,7 @@ def register_cleanup(entity: Any, *hooks: Callable[[], Any]) -> None: Single cleanup hook: >>> from agent_framework.devui import serve, register_cleanup >>> credential = DefaultAzureCredential() - >>> agent = ChatAgent(...) + >>> agent = Agent(...) >>> register_cleanup(agent, credential.close) >>> serve(entities=[agent]) @@ -52,7 +52,7 @@ def register_cleanup(entity: Any, *hooks: Callable[[], Any]) -> None: >>> # In agents/my_agent/agent.py >>> from agent_framework.devui import register_cleanup >>> credential = DefaultAzureCredential() - >>> agent = ChatAgent(...) + >>> agent = Agent(...) >>> register_cleanup(agent, credential.close) >>> # Run: devui ./agents """ diff --git a/python/packages/devui/agent_framework_devui/_conversations.py b/python/packages/devui/agent_framework_devui/_conversations.py index 6b271ddff5..f0e91e0d87 100644 --- a/python/packages/devui/agent_framework_devui/_conversations.py +++ b/python/packages/devui/agent_framework_devui/_conversations.py @@ -3,7 +3,7 @@ """Conversation storage abstraction for OpenAI Conversations API. This module provides a clean abstraction layer for managing conversations -while wrapping AgentFramework's AgentThread underneath. +with in-memory message storage. """ from __future__ import annotations @@ -13,11 +13,11 @@ import uuid from abc import ABC, abstractmethod from typing import Any, Literal, cast -from agent_framework import AgentThread, ChatMessage -from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage +from agent_framework import AgentSession, Message +from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage, WorkflowCheckpoint from openai.types.conversations import Conversation, ConversationDeletedResource from openai.types.conversations.conversation_item import ConversationItem -from openai.types.conversations.message import Message +from openai.types.conversations.message import Message as OpenAIMessage from openai.types.conversations.text_content import TextContent from openai.types.responses import ( ResponseFunctionToolCallItem, @@ -38,14 +38,14 @@ class ConversationStore(ABC): """Abstract base class for conversation storage. Provides OpenAI Conversations API interface while managing - AgentThread instances underneath. + message storage internally. """ @abstractmethod def create_conversation( self, metadata: dict[str, str] | None = None, conversation_id: str | None = None ) -> Conversation: - """Create a new conversation (wraps AgentThread creation). + """Create a new conversation. Args: metadata: Optional metadata dict (e.g., {"agent_id": "weather_agent"}) @@ -86,7 +86,7 @@ class ConversationStore(ABC): @abstractmethod def delete_conversation(self, conversation_id: str) -> ConversationDeletedResource: - """Delete conversation (including AgentThread). + """Delete conversation. Args: conversation_id: Conversation ID @@ -101,7 +101,7 @@ class ConversationStore(ABC): @abstractmethod async def add_items(self, conversation_id: str, items: list[dict[str, Any]]) -> list[ConversationItem]: - """Add items to conversation (syncs to AgentThread.message_store). + """Add items to conversation. Args: conversation_id: Conversation ID @@ -119,7 +119,7 @@ class ConversationStore(ABC): async def list_items( self, conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc" ) -> tuple[list[ConversationItem], bool]: - """List conversation items from AgentThread.message_store. + """List conversation items. Args: conversation_id: Conversation ID @@ -152,17 +152,17 @@ class ConversationStore(ABC): pass @abstractmethod - def get_thread(self, conversation_id: str) -> AgentThread | None: - """Get underlying AgentThread for execution (internal use). + def get_session(self, conversation_id: str) -> AgentSession | None: + """Get AgentSession for agent execution. This is the critical method that allows the executor to get the - AgentThread for running agents with conversation context. + AgentSession for running agents with conversation context. Args: conversation_id: Conversation ID Returns: - AgentThread object or None if not found + AgentSession object or None if not found """ pass @@ -183,7 +183,7 @@ class ConversationStore(ABC): """Add a trace event to the conversation for context inspection. Traces capture execution metadata like token usage, timing, and LLM context - that isn't stored in the AgentThread but is useful for debugging. + that is useful for debugging. Args: conversation_id: Conversation ID @@ -205,17 +205,17 @@ class ConversationStore(ABC): class InMemoryConversationStore(ConversationStore): - """In-memory conversation storage wrapping AgentThread. + """In-memory conversation storage. This implementation stores conversations in memory with their - underlying AgentThread instances for execution. + underlying message lists and AgentSession instances for execution. """ def __init__(self) -> None: """Initialize in-memory conversation storage. Storage structure maps conversation IDs to conversation data including - the underlying AgentThread, metadata, and cached ConversationItems. + messages, metadata, and cached ConversationItems. """ self._conversations: dict[str, dict[str, Any]] = {} @@ -225,20 +225,22 @@ class InMemoryConversationStore(ConversationStore): def create_conversation( self, metadata: dict[str, str] | None = None, conversation_id: str | None = None ) -> Conversation: - """Create a new conversation with underlying AgentThread and checkpoint storage.""" + """Create a new conversation with message storage and checkpoint storage.""" conv_id = conversation_id or f"conv_{uuid.uuid4().hex}" created_at = int(time.time()) - # Create AgentThread with default ChatMessageStore - thread = AgentThread() + # Create message list for internal storage and AgentSession for execution + messages: list[Message] = [] + session = AgentSession(session_id=conv_id) # Create session-scoped checkpoint storage (one per conversation) checkpoint_storage = InMemoryCheckpointStorage() self._conversations[conv_id] = { "id": conv_id, - "thread": thread, - "checkpoint_storage": checkpoint_storage, # Stored alongside thread + "messages": messages, + "session": session, + "checkpoint_storage": checkpoint_storage, "metadata": metadata or {}, "created_at": created_at, "items": [], @@ -279,7 +281,7 @@ class InMemoryConversationStore(ConversationStore): ) def delete_conversation(self, conversation_id: str) -> ConversationDeletedResource: - """Delete conversation and its AgentThread.""" + """Delete conversation.""" if conversation_id not in self._conversations: raise ValueError(f"Conversation {conversation_id} not found") @@ -290,14 +292,14 @@ class InMemoryConversationStore(ConversationStore): return ConversationDeletedResource(id=conversation_id, object="conversation.deleted", deleted=True) async def add_items(self, conversation_id: str, items: list[dict[str, Any]]) -> list[ConversationItem]: - """Add items to conversation and sync to AgentThread.""" + """Add items to conversation.""" conv_data = self._conversations.get(conversation_id) if not conv_data: raise ValueError(f"Conversation {conversation_id} not found") - thread: AgentThread = conv_data["thread"] + stored_messages: list[Message] = conv_data["messages"] - # Convert items to ChatMessages and add to thread + # Convert items to Messages and add to storage chat_messages = [] for item in items: # Simple conversion - assume text content for now @@ -305,11 +307,11 @@ class InMemoryConversationStore(ConversationStore): content = item.get("content", []) text = content[0].get("text", "") if content else "" - chat_msg = ChatMessage(role=role, text=text) # type: ignore[arg-type] + chat_msg = Message(role=role, text=text) # type: ignore[arg-type] chat_messages.append(chat_msg) - # Add messages to AgentThread - await thread.on_new_messages(chat_messages) + # Add messages to internal storage + stored_messages.extend(chat_messages) # Create Message objects (ConversationItem is a Union - use concrete Message type) conv_items: list[ConversationItem] = [] @@ -320,7 +322,7 @@ class InMemoryConversationStore(ConversationStore): role_str = msg.role if hasattr(msg.role, "value") else str(msg.role) role = cast(MessageRole, role_str) # Safe: Agent Framework roles match OpenAI roles - # Convert ChatMessage contents to OpenAI TextContent format + # Convert Message contents to OpenAI TextContent format message_content = [] for content_item in msg.contents: if content_item.type == "text": @@ -329,7 +331,7 @@ class InMemoryConversationStore(ConversationStore): message_content.append(TextContent(type="text", text=text_value)) # Create Message object (concrete type from ConversationItem union) - message = Message( + message = OpenAIMessage( id=item_id, type="message", # Required discriminator for union role=role, @@ -354,9 +356,9 @@ class InMemoryConversationStore(ConversationStore): async def list_items( self, conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc" ) -> tuple[list[ConversationItem], bool]: - """List conversation items from AgentThread message store. + """List conversation items. - Converts AgentFramework ChatMessages to proper OpenAI ConversationItem types: + Converts stored Messages to proper OpenAI ConversationItem types: - Messages with text/images/files → Message - Function calls → ResponseFunctionToolCallItem - Function results → ResponseFunctionToolCallOutputItem @@ -365,125 +367,120 @@ class InMemoryConversationStore(ConversationStore): if not conv_data: raise ValueError(f"Conversation {conversation_id} not found") - thread: AgentThread = conv_data["thread"] + stored_messages: list[Message] = conv_data["messages"] - # Get messages from thread's message store + # Convert stored messages to ConversationItem types items: list[ConversationItem] = [] - if thread.message_store: - af_messages = await thread.message_store.list_messages() + af_messages = stored_messages - # Convert each AgentFramework ChatMessage to appropriate ConversationItem type(s) - for i, msg in enumerate(af_messages): - item_id = f"item_{i}" - role_str = msg.role if hasattr(msg.role, "value") else str(msg.role) - role = cast(MessageRole, role_str) # Safe: Agent Framework roles match OpenAI roles + # Convert each AgentFramework Message to appropriate ConversationItem type(s) + for i, msg in enumerate(af_messages): + item_id = f"item_{i}" + role_str = msg.role if hasattr(msg.role, "value") else str(msg.role) + role = cast(MessageRole, role_str) # Safe: Agent Framework roles match OpenAI roles - # Process each content item in the message - # A single ChatMessage may produce multiple ConversationItems - # (e.g., a message with both text and a function call) - message_contents: list[TextContent | ResponseInputImage | ResponseInputFile] = [] - function_calls = [] - function_results = [] + # Process each content item in the message + # A single Message may produce multiple ConversationItems + # (e.g., a message with both text and a function call) + message_contents: list[TextContent | ResponseInputImage | ResponseInputFile] = [] + function_calls = [] + function_results = [] - for content in msg.contents: - content_type = getattr(content, "type", None) + for content in msg.contents: + content_type = getattr(content, "type", None) - if content_type == "text": - # Text content for Message - text_value = getattr(content, "text", "") - message_contents.append(TextContent(type="text", text=text_value)) + if content_type == "text": + # Text content for Message + text_value = getattr(content, "text", "") + message_contents.append(TextContent(type="text", text=text_value)) - elif content_type == "data": - # Data content (images, files, PDFs) - uri = getattr(content, "uri", "") - media_type = getattr(content, "media_type", None) + elif content_type == "data": + # Data content (images, files, PDFs) + uri = getattr(content, "uri", "") + media_type = getattr(content, "media_type", None) - if media_type and media_type.startswith("image/"): - # Convert to ResponseInputImage - message_contents.append( - ResponseInputImage(type="input_image", image_url=uri, detail="auto") + if media_type and media_type.startswith("image/"): + # Convert to ResponseInputImage + message_contents.append(ResponseInputImage(type="input_image", image_url=uri, detail="auto")) + else: + # Convert to ResponseInputFile + # Extract filename from URI if possible + filename = None + if media_type == "application/pdf": + filename = "document.pdf" + + message_contents.append(ResponseInputFile(type="input_file", file_url=uri, filename=filename)) + + elif content_type == "function_call": + # Function call - create separate ConversationItem + call_id = getattr(content, "call_id", None) + name = getattr(content, "name", "") + arguments = getattr(content, "arguments", "") + + if call_id and name: + function_calls.append( + ResponseFunctionToolCallItem( + id=f"{item_id}_call_{call_id}", + call_id=call_id, + name=name, + arguments=arguments, + type="function_call", + status="completed", ) - else: - # Convert to ResponseInputFile - # Extract filename from URI if possible - filename = None - if media_type == "application/pdf": - filename = "document.pdf" + ) - message_contents.append( - ResponseInputFile(type="input_file", file_url=uri, filename=filename) + elif content_type == "function_result": + # Function result - create separate ConversationItem + call_id = getattr(content, "call_id", None) + # Output is stored in the 'result' field of FunctionResultContent + result_value = getattr(content, "result", None) + # Convert result to string (it could be dict, list, or other types) + if result_value is None: + output = "" + elif isinstance(result_value, str): + output = result_value + else: + import json + + try: + output = json.dumps(result_value) + except (TypeError, ValueError): + output = str(result_value) + + if call_id: + function_results.append( + ResponseFunctionToolCallOutputItem( + id=f"{item_id}_result_{call_id}", + call_id=call_id, + output=output, + type="function_call_output", + status="completed", ) + ) - elif content_type == "function_call": - # Function call - create separate ConversationItem - call_id = getattr(content, "call_id", None) - name = getattr(content, "name", "") - arguments = getattr(content, "arguments", "") + # Create ConversationItems based on what we found + # If message has text/images/files, create a Message item + if message_contents: + message = OpenAIMessage( + id=item_id, + type="message", + role=role, # type: ignore + content=message_contents, # type: ignore + status="completed", + ) + items.append(message) - if call_id and name: - function_calls.append( - ResponseFunctionToolCallItem( - id=f"{item_id}_call_{call_id}", - call_id=call_id, - name=name, - arguments=arguments, - type="function_call", - status="completed", - ) - ) + # Add function call items + items.extend(function_calls) - elif content_type == "function_result": - # Function result - create separate ConversationItem - call_id = getattr(content, "call_id", None) - # Output is stored in the 'result' field of FunctionResultContent - result_value = getattr(content, "result", None) - # Convert result to string (it could be dict, list, or other types) - if result_value is None: - output = "" - elif isinstance(result_value, str): - output = result_value - else: - import json - - try: - output = json.dumps(result_value) - except (TypeError, ValueError): - output = str(result_value) - - if call_id: - function_results.append( - ResponseFunctionToolCallOutputItem( - id=f"{item_id}_result_{call_id}", - call_id=call_id, - output=output, - type="function_call_output", - status="completed", - ) - ) - - # Create ConversationItems based on what we found - # If message has text/images/files, create a Message item - if message_contents: - message = Message( - id=item_id, - type="message", - role=role, # type: ignore - content=message_contents, # type: ignore - status="completed", - ) - items.append(message) - - # Add function call items - items.extend(function_calls) - - # Add function result items - items.extend(function_results) + # Add function result items + items.extend(function_results) # Include checkpoints from checkpoint storage as conversation items checkpoint_storage = conv_data.get("checkpoint_storage") if checkpoint_storage: # Get all checkpoints for this conversation - checkpoints = await checkpoint_storage.list_checkpoints() + checkpoints = self._list_all_checkpoints(checkpoint_storage) for checkpoint in checkpoints: # Create a conversation item for each checkpoint with summary metadata # Full checkpoint state is NOT included here (too large for list view) @@ -498,7 +495,9 @@ class InMemoryConversationStore(ConversationStore): "id": f"checkpoint_{checkpoint.checkpoint_id}", "type": "checkpoint", "checkpoint_id": checkpoint.checkpoint_id, - "workflow_id": checkpoint.workflow_id, + # Keep workflow_id for backward compatibility with existing UI payloads. + "workflow_id": checkpoint.workflow_name, + "workflow_name": checkpoint.workflow_name, "timestamp": checkpoint.timestamp, "status": "completed", "metadata": { @@ -509,6 +508,7 @@ class InMemoryConversationStore(ConversationStore): "message_count": sum(len(msgs) for msgs in checkpoint.messages.values()), "size_bytes": checkpoint_size, "version": checkpoint.version, + "graph_signature_hash": checkpoint.graph_signature_hash, }, } items.append(cast(ConversationItem, checkpoint_item)) @@ -554,8 +554,9 @@ class InMemoryConversationStore(ConversationStore): return None # Load full checkpoint from storage - checkpoint = await checkpoint_storage.load_checkpoint(checkpoint_id) - if not checkpoint: + try: + checkpoint = await checkpoint_storage.load(checkpoint_id) + except Exception: return None # Calculate size of checkpoint @@ -569,7 +570,9 @@ class InMemoryConversationStore(ConversationStore): "id": item_id, "type": "checkpoint", "checkpoint_id": checkpoint.checkpoint_id, - "workflow_id": checkpoint.workflow_id, + # Keep workflow_id for backward compatibility with existing UI payloads. + "workflow_id": checkpoint.workflow_name, + "workflow_name": checkpoint.workflow_name, "timestamp": checkpoint.timestamp, "status": "completed", "metadata": { @@ -580,6 +583,7 @@ class InMemoryConversationStore(ConversationStore): "message_count": sum(len(msgs) for msgs in checkpoint.messages.values()), "size_bytes": checkpoint_size, "version": checkpoint.version, + "graph_signature_hash": checkpoint.graph_signature_hash, # 🔥 FULL checkpoint state (lazy loaded) "full_checkpoint": checkpoint.to_dict(), }, @@ -589,16 +593,16 @@ class InMemoryConversationStore(ConversationStore): return None - def get_thread(self, conversation_id: str) -> AgentThread | None: - """Get AgentThread for execution - CRITICAL for agent.run().""" + def get_session(self, conversation_id: str) -> AgentSession | None: + """Get AgentSession for execution - CRITICAL for agent.run().""" conv_data = self._conversations.get(conversation_id) - return conv_data["thread"] if conv_data else None + return conv_data["session"] if conv_data else None def add_trace(self, conversation_id: str, trace_event: dict[str, Any]) -> None: """Add a trace event to the conversation for context inspection. Traces capture execution metadata like token usage, timing, and LLM context - that isn't stored in the AgentThread but is useful for debugging. + that is useful for debugging. Args: conversation_id: Conversation ID @@ -634,8 +638,8 @@ class InMemoryConversationStore(ConversationStore): if conv_meta.get("type") == "workflow_session": checkpoint_storage = conv_data.get("checkpoint_storage") if checkpoint_storage: - checkpoints = await checkpoint_storage.list_checkpoints() - latest = checkpoints[0] if checkpoints else None + checkpoints = self._list_all_checkpoints(checkpoint_storage) + latest = max(checkpoints, key=lambda cp: cp.timestamp) if checkpoints else None conv_meta["checkpoint_summary"] = { "count": len(checkpoints), "latest_iteration": latest.iteration_count if latest else 0, @@ -657,6 +661,19 @@ class InMemoryConversationStore(ConversationStore): return results + @staticmethod + def _list_all_checkpoints(checkpoint_storage: Any) -> list[WorkflowCheckpoint]: + """Return all checkpoints from a conversation-scoped storage instance. + + DevUI uses one checkpoint storage per conversation. Core storage APIs now + require workflow_name filters, so we gather directly from in-memory storage + internals to provide conversation-wide listing for UI views. + """ + checkpoint_map = getattr(checkpoint_storage, "_checkpoints", None) + if isinstance(checkpoint_map, dict): + return list(cast(dict[str, WorkflowCheckpoint], checkpoint_map).values()) + return [] + class CheckpointConversationManager: """Manages checkpoint storage for workflow sessions - SESSION-SCOPED. diff --git a/python/packages/devui/agent_framework_devui/_discovery.py b/python/packages/devui/agent_framework_devui/_discovery.py index 8058d31083..a5fada1ba9 100644 --- a/python/packages/devui/agent_framework_devui/_discovery.py +++ b/python/packages/devui/agent_framework_devui/_discovery.py @@ -541,7 +541,7 @@ class EntityDiscovery: """Check if a Python file has entity exports (agent or workflow) using AST parsing. This safely checks for module-level assignments like: - - agent = ChatAgent(...) + - agent = Agent(...) - workflow = WorkflowBuilder(start_executor=...)... Args: diff --git a/python/packages/devui/agent_framework_devui/_executor.py b/python/packages/devui/agent_framework_devui/_executor.py index c70c123983..e019917630 100644 --- a/python/packages/devui/agent_framework_devui/_executor.py +++ b/python/packages/devui/agent_framework_devui/_executor.py @@ -305,23 +305,23 @@ class AgentFrameworkExecutor: yield AgentStartedEvent() - # Convert input to proper ChatMessage or string + # Convert input to proper Message or string user_message = self._convert_input_to_chat_message(request.input) - # Get thread from conversation parameter (OpenAI standard!) - thread = None + # Get session from conversation parameter (OpenAI standard!) + session = None conversation_id = request._get_conversation_id() if conversation_id: - thread = self.conversation_store.get_thread(conversation_id) - if thread: + session = self.conversation_store.get_session(conversation_id) + if session: logger.debug(f"Using existing conversation: {conversation_id}") else: - logger.warning(f"Conversation {conversation_id} not found, proceeding without thread") + logger.warning(f"Conversation {conversation_id} not found, proceeding without session") if isinstance(user_message, str): logger.debug(f"Executing agent with text input: {user_message[:100]}...") else: - logger.debug(f"Executing agent with multimodal ChatMessage: {type(user_message)}") + logger.debug(f"Executing agent with multimodal Message: {type(user_message)}") # Workaround for MCP tool stale connection bug (GitHub issue pending) # When HTTP streaming ends, GeneratorExit can close MCP stdio streams @@ -331,8 +331,8 @@ class AgentFrameworkExecutor: # Agent must have run() method - use stream=True for streaming if hasattr(agent, "run") and callable(agent.run): # Use Agent Framework's run() with stream=True for streaming - if thread: - async for update in agent.run(user_message, stream=True, thread=thread): + if session: + async for update in agent.run(user_message, stream=True, session=session): for trace_event in trace_collector.get_pending_events(): yield trace_event @@ -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 @@ -534,7 +534,7 @@ class AgentFrameworkExecutor: yield {"type": "error", "message": f"Workflow execution error: {e!s}"} def _convert_input_to_chat_message(self, input_data: Any) -> Any: - """Convert OpenAI Responses API input to Agent Framework ChatMessage or string. + """Convert OpenAI Responses API input to Agent Framework Message or string. Handles various input formats including text, images, files, and multimodal content. Falls back to string extraction for simple cases. @@ -543,11 +543,11 @@ class AgentFrameworkExecutor: input_data: OpenAI ResponseInputParam (List[ResponseInputItemParam]) Returns: - ChatMessage for multimodal content, or string for simple text + Message for multimodal content, or string for simple text """ # Import Agent Framework types try: - from agent_framework import ChatMessage, Role + from agent_framework import Message, Role except ImportError: # Fallback to string extraction if Agent Framework not available return self._extract_user_message_fallback(input_data) @@ -558,24 +558,24 @@ class AgentFrameworkExecutor: # Handle OpenAI ResponseInputParam (List[ResponseInputItemParam]) if isinstance(input_data, list): - return self._convert_openai_input_to_chat_message(input_data, ChatMessage, Role) + return self._convert_openai_input_to_chat_message(input_data, Message, Role) # Fallback for other formats return self._extract_user_message_fallback(input_data) - def _convert_openai_input_to_chat_message(self, input_items: list[Any], ChatMessage: Any, Role: Any) -> Any: - """Convert OpenAI ResponseInputParam to Agent Framework ChatMessage. + def _convert_openai_input_to_chat_message(self, input_items: list[Any], Message: Any, Role: Any) -> Any: + """Convert OpenAI ResponseInputParam to Agent Framework Message. Processes text, images, files, and other content types from OpenAI format - to Agent Framework ChatMessage with appropriate content objects. + to Agent Framework Message with appropriate content objects. Args: input_items: List of OpenAI ResponseInputItemParam objects (dicts or objects) - ChatMessage: ChatMessage class for creating chat messages + Message: Message class for creating chat messages Role: Role enum for message roles Returns: - ChatMessage with converted content + Message with converted content """ contents: list[Content] = [] @@ -705,9 +705,9 @@ class AgentFrameworkExecutor: if not contents: contents.append(Content.from_text(text="")) - chat_message = ChatMessage(role="user", contents=contents) + chat_message = Message(role="user", contents=contents) - logger.info(f"Created ChatMessage with {len(contents)} contents:") + logger.info(f"Created Message with {len(contents)} contents:") for idx, content in enumerate(contents): content_type = content.__class__.__name__ if hasattr(content, "media_type"): @@ -772,9 +772,9 @@ class AgentFrameworkExecutor: pass # Check for OpenAI multimodal format (list with type: "message") - # This handles ChatMessage inputs with images, files, etc. + # This handles Message inputs with images, files, etc. if self._is_openai_multimodal_format(raw_input): - logger.debug("Detected OpenAI multimodal format, converting to ChatMessage") + logger.debug("Detected OpenAI multimodal format, converting to Message") return self._convert_input_to_chat_message(raw_input) # Handle structured input (dict) diff --git a/python/packages/devui/agent_framework_devui/_mapper.py b/python/packages/devui/agent_framework_devui/_mapper.py index cb2ecacdd0..bcb99634cb 100644 --- a/python/packages/devui/agent_framework_devui/_mapper.py +++ b/python/packages/devui/agent_framework_devui/_mapper.py @@ -14,7 +14,7 @@ from datetime import datetime from typing import Any, Union from uuid import uuid4 -from agent_framework import ChatMessage, Content +from agent_framework import Content, Message from openai.types.responses import ( Response, ResponseContentPartAddedEvent, @@ -453,7 +453,7 @@ class MessageMapper: Handles: - Primitives (str, int, float, bool, None) - Collections (list, tuple, set, dict) - - SerializationMixin objects (ChatMessage, etc.) - calls to_dict() + - SerializationMixin objects (Message, etc.) - calls to_dict() - Pydantic models - calls model_dump() - Dataclasses - recursively serializes with asdict() - Enums - extracts value @@ -502,7 +502,7 @@ class MessageMapper: if isinstance(value, dict): return {k: self._serialize_value(v) for k, v in value.items()} - # Handle SerializationMixin (like ChatMessage) - call to_dict() + # Handle SerializationMixin (like Message) - call to_dict() if hasattr(value, "to_dict") and callable(getattr(value, "to_dict", None)): try: return value.to_dict() # type: ignore[attr-defined, no-any-return] @@ -536,7 +536,7 @@ class MessageMapper: def _serialize_request_data(self, request_data: Any) -> dict[str, Any]: """Serialize RequestInfoMessage to dict for JSON transmission. - Handles nested SerializationMixin objects (like ChatMessage) within dataclasses. + Handles nested SerializationMixin objects (like Message) within dataclasses. Args: request_data: The RequestInfoMessage instance @@ -554,7 +554,7 @@ class MessageMapper: return {k: self._serialize_value(v) for k, v in request_data.items()} # Handle dataclasses with nested SerializationMixin objects - # We can't use asdict() directly because it doesn't handle ChatMessage + # We can't use asdict() directly because it doesn't handle Message if is_dataclass(request_data) and not isinstance(request_data, type): try: # Manually serialize each field to handle nested SerializationMixin @@ -892,17 +892,17 @@ class MessageMapper: # Extract text from output data based on type text = None - if isinstance(output_data, ChatMessage): - # Handle ChatMessage (from Magentic and AgentExecutor with output_response=True) + if isinstance(output_data, Message): + # Handle Message (from Magentic and AgentExecutor with output_response=True) text = getattr(output_data, "text", None) if not text: # Fallback to string representation text = str(output_data) elif isinstance(output_data, list): - # Handle list of ChatMessage objects (from Magentic yield_output([final_answer])) + # Handle list of Message objects (from Magentic yield_output([final_answer])) text_parts = [] for item in output_data: - if isinstance(item, ChatMessage): + if isinstance(item, Message): item_text = getattr(item, "text", None) if item_text: text_parts.append(item_text) @@ -1047,7 +1047,7 @@ class MessageMapper: # Create ExecutorActionItem with completed status # executor_completed event (type='executor_completed') uses 'data' field, not 'result' # Serialize the result data to ensure it's JSON-serializable - # (AgentExecutorResponse contains AgentResponse/ChatMessage which are SerializationMixin) + # (AgentExecutorResponse contains AgentResponse/Message which are SerializationMixin) raw_result = getattr(event, "data", None) serialized_result = self._serialize_value(raw_result) if raw_result is not None else None executor_item = ExecutorActionItem( diff --git a/python/packages/devui/agent_framework_devui/_server.py b/python/packages/devui/agent_framework_devui/_server.py index 1045c82923..e7994d3d3b 100644 --- a/python/packages/devui/agent_framework_devui/_server.py +++ b/python/packages/devui/agent_framework_devui/_server.py @@ -222,8 +222,8 @@ class DevServer: # Step 2: Close chat clients and their credentials (EXISTING) entity_obj = self.executor.entity_discovery.get_entity_object(entity_id) - if entity_obj and hasattr(entity_obj, "chat_client"): - client = entity_obj.chat_client + if entity_obj and hasattr(entity_obj, "client"): + client = entity_obj.client # Close the chat client itself if hasattr(client, "close") and callable(client.close): @@ -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/devui/agent_framework_devui/_utils.py b/python/packages/devui/agent_framework_devui/_utils.py index b715263075..66886b8ea7 100644 --- a/python/packages/devui/agent_framework_devui/_utils.py +++ b/python/packages/devui/agent_framework_devui/_utils.py @@ -9,7 +9,7 @@ from dataclasses import fields, is_dataclass from types import UnionType from typing import Any, Union, get_args, get_origin, get_type_hints -from agent_framework import ChatMessage +from agent_framework import Message logger = logging.getLogger(__name__) @@ -45,7 +45,7 @@ def extract_agent_metadata(entity_object: Any) -> dict[str, Any]: elif hasattr(chat_opts, "instructions"): metadata["instructions"] = chat_opts.instructions - # Try to get model - check both default_options and chat_client + # Try to get model - check both default_options and client if hasattr(entity_object, "default_options"): chat_opts = entity_object.default_options if isinstance(chat_opts, dict): @@ -53,16 +53,12 @@ def extract_agent_metadata(entity_object: Any) -> dict[str, Any]: metadata["model"] = chat_opts.get("model_id") elif hasattr(chat_opts, "model_id") and chat_opts.model_id: metadata["model"] = chat_opts.model_id - if ( - metadata["model"] is None - and hasattr(entity_object, "chat_client") - and hasattr(entity_object.chat_client, "model_id") - ): - metadata["model"] = entity_object.chat_client.model_id + if metadata["model"] is None and hasattr(entity_object, "client") and hasattr(entity_object.client, "model_id"): + metadata["model"] = entity_object.client.model_id # Try to get chat client type - if hasattr(entity_object, "chat_client"): - metadata["chat_client_type"] = entity_object.chat_client.__class__.__name__ + if hasattr(entity_object, "client"): + metadata["chat_client_type"] = entity_object.client.__class__.__name__ # Try to get context providers if ( @@ -124,8 +120,8 @@ def extract_executor_message_types(executor: Any) -> list[Any]: def _contains_chat_message(type_hint: Any) -> bool: - """Check whether the provided type hint directly or indirectly references ChatMessage.""" - if type_hint is ChatMessage: + """Check whether the provided type hint directly or indirectly references Message.""" + if type_hint is Message: return True origin = get_origin(type_hint) @@ -141,7 +137,7 @@ def _contains_chat_message(type_hint: Any) -> bool: def select_primary_input_type(message_types: list[Any]) -> Any | None: """Choose the most user-friendly input type for workflow inputs. - Prefers ChatMessage (or containers thereof) and then falls back to primitives. + Prefers Message (or containers thereof) and then falls back to primitives. Args: message_types: List of possible message types @@ -154,7 +150,7 @@ def select_primary_input_type(message_types: list[Any]) -> Any | None: for message_type in message_types: if _contains_chat_message(message_type): - return ChatMessage + return Message preferred = (str, dict) @@ -427,7 +423,7 @@ def generate_input_schema(input_type: type) -> dict[str, Any]: if hasattr(input_type, "model_json_schema"): return input_type.model_json_schema() # type: ignore - # 3. SerializationMixin classes (ChatMessage, etc.) + # 3. SerializationMixin classes (Message, etc.) if is_serialization_mixin(input_type): return generate_schema_from_serialization_mixin(input_type) @@ -521,7 +517,7 @@ def _parse_string_input(input_str: str, target_type: type) -> Any: except Exception as e: logger.debug(f"Failed to parse string as Pydantic model: {e}") - # SerializationMixin (like ChatMessage) + # SerializationMixin (like Message) if is_serialization_mixin(target_type): try: # Try parsing as JSON dict first @@ -531,7 +527,7 @@ def _parse_string_input(input_str: str, target_type: type) -> Any: return target_type.from_dict(data) # type: ignore return target_type(**data) # type: ignore - # For ChatMessage specifically: create from text + # For Message specifically: create from text # Try common field patterns common_fields = ["text", "message", "content"] sig = inspect.signature(target_type) diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index.js b/python/packages/devui/agent_framework_devui/ui/assets/index.js index 276af33633..6387ada58a 100644 --- a/python/packages/devui/agent_framework_devui/ui/assets/index.js +++ b/python/packages/devui/agent_framework_devui/ui/assets/index.js @@ -453,7 +453,7 @@ Error generating stack: `+i.message+` and value of this only key should be a state object. Example: { "type": "__setState", "state": { "abc123Store": { "foo": "bar" } } } `);const A=_.state[f];if(A==null)return;JSON.stringify(l.getState())!==JSON.stringify(A)&&b(A);return}l.dispatchFromDevtools&&typeof l.dispatch=="function"&&l.dispatch(_)});case"DISPATCH":switch(N.payload.type){case"RESET":return b(j),f===void 0?g?.init(l.getState()):g?.init(au(m.name));case"COMMIT":if(f===void 0){g?.init(l.getState());return}return g?.init(au(m.name));case"ROLLBACK":return bh(N.state,_=>{if(f===void 0){b(_),g?.init(l.getState());return}b(_[f]),g?.init(au(m.name))});case"JUMP_TO_STATE":case"JUMP_TO_ACTION":return bh(N.state,_=>{if(f===void 0){b(_);return}JSON.stringify(l.getState())!==JSON.stringify(_[f])&&b(_[f])});case"IMPORT_STATE":{const{nextLiftedState:_}=N.payload,A=(S=_.computedStates.slice(-1)[0])==null?void 0:S.state;if(!A)return;b(f===void 0?A:A[f]),g?.send(null,_);return}case"PAUSE_RECORDING":return y=!y}return}}),j},m5=f5,bh=(e,n)=>{let r;try{r=JSON.parse(e)}catch(a){console.error("[zustand devtools middleware] Could not parse the received json",a)}r!==void 0&&n(r)};function h5(e,n){let r;try{r=e()}catch{return}return{getItem:l=>{var c;const d=m=>m===null?null:JSON.parse(m,void 0),f=(c=r.getItem(l))!=null?c:null;return f instanceof Promise?f.then(d):d(f)},setItem:(l,c)=>r.setItem(l,JSON.stringify(c,void 0)),removeItem:l=>r.removeItem(l)}}const ep=e=>n=>{try{const r=e(n);return r instanceof Promise?r:{then(a){return ep(a)(r)},catch(a){return this}}}catch(r){return{then(a){return this},catch(a){return ep(a)(r)}}}},p5=(e,n)=>(r,a,l)=>{let c={storage:h5(()=>localStorage),partialize:N=>N,version:0,merge:(N,S)=>({...S,...N}),...n},d=!1;const f=new Set,m=new Set;let h=c.storage;if(!h)return e((...N)=>{console.warn(`[zustand persist middleware] Unable to update item '${c.name}', the given storage is currently unavailable.`),r(...N)},a,l);const g=()=>{const N=c.partialize({...a()});return h.setItem(c.name,{state:N,version:c.version})},x=l.setState;l.setState=(N,S)=>(x(N,S),g());const y=e((...N)=>(r(...N),g()),a,l);l.getInitialState=()=>y;let b;const j=()=>{var N,S;if(!h)return;d=!1,f.forEach(A=>{var E;return A((E=a())!=null?E:y)});const _=((S=c.onRehydrateStorage)==null?void 0:S.call(c,(N=a())!=null?N:y))||void 0;return ep(h.getItem.bind(h))(c.name).then(A=>{if(A)if(typeof A.version=="number"&&A.version!==c.version){if(c.migrate){const E=c.migrate(A.state,A.version);return E instanceof Promise?E.then(M=>[!0,M]):[!0,E]}console.error("State loaded from storage couldn't be migrated since no migrate function was provided")}else return[!1,A.state];return[!1,void 0]}).then(A=>{var E;const[M,T]=A;if(b=c.merge(T,(E=a())!=null?E:y),r(b,!0),M)return g()}).then(()=>{_?.(b,void 0),b=a(),d=!0,m.forEach(A=>A(b))}).catch(A=>{_?.(void 0,A)})};return l.persist={setOptions:N=>{c={...c,...N},N.storage&&(h=N.storage)},clearStorage:()=>{h?.removeItem(c.name)},getOptions:()=>c,rehydrate:()=>j(),hasHydrated:()=>d,onHydrate:N=>(f.add(N),()=>{f.delete(N)}),onFinishHydration:N=>(m.add(N),()=>{m.delete(N)})},c.skipHydration||j(),b||y},g5=p5,le=l5()(m5(g5(e=>({agents:[],workflows:[],entities:[],selectedAgent:void 0,isLoadingEntities:!0,entityError:null,currentConversation:void 0,availableConversations:[],chatItems:[],isStreaming:!1,isSubmitting:!1,loadingConversations:!1,inputValue:"",attachments:[],conversationUsage:{total_tokens:0,message_count:0},pendingApprovals:[],currentSession:void 0,availableSessions:[],sessionCheckpoints:[],loadingSessions:!1,loadingCheckpoints:!1,showDebugPanel:!0,debugPanelMinimized:!1,debugPanelWidth:320,debugEvents:[],isResizing:!1,showToolCalls:!0,streamingEnabled:!0,debugPanelTab:"events",debugTraceSubTab:"spans",contextInspectorViewMode:"tokens",contextInspectorCumulative:!1,showAboutModal:!1,showGallery:!1,showDeployModal:!1,showEntityNotFoundToast:!1,toasts:[],oaiMode:{enabled:!1,model:"gpt-4o-mini"},uiMode:"developer",runtime:"python",serverCapabilities:{instrumentation:!1,openai_proxy:!1,deployment:!1},authRequired:!1,serverVersion:null,isDeploying:!1,deploymentLogs:[],lastDeployment:null,azureDeploymentEnabled:!1,setAgents:n=>e({agents:n}),setWorkflows:n=>e({workflows:n}),setEntities:n=>e({entities:n}),setSelectedAgent:n=>e({selectedAgent:n}),addAgent:n=>e(r=>({agents:[...r.agents,n]})),addWorkflow:n=>e(r=>({workflows:[...r.workflows,n]})),updateAgent:n=>e(r=>({agents:r.agents.map(a=>a.id===n.id?n:a),selectedAgent:r.selectedAgent?.id===n.id&&r.selectedAgent.type==="agent"?n:r.selectedAgent})),updateWorkflow:n=>e(r=>({workflows:r.workflows.map(a=>a.id===n.id?n:a),selectedAgent:r.selectedAgent?.id===n.id&&r.selectedAgent.type==="workflow"?n:r.selectedAgent})),removeEntity:n=>e(r=>({agents:r.agents.filter(a=>a.id!==n),workflows:r.workflows.filter(a=>a.id!==n),selectedAgent:r.selectedAgent?.id===n?void 0:r.selectedAgent})),setEntityError:n=>e({entityError:n}),setIsLoadingEntities:n=>e({isLoadingEntities:n}),setCurrentConversation:n=>e({currentConversation:n}),setAvailableConversations:n=>e({availableConversations:n}),setChatItems:n=>e({chatItems:n}),setIsStreaming:n=>e({isStreaming:n}),setIsSubmitting:n=>e({isSubmitting:n}),setLoadingConversations:n=>e({loadingConversations:n}),setInputValue:n=>e({inputValue:n}),setAttachments:n=>e({attachments:n}),updateConversationUsage:n=>e(r=>({conversationUsage:{total_tokens:r.conversationUsage.total_tokens+n,message_count:r.conversationUsage.message_count+1}})),setPendingApprovals:n=>e({pendingApprovals:n}),setCurrentSession:n=>e({currentSession:n}),setAvailableSessions:n=>e({availableSessions:n}),setSessionCheckpoints:n=>e({sessionCheckpoints:n}),setLoadingSessions:n=>e({loadingSessions:n}),setLoadingCheckpoints:n=>e({loadingCheckpoints:n}),addSession:n=>e(r=>({availableSessions:[n,...r.availableSessions]})),removeSession:n=>e(r=>({availableSessions:r.availableSessions.filter(a=>a.conversation_id!==n),currentSession:r.currentSession?.conversation_id===n?void 0:r.currentSession,sessionCheckpoints:r.currentSession?.conversation_id===n?[]:r.sessionCheckpoints})),setShowDebugPanel:n=>e({showDebugPanel:n}),setDebugPanelMinimized:n=>e({debugPanelMinimized:n}),setDebugPanelWidth:n=>e({debugPanelWidth:n}),setShowToolCalls:n=>e({showToolCalls:n}),setStreamingEnabled:n=>e({streamingEnabled:n}),addDebugEvent:n=>e(r=>{const a=Math.floor(Date.now()/1e3),c=(r.debugEvents.length>0?r.debugEvents[r.debugEvents.length-1]:null)?._uiTimestamp??0,d=Math.max(a,c+1);return{debugEvents:[...r.debugEvents,{...n,_uiTimestamp:"created_at"in n&&n.created_at?n.created_at:d}]}}),clearDebugEvents:()=>e({debugEvents:[]}),setIsResizing:n=>e({isResizing:n}),setDebugPanelTab:n=>e({debugPanelTab:n}),setDebugTraceSubTab:n=>e({debugTraceSubTab:n}),setContextInspectorViewMode:n=>e({contextInspectorViewMode:n}),setContextInspectorCumulative:n=>e({contextInspectorCumulative:n}),setShowAboutModal:n=>e({showAboutModal:n}),setShowGallery:n=>e({showGallery:n}),setShowDeployModal:n=>e({showDeployModal:n}),setShowEntityNotFoundToast:n=>e({showEntityNotFoundToast:n}),addToast:n=>e(r=>({toasts:[...r.toasts,{id:`toast-${Date.now()}-${Math.random().toString(36).substr(2,9)}`,type:n.type||"info",duration:n.duration||4e3,...n}]})),removeToast:n=>e(r=>({toasts:r.toasts.filter(a=>a.id!==n)})),setOAIMode:n=>e(r=>n.enabled&&!r.oaiMode.enabled?(Object.keys(localStorage).forEach(a=>{a.startsWith("devui_convs_")&&localStorage.removeItem(a)}),{oaiMode:n,currentConversation:void 0,availableConversations:[],chatItems:[],inputValue:"",attachments:[],conversationUsage:{total_tokens:0,message_count:0},isStreaming:!1,isSubmitting:!1,pendingApprovals:[],debugEvents:[]}):!n.enabled&&r.oaiMode.enabled?(Object.keys(localStorage).forEach(a=>{a.startsWith("devui_convs_")&&localStorage.removeItem(a)}),{oaiMode:n,currentConversation:void 0,availableConversations:[],chatItems:[],inputValue:"",attachments:[],conversationUsage:{total_tokens:0,message_count:0},isStreaming:!1,isSubmitting:!1,pendingApprovals:[],debugEvents:[]}):{oaiMode:n}),toggleOAIMode:()=>e(n=>{const r=!n.oaiMode.enabled;return{oaiMode:{...n.oaiMode,enabled:r},currentConversation:void 0,availableConversations:[],chatItems:[],inputValue:"",attachments:[],conversationUsage:{total_tokens:0,message_count:0},isStreaming:!1,isSubmitting:!1,pendingApprovals:[],debugEvents:[]}}),setServerMeta:n=>e({uiMode:n.uiMode,runtime:n.runtime,serverCapabilities:n.capabilities,authRequired:n.authRequired,serverVersion:n.version||null}),startDeployment:()=>e({isDeploying:!0,deploymentLogs:[],lastDeployment:null}),addDeploymentLog:n=>e(r=>({deploymentLogs:[...r.deploymentLogs,n]})),setDeploymentResult:n=>e({isDeploying:!1,lastDeployment:n}),stopDeployment:()=>e({isDeploying:!1}),clearDeploymentState:()=>e({isDeploying:!1,deploymentLogs:[],lastDeployment:null}),setAzureDeploymentEnabled:n=>e({azureDeploymentEnabled:n}),selectEntity:n=>{e({selectedAgent:n,currentConversation:void 0,availableConversations:[],chatItems:[],inputValue:"",attachments:[],conversationUsage:{total_tokens:0,message_count:0},isStreaming:!1,isSubmitting:!1,pendingApprovals:[],currentSession:void 0,availableSessions:[],sessionCheckpoints:[],debugEvents:[]});const r=new URL(window.location.href);r.searchParams.set("entity_id",n.id),window.history.pushState({},"",r)}}),{name:"devui-storage",partialize:e=>({showDebugPanel:e.showDebugPanel,debugPanelMinimized:e.debugPanelMinimized,debugPanelWidth:e.debugPanelWidth,showToolCalls:e.showToolCalls,streamingEnabled:e.streamingEnabled,oaiMode:e.oaiMode,azureDeploymentEnabled:e.azureDeploymentEnabled,debugPanelTab:e.debugPanelTab,debugTraceSubTab:e.debugTraceSubTab,contextInspectorViewMode:e.contextInspectorViewMode,contextInspectorCumulative:e.contextInspectorCumulative})}),{name:"DevUI Store"})),wu=Object.freeze(Object.defineProperty({__proto__:null,useDevUIStore:le},Symbol.toStringTag,{value:"Module"}));function ab({agents:e,workflows:n,entities:r,selectedItem:a,onSelect:l,onBrowseGallery:c,isLoading:d=!1,onSettingsClick:f}){const{oaiMode:m,serverVersion:h}=le();return o.jsxs("header",{className:"flex h-14 items-center gap-4 border-b px-4",children:[o.jsxs("div",{className:"flex items-center gap-2 font-semibold",children:[o.jsxs("svg",{width:"24",height:"24",viewBox:"0 0 805 805",fill:"none",xmlns:"http://www.w3.org/2000/svg",className:"flex-shrink-0",children:[o.jsx("path",{d:"M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z",fill:"url(#paint0_linear_510_1294)"}),o.jsx("defs",{children:o.jsxs("linearGradient",{id:"paint0_linear_510_1294",x1:"255.628",y1:"-34.3245",x2:"618.483",y2:"632.032",gradientUnits:"userSpaceOnUse",children:[o.jsx("stop",{stopColor:"#D59FFF"}),o.jsx("stop",{offset:"1",stopColor:"#8562C5"})]})})]}),"Dev UI",h&&o.jsxs("span",{className:"text-xs text-muted-foreground ml-1",children:["v",h]}),m.enabled&&o.jsxs(ut,{variant:"secondary",className:"gap-1 ml-2",children:[o.jsx(og,{className:"h-3 w-3"}),"OpenAI: ",m.model]})]}),!m.enabled&&o.jsx(YM,{agents:e,workflows:n,entities:r,selectedItem:a,onSelect:l,onBrowseGallery:c,isLoading:d}),o.jsx("div",{className:"flex-1"}),o.jsxs("div",{className:"flex items-center gap-2 ml-auto",children:[o.jsx(s5,{}),o.jsx(Le,{variant:"ghost",size:"sm",onClick:g=>{g.stopPropagation(),f?.()},children:o.jsx(Jh,{className:"h-4 w-4"})})]})]})}function tp(e,[n,r]){return Math.min(r,Math.max(n,e))}function x5(e,n){return w.useReducer((r,a)=>n[r][a]??r,e)}var ig="ScrollArea",[dN,W7]=Kn(ig),[y5,$n]=dN(ig),fN=w.forwardRef((e,n)=>{const{__scopeScrollArea:r,type:a="hover",dir:l,scrollHideDelay:c=600,...d}=e,[f,m]=w.useState(null),[h,g]=w.useState(null),[x,y]=w.useState(null),[b,j]=w.useState(null),[N,S]=w.useState(null),[_,A]=w.useState(0),[E,M]=w.useState(0),[T,D]=w.useState(!1),[z,H]=w.useState(!1),q=rt(n,W=>m(W)),X=jl(l);return o.jsx(y5,{scope:r,type:a,dir:X,scrollHideDelay:c,scrollArea:f,viewport:h,onViewportChange:g,content:x,onContentChange:y,scrollbarX:b,onScrollbarXChange:j,scrollbarXEnabled:T,onScrollbarXEnabledChange:D,scrollbarY:N,onScrollbarYChange:S,scrollbarYEnabled:z,onScrollbarYEnabledChange:H,onCornerWidthChange:A,onCornerHeightChange:M,children:o.jsx(Ye.div,{dir:X,...d,ref:q,style:{position:"relative","--radix-scroll-area-corner-width":_+"px","--radix-scroll-area-corner-height":E+"px",...e.style}})})});fN.displayName=ig;var mN="ScrollAreaViewport",hN=w.forwardRef((e,n)=>{const{__scopeScrollArea:r,children:a,nonce:l,...c}=e,d=$n(mN,r),f=w.useRef(null),m=rt(n,f,d.onViewportChange);return o.jsxs(o.Fragment,{children:[o.jsx("style",{dangerouslySetInnerHTML:{__html:"[data-radix-scroll-area-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-radix-scroll-area-viewport]::-webkit-scrollbar{display:none}"},nonce:l}),o.jsx(Ye.div,{"data-radix-scroll-area-viewport":"",...c,ref:m,style:{overflowX:d.scrollbarXEnabled?"scroll":"hidden",overflowY:d.scrollbarYEnabled?"scroll":"hidden",...e.style},children:o.jsx("div",{ref:d.onContentChange,style:{minWidth:"100%",display:"table"},children:a})})]})});hN.displayName=mN;var xs="ScrollAreaScrollbar",lg=w.forwardRef((e,n)=>{const{forceMount:r,...a}=e,l=$n(xs,e.__scopeScrollArea),{onScrollbarXEnabledChange:c,onScrollbarYEnabledChange:d}=l,f=e.orientation==="horizontal";return w.useEffect(()=>(f?c(!0):d(!0),()=>{f?c(!1):d(!1)}),[f,c,d]),l.type==="hover"?o.jsx(v5,{...a,ref:n,forceMount:r}):l.type==="scroll"?o.jsx(b5,{...a,ref:n,forceMount:r}):l.type==="auto"?o.jsx(pN,{...a,ref:n,forceMount:r}):l.type==="always"?o.jsx(cg,{...a,ref:n}):null});lg.displayName=xs;var v5=w.forwardRef((e,n)=>{const{forceMount:r,...a}=e,l=$n(xs,e.__scopeScrollArea),[c,d]=w.useState(!1);return w.useEffect(()=>{const f=l.scrollArea;let m=0;if(f){const h=()=>{window.clearTimeout(m),d(!0)},g=()=>{m=window.setTimeout(()=>d(!1),l.scrollHideDelay)};return f.addEventListener("pointerenter",h),f.addEventListener("pointerleave",g),()=>{window.clearTimeout(m),f.removeEventListener("pointerenter",h),f.removeEventListener("pointerleave",g)}}},[l.scrollArea,l.scrollHideDelay]),o.jsx(Cn,{present:r||c,children:o.jsx(pN,{"data-state":c?"visible":"hidden",...a,ref:n})})}),b5=w.forwardRef((e,n)=>{const{forceMount:r,...a}=e,l=$n(xs,e.__scopeScrollArea),c=e.orientation==="horizontal",d=Sd(()=>m("SCROLL_END"),100),[f,m]=x5("hidden",{hidden:{SCROLL:"scrolling"},scrolling:{SCROLL_END:"idle",POINTER_ENTER:"interacting"},interacting:{SCROLL:"interacting",POINTER_LEAVE:"idle"},idle:{HIDE:"hidden",SCROLL:"scrolling",POINTER_ENTER:"interacting"}});return w.useEffect(()=>{if(f==="idle"){const h=window.setTimeout(()=>m("HIDE"),l.scrollHideDelay);return()=>window.clearTimeout(h)}},[f,l.scrollHideDelay,m]),w.useEffect(()=>{const h=l.viewport,g=c?"scrollLeft":"scrollTop";if(h){let x=h[g];const y=()=>{const b=h[g];x!==b&&(m("SCROLL"),d()),x=b};return h.addEventListener("scroll",y),()=>h.removeEventListener("scroll",y)}},[l.viewport,c,m,d]),o.jsx(Cn,{present:r||f!=="hidden",children:o.jsx(cg,{"data-state":f==="hidden"?"hidden":"visible",...a,ref:n,onPointerEnter:ke(e.onPointerEnter,()=>m("POINTER_ENTER")),onPointerLeave:ke(e.onPointerLeave,()=>m("POINTER_LEAVE"))})})}),pN=w.forwardRef((e,n)=>{const r=$n(xs,e.__scopeScrollArea),{forceMount:a,...l}=e,[c,d]=w.useState(!1),f=e.orientation==="horizontal",m=Sd(()=>{if(r.viewport){const h=r.viewport.offsetWidth{const{orientation:r="vertical",...a}=e,l=$n(xs,e.__scopeScrollArea),c=w.useRef(null),d=w.useRef(0),[f,m]=w.useState({content:0,viewport:0,scrollbar:{size:0,paddingStart:0,paddingEnd:0}}),h=bN(f.viewport,f.content),g={...a,sizes:f,onSizesChange:m,hasThumb:h>0&&h<1,onThumbChange:y=>c.current=y,onThumbPointerUp:()=>d.current=0,onThumbPointerDown:y=>d.current=y};function x(y,b){return E5(y,d.current,f,b)}return r==="horizontal"?o.jsx(w5,{...g,ref:n,onThumbPositionChange:()=>{if(l.viewport&&c.current){const y=l.viewport.scrollLeft,b=ib(y,f,l.dir);c.current.style.transform=`translate3d(${b}px, 0, 0)`}},onWheelScroll:y=>{l.viewport&&(l.viewport.scrollLeft=y)},onDragScroll:y=>{l.viewport&&(l.viewport.scrollLeft=x(y,l.dir))}}):r==="vertical"?o.jsx(N5,{...g,ref:n,onThumbPositionChange:()=>{if(l.viewport&&c.current){const y=l.viewport.scrollTop,b=ib(y,f);c.current.style.transform=`translate3d(0, ${b}px, 0)`}},onWheelScroll:y=>{l.viewport&&(l.viewport.scrollTop=y)},onDragScroll:y=>{l.viewport&&(l.viewport.scrollTop=x(y))}}):null}),w5=w.forwardRef((e,n)=>{const{sizes:r,onSizesChange:a,...l}=e,c=$n(xs,e.__scopeScrollArea),[d,f]=w.useState(),m=w.useRef(null),h=rt(n,m,c.onScrollbarXChange);return w.useEffect(()=>{m.current&&f(getComputedStyle(m.current))},[m]),o.jsx(xN,{"data-orientation":"horizontal",...l,ref:h,sizes:r,style:{bottom:0,left:c.dir==="rtl"?"var(--radix-scroll-area-corner-width)":0,right:c.dir==="ltr"?"var(--radix-scroll-area-corner-width)":0,"--radix-scroll-area-thumb-width":jd(r)+"px",...e.style},onThumbPointerDown:g=>e.onThumbPointerDown(g.x),onDragScroll:g=>e.onDragScroll(g.x),onWheelScroll:(g,x)=>{if(c.viewport){const y=c.viewport.scrollLeft+g.deltaX;e.onWheelScroll(y),NN(y,x)&&g.preventDefault()}},onResize:()=>{m.current&&c.viewport&&d&&a({content:c.viewport.scrollWidth,viewport:c.viewport.offsetWidth,scrollbar:{size:m.current.clientWidth,paddingStart:Fu(d.paddingLeft),paddingEnd:Fu(d.paddingRight)}})}})}),N5=w.forwardRef((e,n)=>{const{sizes:r,onSizesChange:a,...l}=e,c=$n(xs,e.__scopeScrollArea),[d,f]=w.useState(),m=w.useRef(null),h=rt(n,m,c.onScrollbarYChange);return w.useEffect(()=>{m.current&&f(getComputedStyle(m.current))},[m]),o.jsx(xN,{"data-orientation":"vertical",...l,ref:h,sizes:r,style:{top:0,right:c.dir==="ltr"?0:void 0,left:c.dir==="rtl"?0:void 0,bottom:"var(--radix-scroll-area-corner-height)","--radix-scroll-area-thumb-height":jd(r)+"px",...e.style},onThumbPointerDown:g=>e.onThumbPointerDown(g.y),onDragScroll:g=>e.onDragScroll(g.y),onWheelScroll:(g,x)=>{if(c.viewport){const y=c.viewport.scrollTop+g.deltaY;e.onWheelScroll(y),NN(y,x)&&g.preventDefault()}},onResize:()=>{m.current&&c.viewport&&d&&a({content:c.viewport.scrollHeight,viewport:c.viewport.offsetHeight,scrollbar:{size:m.current.clientHeight,paddingStart:Fu(d.paddingTop),paddingEnd:Fu(d.paddingBottom)}})}})}),[j5,gN]=dN(xs),xN=w.forwardRef((e,n)=>{const{__scopeScrollArea:r,sizes:a,hasThumb:l,onThumbChange:c,onThumbPointerUp:d,onThumbPointerDown:f,onThumbPositionChange:m,onDragScroll:h,onWheelScroll:g,onResize:x,...y}=e,b=$n(xs,r),[j,N]=w.useState(null),S=rt(n,q=>N(q)),_=w.useRef(null),A=w.useRef(""),E=b.viewport,M=a.content-a.viewport,T=Zt(g),D=Zt(m),z=Sd(x,10);function H(q){if(_.current){const X=q.clientX-_.current.left,W=q.clientY-_.current.top;h({x:X,y:W})}}return w.useEffect(()=>{const q=X=>{const W=X.target;j?.contains(W)&&T(X,M)};return document.addEventListener("wheel",q,{passive:!1}),()=>document.removeEventListener("wheel",q,{passive:!1})},[E,j,M,T]),w.useEffect(D,[a,D]),Ca(j,z),Ca(b.content,z),o.jsx(j5,{scope:r,scrollbar:j,hasThumb:l,onThumbChange:Zt(c),onThumbPointerUp:Zt(d),onThumbPositionChange:D,onThumbPointerDown:Zt(f),children:o.jsx(Ye.div,{...y,ref:S,style:{position:"absolute",...y.style},onPointerDown:ke(e.onPointerDown,q=>{q.button===0&&(q.target.setPointerCapture(q.pointerId),_.current=j.getBoundingClientRect(),A.current=document.body.style.webkitUserSelect,document.body.style.webkitUserSelect="none",b.viewport&&(b.viewport.style.scrollBehavior="auto"),H(q))}),onPointerMove:ke(e.onPointerMove,H),onPointerUp:ke(e.onPointerUp,q=>{const X=q.target;X.hasPointerCapture(q.pointerId)&&X.releasePointerCapture(q.pointerId),document.body.style.webkitUserSelect=A.current,b.viewport&&(b.viewport.style.scrollBehavior=""),_.current=null})})})}),qu="ScrollAreaThumb",yN=w.forwardRef((e,n)=>{const{forceMount:r,...a}=e,l=gN(qu,e.__scopeScrollArea);return o.jsx(Cn,{present:r||l.hasThumb,children:o.jsx(S5,{ref:n,...a})})}),S5=w.forwardRef((e,n)=>{const{__scopeScrollArea:r,style:a,...l}=e,c=$n(qu,r),d=gN(qu,r),{onThumbPositionChange:f}=d,m=rt(n,x=>d.onThumbChange(x)),h=w.useRef(void 0),g=Sd(()=>{h.current&&(h.current(),h.current=void 0)},100);return w.useEffect(()=>{const x=c.viewport;if(x){const y=()=>{if(g(),!h.current){const b=C5(x,f);h.current=b,f()}};return f(),x.addEventListener("scroll",y),()=>x.removeEventListener("scroll",y)}},[c.viewport,g,f]),o.jsx(Ye.div,{"data-state":d.hasThumb?"visible":"hidden",...l,ref:m,style:{width:"var(--radix-scroll-area-thumb-width)",height:"var(--radix-scroll-area-thumb-height)",...a},onPointerDownCapture:ke(e.onPointerDownCapture,x=>{const b=x.target.getBoundingClientRect(),j=x.clientX-b.left,N=x.clientY-b.top;d.onThumbPointerDown({x:j,y:N})}),onPointerUp:ke(e.onPointerUp,d.onThumbPointerUp)})});yN.displayName=qu;var ug="ScrollAreaCorner",vN=w.forwardRef((e,n)=>{const r=$n(ug,e.__scopeScrollArea),a=!!(r.scrollbarX&&r.scrollbarY);return r.type!=="scroll"&&a?o.jsx(_5,{...e,ref:n}):null});vN.displayName=ug;var _5=w.forwardRef((e,n)=>{const{__scopeScrollArea:r,...a}=e,l=$n(ug,r),[c,d]=w.useState(0),[f,m]=w.useState(0),h=!!(c&&f);return Ca(l.scrollbarX,()=>{const g=l.scrollbarX?.offsetHeight||0;l.onCornerHeightChange(g),m(g)}),Ca(l.scrollbarY,()=>{const g=l.scrollbarY?.offsetWidth||0;l.onCornerWidthChange(g),d(g)}),h?o.jsx(Ye.div,{...a,ref:n,style:{width:c,height:f,position:"absolute",right:l.dir==="ltr"?0:void 0,left:l.dir==="rtl"?0:void 0,bottom:0,...e.style}}):null});function Fu(e){return e?parseInt(e,10):0}function bN(e,n){const r=e/n;return isNaN(r)?0:r}function jd(e){const n=bN(e.viewport,e.content),r=e.scrollbar.paddingStart+e.scrollbar.paddingEnd,a=(e.scrollbar.size-r)*n;return Math.max(a,18)}function E5(e,n,r,a="ltr"){const l=jd(r),c=l/2,d=n||c,f=l-d,m=r.scrollbar.paddingStart+d,h=r.scrollbar.size-r.scrollbar.paddingEnd-f,g=r.content-r.viewport,x=a==="ltr"?[0,g]:[g*-1,0];return wN([m,h],x)(e)}function ib(e,n,r="ltr"){const a=jd(n),l=n.scrollbar.paddingStart+n.scrollbar.paddingEnd,c=n.scrollbar.size-l,d=n.content-n.viewport,f=c-a,m=r==="ltr"?[0,d]:[d*-1,0],h=tp(e,m);return wN([0,d],[0,f])(h)}function wN(e,n){return r=>{if(e[0]===e[1]||n[0]===n[1])return n[0];const a=(n[1]-n[0])/(e[1]-e[0]);return n[0]+a*(r-e[0])}}function NN(e,n){return e>0&&e{})=>{let r={left:e.scrollLeft,top:e.scrollTop},a=0;return(function l(){const c={left:e.scrollLeft,top:e.scrollTop},d=r.left!==c.left,f=r.top!==c.top;(d||f)&&n(),r=c,a=window.requestAnimationFrame(l)})(),()=>window.cancelAnimationFrame(a)};function Sd(e,n){const r=Zt(e),a=w.useRef(0);return w.useEffect(()=>()=>window.clearTimeout(a.current),[]),w.useCallback(()=>{window.clearTimeout(a.current),a.current=window.setTimeout(r,n)},[r,n])}function Ca(e,n){const r=Zt(n);Wt(()=>{let a=0;if(e){const l=new ResizeObserver(()=>{cancelAnimationFrame(a),a=window.requestAnimationFrame(r)});return l.observe(e),()=>{window.cancelAnimationFrame(a),l.unobserve(e)}}},[e,r])}var jN=fN,k5=hN,T5=vN;const Wn=w.forwardRef(({className:e,children:n,...r},a)=>o.jsxs(jN,{ref:a,className:We("relative overflow-hidden",e),...r,children:[o.jsx(k5,{className:"h-full w-full rounded-[inherit]",children:n}),o.jsx(SN,{}),o.jsx(T5,{})]}));Wn.displayName=jN.displayName;const SN=w.forwardRef(({className:e,orientation:n="vertical",...r},a)=>o.jsx(lg,{ref:a,orientation:n,className:We("flex touch-none select-none transition-colors",n==="vertical"&&"h-full w-2.5 border-l border-l-transparent p-[1px]",n==="horizontal"&&"h-2.5 flex-col border-t border-t-transparent p-[1px]",e),...r,children:o.jsx(yN,{className:"relative flex-1 rounded-full bg-border"})}));SN.displayName=lg.displayName;var _d="Tabs",[A5,K7]=Kn(_d,[md]),_N=md(),[M5,dg]=A5(_d),EN=w.forwardRef((e,n)=>{const{__scopeTabs:r,value:a,onValueChange:l,defaultValue:c,orientation:d="horizontal",dir:f,activationMode:m="automatic",...h}=e,g=jl(f),[x,y]=Ar({prop:a,onChange:l,defaultProp:c??"",caller:_d});return o.jsx(M5,{scope:r,baseId:Mr(),value:x,onValueChange:y,orientation:d,dir:g,activationMode:m,children:o.jsx(Ye.div,{dir:g,"data-orientation":d,...h,ref:n})})});EN.displayName=_d;var CN="TabsList",kN=w.forwardRef((e,n)=>{const{__scopeTabs:r,loop:a=!0,...l}=e,c=dg(CN,r),d=_N(r);return o.jsx(d1,{asChild:!0,...d,orientation:c.orientation,dir:c.dir,loop:a,children:o.jsx(Ye.div,{role:"tablist","aria-orientation":c.orientation,...l,ref:n})})});kN.displayName=CN;var TN="TabsTrigger",AN=w.forwardRef((e,n)=>{const{__scopeTabs:r,value:a,disabled:l=!1,...c}=e,d=dg(TN,r),f=_N(r),m=DN(d.baseId,a),h=ON(d.baseId,a),g=a===d.value;return o.jsx(f1,{asChild:!0,...f,focusable:!l,active:g,children:o.jsx(Ye.button,{type:"button",role:"tab","aria-selected":g,"aria-controls":h,"data-state":g?"active":"inactive","data-disabled":l?"":void 0,disabled:l,id:m,...c,ref:n,onMouseDown:ke(e.onMouseDown,x=>{!l&&x.button===0&&x.ctrlKey===!1?d.onValueChange(a):x.preventDefault()}),onKeyDown:ke(e.onKeyDown,x=>{[" ","Enter"].includes(x.key)&&d.onValueChange(a)}),onFocus:ke(e.onFocus,()=>{const x=d.activationMode!=="manual";!g&&!l&&x&&d.onValueChange(a)})})})});AN.displayName=TN;var MN="TabsContent",RN=w.forwardRef((e,n)=>{const{__scopeTabs:r,value:a,forceMount:l,children:c,...d}=e,f=dg(MN,r),m=DN(f.baseId,a),h=ON(f.baseId,a),g=a===f.value,x=w.useRef(g);return w.useEffect(()=>{const y=requestAnimationFrame(()=>x.current=!1);return()=>cancelAnimationFrame(y)},[]),o.jsx(Cn,{present:l||g,children:({present:y})=>o.jsx(Ye.div,{"data-state":g?"active":"inactive","data-orientation":f.orientation,role:"tabpanel","aria-labelledby":m,hidden:!y,id:h,tabIndex:0,...d,ref:n,style:{...e.style,animationDuration:x.current?"0s":void 0},children:y&&c})})});RN.displayName=MN;function DN(e,n){return`${e}-trigger-${n}`}function ON(e,n){return`${e}-content-${n}`}var R5=EN,zN=kN,IN=AN,LN=RN;const D5=R5,$N=w.forwardRef(({className:e,...n},r)=>o.jsx(zN,{ref:r,className:We("inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",e),...n}));$N.displayName=zN.displayName;const Nu=w.forwardRef(({className:e,...n},r)=>o.jsx(IN,{ref:r,className:We("inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",e),...n}));Nu.displayName=IN.displayName;const ju=w.forwardRef(({className:e,...n},r)=>o.jsx(LN,{ref:r,className:We("mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",e),...n}));ju.displayName=LN.displayName;function fg(e){const n=w.useRef({value:e,previous:e});return w.useMemo(()=>(n.current.value!==e&&(n.current.previous=n.current.value,n.current.value=e),n.current.previous),[e])}var Ed="Checkbox",[O5,Q7]=Kn(Ed),[z5,mg]=O5(Ed);function I5(e){const{__scopeCheckbox:n,checked:r,children:a,defaultChecked:l,disabled:c,form:d,name:f,onCheckedChange:m,required:h,value:g="on",internal_do_not_use_render:x}=e,[y,b]=Ar({prop:r,defaultProp:l??!1,onChange:m,caller:Ed}),[j,N]=w.useState(null),[S,_]=w.useState(null),A=w.useRef(!1),E=j?!!d||!!j.closest("form"):!0,M={checked:y,disabled:c,setChecked:b,control:j,setControl:N,name:f,form:d,value:g,hasConsumerStoppedPropagationRef:A,required:h,defaultChecked:Tr(l)?!1:l,isFormControl:E,bubbleInput:S,setBubbleInput:_};return o.jsx(z5,{scope:n,...M,children:L5(x)?x(M):a})}var PN="CheckboxTrigger",HN=w.forwardRef(({__scopeCheckbox:e,onKeyDown:n,onClick:r,...a},l)=>{const{control:c,value:d,disabled:f,checked:m,required:h,setControl:g,setChecked:x,hasConsumerStoppedPropagationRef:y,isFormControl:b,bubbleInput:j}=mg(PN,e),N=rt(l,g),S=w.useRef(m);return w.useEffect(()=>{const _=c?.form;if(_){const A=()=>x(S.current);return _.addEventListener("reset",A),()=>_.removeEventListener("reset",A)}},[c,x]),o.jsx(Ye.button,{type:"button",role:"checkbox","aria-checked":Tr(m)?"mixed":m,"aria-required":h,"data-state":YN(m),"data-disabled":f?"":void 0,disabled:f,value:d,...a,ref:N,onKeyDown:ke(n,_=>{_.key==="Enter"&&_.preventDefault()}),onClick:ke(r,_=>{x(A=>Tr(A)?!0:!A),j&&b&&(y.current=_.isPropagationStopped(),y.current||_.stopPropagation())})})});HN.displayName=PN;var UN=w.forwardRef((e,n)=>{const{__scopeCheckbox:r,name:a,checked:l,defaultChecked:c,required:d,disabled:f,value:m,onCheckedChange:h,form:g,...x}=e;return o.jsx(I5,{__scopeCheckbox:r,checked:l,defaultChecked:c,disabled:f,required:d,onCheckedChange:h,name:a,form:g,value:m,internal_do_not_use_render:({isFormControl:y})=>o.jsxs(o.Fragment,{children:[o.jsx(HN,{...x,ref:n,__scopeCheckbox:r}),y&&o.jsx(FN,{__scopeCheckbox:r})]})})});UN.displayName=Ed;var BN="CheckboxIndicator",VN=w.forwardRef((e,n)=>{const{__scopeCheckbox:r,forceMount:a,...l}=e,c=mg(BN,r);return o.jsx(Cn,{present:a||Tr(c.checked)||c.checked===!0,children:o.jsx(Ye.span,{"data-state":YN(c.checked),"data-disabled":c.disabled?"":void 0,...l,ref:n,style:{pointerEvents:"none",...e.style}})})});VN.displayName=BN;var qN="CheckboxBubbleInput",FN=w.forwardRef(({__scopeCheckbox:e,...n},r)=>{const{control:a,hasConsumerStoppedPropagationRef:l,checked:c,defaultChecked:d,required:f,disabled:m,name:h,value:g,form:x,bubbleInput:y,setBubbleInput:b}=mg(qN,e),j=rt(r,b),N=fg(c),S=Lp(a);w.useEffect(()=>{const A=y;if(!A)return;const E=window.HTMLInputElement.prototype,T=Object.getOwnPropertyDescriptor(E,"checked").set,D=!l.current;if(N!==c&&T){const z=new Event("click",{bubbles:D});A.indeterminate=Tr(c),T.call(A,Tr(c)?!1:c),A.dispatchEvent(z)}},[y,N,c,l]);const _=w.useRef(Tr(c)?!1:c);return o.jsx(Ye.input,{type:"checkbox","aria-hidden":!0,defaultChecked:d??_.current,required:f,disabled:m,name:h,value:g,form:x,...n,tabIndex:-1,ref:j,style:{...n.style,...S,position:"absolute",pointerEvents:"none",opacity:0,margin:0,transform:"translateX(-100%)"}})});FN.displayName=qN;function L5(e){return typeof e=="function"}function Tr(e){return e==="indeterminate"}function YN(e){return Tr(e)?"indeterminate":e?"checked":"unchecked"}function co({className:e,...n}){return o.jsx(UN,{"data-slot":"checkbox",className:We("peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",e),...n,children:o.jsx(VN,{"data-slot":"checkbox-indicator",className:"flex items-center justify-center text-current transition-none",children:o.jsx(jo,{className:"size-3.5"})})})}var GN=Object.freeze({position:"absolute",border:0,width:1,height:1,padding:0,margin:-1,overflow:"hidden",clip:"rect(0, 0, 0, 0)",whiteSpace:"nowrap",wordWrap:"normal"}),$5="VisuallyHidden",XN=w.forwardRef((e,n)=>o.jsx(Ye.span,{...e,ref:n,style:{...GN,...e.style}}));XN.displayName=$5;var P5=XN,[Cd,J7]=Kn("Tooltip",[Ua]),kd=Ua(),ZN="TooltipProvider",H5=700,np="tooltip.open",[U5,hg]=Cd(ZN),WN=e=>{const{__scopeTooltip:n,delayDuration:r=H5,skipDelayDuration:a=300,disableHoverableContent:l=!1,children:c}=e,d=w.useRef(!0),f=w.useRef(!1),m=w.useRef(0);return w.useEffect(()=>{const h=m.current;return()=>window.clearTimeout(h)},[]),o.jsx(U5,{scope:n,isOpenDelayedRef:d,delayDuration:r,onOpen:w.useCallback(()=>{window.clearTimeout(m.current),d.current=!1},[]),onClose:w.useCallback(()=>{window.clearTimeout(m.current),m.current=window.setTimeout(()=>d.current=!0,a)},[a]),isPointerInTransitRef:f,onPointerInTransitChange:w.useCallback(h=>{f.current=h},[]),disableHoverableContent:l,children:c})};WN.displayName=ZN;var ul="Tooltip",[B5,Tl]=Cd(ul),KN=e=>{const{__scopeTooltip:n,children:r,open:a,defaultOpen:l,onOpenChange:c,disableHoverableContent:d,delayDuration:f}=e,m=hg(ul,e.__scopeTooltip),h=kd(n),[g,x]=w.useState(null),y=Mr(),b=w.useRef(0),j=d??m.disableHoverableContent,N=f??m.delayDuration,S=w.useRef(!1),[_,A]=Ar({prop:a,defaultProp:l??!1,onChange:z=>{z?(m.onOpen(),document.dispatchEvent(new CustomEvent(np))):m.onClose(),c?.(z)},caller:ul}),E=w.useMemo(()=>_?S.current?"delayed-open":"instant-open":"closed",[_]),M=w.useCallback(()=>{window.clearTimeout(b.current),b.current=0,S.current=!1,A(!0)},[A]),T=w.useCallback(()=>{window.clearTimeout(b.current),b.current=0,A(!1)},[A]),D=w.useCallback(()=>{window.clearTimeout(b.current),b.current=window.setTimeout(()=>{S.current=!0,A(!0),b.current=0},N)},[N,A]);return w.useEffect(()=>()=>{b.current&&(window.clearTimeout(b.current),b.current=0)},[]),o.jsx(Hp,{...h,children:o.jsx(B5,{scope:n,contentId:y,open:_,stateAttribute:E,trigger:g,onTriggerChange:x,onTriggerEnter:w.useCallback(()=>{m.isOpenDelayedRef.current?D():M()},[m.isOpenDelayedRef,D,M]),onTriggerLeave:w.useCallback(()=>{j?T():(window.clearTimeout(b.current),b.current=0)},[T,j]),onOpen:M,onClose:T,disableHoverableContent:j,children:r})})};KN.displayName=ul;var sp="TooltipTrigger",QN=w.forwardRef((e,n)=>{const{__scopeTooltip:r,...a}=e,l=Tl(sp,r),c=hg(sp,r),d=kd(r),f=w.useRef(null),m=rt(n,f,l.onTriggerChange),h=w.useRef(!1),g=w.useRef(!1),x=w.useCallback(()=>h.current=!1,[]);return w.useEffect(()=>()=>document.removeEventListener("pointerup",x),[x]),o.jsx(Up,{asChild:!0,...d,children:o.jsx(Ye.button,{"aria-describedby":l.open?l.contentId:void 0,"data-state":l.stateAttribute,...a,ref:m,onPointerMove:ke(e.onPointerMove,y=>{y.pointerType!=="touch"&&!g.current&&!c.isPointerInTransitRef.current&&(l.onTriggerEnter(),g.current=!0)}),onPointerLeave:ke(e.onPointerLeave,()=>{l.onTriggerLeave(),g.current=!1}),onPointerDown:ke(e.onPointerDown,()=>{l.open&&l.onClose(),h.current=!0,document.addEventListener("pointerup",x,{once:!0})}),onFocus:ke(e.onFocus,()=>{h.current||l.onOpen()}),onBlur:ke(e.onBlur,l.onClose),onClick:ke(e.onClick,l.onClose)})})});QN.displayName=sp;var pg="TooltipPortal",[V5,q5]=Cd(pg,{forceMount:void 0}),JN=e=>{const{__scopeTooltip:n,forceMount:r,children:a,container:l}=e,c=Tl(pg,n);return o.jsx(V5,{scope:n,forceMount:r,children:o.jsx(Cn,{present:r||c.open,children:o.jsx(fd,{asChild:!0,container:l,children:a})})})};JN.displayName=pg;var ka="TooltipContent",e2=w.forwardRef((e,n)=>{const r=q5(ka,e.__scopeTooltip),{forceMount:a=r.forceMount,side:l="top",...c}=e,d=Tl(ka,e.__scopeTooltip);return o.jsx(Cn,{present:a||d.open,children:d.disableHoverableContent?o.jsx(t2,{side:l,...c,ref:n}):o.jsx(F5,{side:l,...c,ref:n})})}),F5=w.forwardRef((e,n)=>{const r=Tl(ka,e.__scopeTooltip),a=hg(ka,e.__scopeTooltip),l=w.useRef(null),c=rt(n,l),[d,f]=w.useState(null),{trigger:m,onClose:h}=r,g=l.current,{onPointerInTransitChange:x}=a,y=w.useCallback(()=>{f(null),x(!1)},[x]),b=w.useCallback((j,N)=>{const S=j.currentTarget,_={x:j.clientX,y:j.clientY},A=W5(_,S.getBoundingClientRect()),E=K5(_,A),M=Q5(N.getBoundingClientRect()),T=eR([...E,...M]);f(T),x(!0)},[x]);return w.useEffect(()=>()=>y(),[y]),w.useEffect(()=>{if(m&&g){const j=S=>b(S,g),N=S=>b(S,m);return m.addEventListener("pointerleave",j),g.addEventListener("pointerleave",N),()=>{m.removeEventListener("pointerleave",j),g.removeEventListener("pointerleave",N)}}},[m,g,b,y]),w.useEffect(()=>{if(d){const j=N=>{const S=N.target,_={x:N.clientX,y:N.clientY},A=m?.contains(S)||g?.contains(S),E=!J5(_,d);A?y():E&&(y(),h())};return document.addEventListener("pointermove",j),()=>document.removeEventListener("pointermove",j)}},[m,g,d,h,y]),o.jsx(t2,{...e,ref:c})}),[Y5,G5]=Cd(ul,{isInside:!1}),X5=cC("TooltipContent"),t2=w.forwardRef((e,n)=>{const{__scopeTooltip:r,children:a,"aria-label":l,onEscapeKeyDown:c,onPointerDownOutside:d,...f}=e,m=Tl(ka,r),h=kd(r),{onClose:g}=m;return w.useEffect(()=>(document.addEventListener(np,g),()=>document.removeEventListener(np,g)),[g]),w.useEffect(()=>{if(m.trigger){const x=y=>{y.target?.contains(m.trigger)&&g()};return window.addEventListener("scroll",x,{capture:!0}),()=>window.removeEventListener("scroll",x,{capture:!0})}},[m.trigger,g]),o.jsx(id,{asChild:!0,disableOutsidePointerEvents:!1,onEscapeKeyDown:c,onPointerDownOutside:d,onFocusOutside:x=>x.preventDefault(),onDismiss:g,children:o.jsxs(Bp,{"data-state":m.stateAttribute,...h,...f,ref:n,style:{...f.style,"--radix-tooltip-content-transform-origin":"var(--radix-popper-transform-origin)","--radix-tooltip-content-available-width":"var(--radix-popper-available-width)","--radix-tooltip-content-available-height":"var(--radix-popper-available-height)","--radix-tooltip-trigger-width":"var(--radix-popper-anchor-width)","--radix-tooltip-trigger-height":"var(--radix-popper-anchor-height)"},children:[o.jsx(X5,{children:a}),o.jsx(Y5,{scope:r,isInside:!0,children:o.jsx(P5,{id:m.contentId,role:"tooltip",children:l||a})})]})})});e2.displayName=ka;var n2="TooltipArrow",Z5=w.forwardRef((e,n)=>{const{__scopeTooltip:r,...a}=e,l=kd(r);return G5(n2,r).isInside?null:o.jsx(Vp,{...l,...a,ref:n})});Z5.displayName=n2;function W5(e,n){const r=Math.abs(n.top-e.y),a=Math.abs(n.bottom-e.y),l=Math.abs(n.right-e.x),c=Math.abs(n.left-e.x);switch(Math.min(r,a,l,c)){case c:return"left";case l:return"right";case r:return"top";case a:return"bottom";default:throw new Error("unreachable")}}function K5(e,n,r=5){const a=[];switch(n){case"top":a.push({x:e.x-r,y:e.y+r},{x:e.x+r,y:e.y+r});break;case"bottom":a.push({x:e.x-r,y:e.y-r},{x:e.x+r,y:e.y-r});break;case"left":a.push({x:e.x+r,y:e.y-r},{x:e.x+r,y:e.y+r});break;case"right":a.push({x:e.x-r,y:e.y-r},{x:e.x-r,y:e.y+r});break}return a}function Q5(e){const{top:n,right:r,bottom:a,left:l}=e;return[{x:l,y:n},{x:r,y:n},{x:r,y:a},{x:l,y:a}]}function J5(e,n){const{x:r,y:a}=e;let l=!1;for(let c=0,d=n.length-1;ca!=y>a&&r<(x-h)*(a-g)/(y-g)+h&&(l=!l)}return l}function eR(e){const n=e.slice();return n.sort((r,a)=>r.xa.x?1:r.ya.y?1:0),tR(n)}function tR(e){if(e.length<=1)return e.slice();const n=[];for(let a=0;a=2;){const c=n[n.length-1],d=n[n.length-2];if((c.x-d.x)*(l.y-d.y)>=(c.y-d.y)*(l.x-d.x))n.pop();else break}n.push(l)}n.pop();const r=[];for(let a=e.length-1;a>=0;a--){const l=e[a];for(;r.length>=2;){const c=r[r.length-1],d=r[r.length-2];if((c.x-d.x)*(l.y-d.y)>=(c.y-d.y)*(l.x-d.x))r.pop();else break}r.push(l)}return r.pop(),n.length===1&&r.length===1&&n[0].x===r[0].x&&n[0].y===r[0].y?n:n.concat(r)}var nR=WN,sR=KN,rR=QN,oR=JN,s2=e2;const aR=nR,iR=sR,lR=rR,r2=w.forwardRef(({className:e,sideOffset:n=4,...r},a)=>o.jsx(oR,{children:o.jsx(s2,{ref:a,sideOffset:n,className:We("z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",e),...r})}));r2.displayName=s2.displayName;const fa={MODEL:"gen_ai.request.model",INPUT_TOKENS:"gen_ai.usage.input_tokens",OUTPUT_TOKENS:"gen_ai.usage.output_tokens",INPUT_MESSAGES:"gen_ai.input.messages",SYSTEM_INSTRUCTIONS:"gen_ai.system_instructions"};function cR(e){return e.type==="text"}function uR(e){return e.type==="tool_call"||e.type==="function_call"}function dR(e){return e.type==="tool_result"||e.type==="function_result"||e.type==="tool_call_response"}function fR(e){if(!e)return[];try{return JSON.parse(e)}catch{return[]}}function mR(e){const n={system:0,user:0,assistant:0,toolCalls:0,toolResults:0,total:0};try{let r;if(typeof e=="string")r=fR(e);else if(Array.isArray(e))r=e;else return n;for(const a of r){if(!a||typeof a!="object")continue;const l=a.role,c=a.parts;let d=0;if(Array.isArray(c)){for(const f of c)if(!(!f||typeof f!="object")){if(cR(f)){const m=f.content||f.text||"";d+=m.length}else if(uR(f)){const m=f.name||"",h=f.arguments||"";n.toolCalls+=m.length+h.length}else if(dR(f)){const m=f.result||f.response||"";n.toolResults+=m.length}}}l==="system"?n.system+=d:l==="user"?n.user+=d:l==="assistant"?n.assistant+=d:l==="tool"&&(n.toolResults+=d)}n.total=n.system+n.user+n.assistant+n.toolCalls+n.toolResults}catch{}return n}function hR(e){const n=e.filter(l=>l.type==="response.trace.completed"),r=new Map;for(const l of n){if(!("data"in l))continue;const c=l.data,d=c.response_id||"unknown";r.has(d)||r.set(d,[]),r.get(d).push(c)}const a=[];for(const[l,c]of r){let d=0,f=0,m,h=Date.now()/1e3,g,x=0,y={system:0,user:0,assistant:0,toolCalls:0,toolResults:0,total:0};for(const b of c){const j=b.attributes||{},N=j[fa.INPUT_TOKENS],S=j[fa.OUTPUT_TOKENS];N!==void 0&&(d+=Number(N)),S!==void 0&&(f+=Number(S)),j[fa.MODEL]&&(m=String(j[fa.MODEL])),b.start_time&&b.start_time0||f>0)&&a.push({response_id:l,timestamp:h,input_tokens:d,output_tokens:f,total_tokens:d+f,model:m,entity_id:g,duration_ms:x,composition:y})}return a.sort((l,c)=>l.timestamp-c.timestamp),a}function pR(e){if(e.length===0)return{totalInput:0,totalOutput:0,totalTokens:0,avgInput:0,avgOutput:0,avgTotal:0,peakInput:0,peakOutput:0,peakTotal:0,turnCount:0};const n=e.reduce((f,m)=>f+m.input_tokens,0),r=e.reduce((f,m)=>f+m.output_tokens,0),a=n+r,l=Math.max(...e.map(f=>f.input_tokens)),c=Math.max(...e.map(f=>f.output_tokens)),d=Math.max(...e.map(f=>f.total_tokens));return{totalInput:n,totalOutput:r,totalTokens:a,avgInput:Math.round(n/e.length),avgOutput:Math.round(r/e.length),avgTotal:Math.round(a/e.length),peakInput:l,peakOutput:c,peakTotal:d,turnCount:e.length}}function gR(e){return e.reduce((n,r)=>({system:n.system+r.composition.system,user:n.user+r.composition.user,assistant:n.assistant+r.composition.assistant,toolCalls:n.toolCalls+r.composition.toolCalls,toolResults:n.toolResults+r.composition.toolResults,total:n.total+r.composition.total}),{system:0,user:0,assistant:0,toolCalls:0,toolResults:0,total:0})}function In(e){return e>=1e3?`${(e/1e3).toFixed(1)}k`:String(e)}const Pt={input:"bg-blue-500 dark:bg-blue-600",output:"bg-emerald-500 dark:bg-emerald-600",system:"bg-purple-500 dark:bg-purple-600",user:"bg-blue-500 dark:bg-blue-600",assistant:"bg-emerald-500 dark:bg-emerald-600",toolCalls:"bg-amber-500 dark:bg-amber-600",toolResults:"bg-orange-500 dark:bg-orange-600"};function lb({segments:e,maxValue:n,height:r=20,renderLabel:a}){const l=e.reduce((f,m)=>f+m.value,0);if(l===0)return o.jsx("div",{className:"flex items-center gap-2 w-full",children:o.jsx("div",{className:"rounded bg-muted/30 flex-1",style:{height:`${r}px`}})});const c=n>0?l/n*100:100,d=e.filter(f=>f.value>0).map(f=>({...f,percent:Math.round(f.value/l*100)}));return o.jsxs("div",{className:"flex items-center gap-2 w-full",children:[o.jsx("div",{className:"relative rounded overflow-hidden bg-muted/30 flex-1",style:{height:`${r}px`},children:o.jsx(aR,{delayDuration:150,children:o.jsx("div",{className:"h-full flex transition-all duration-300",style:{width:`${c}%`},children:d.map(f=>o.jsxs(iR,{children:[o.jsx(lR,{asChild:!0,children:o.jsx("div",{className:`h-full ${f.color} transition-all duration-150 hover:brightness-110 hover:scale-y-[1.15] origin-bottom cursor-default`,style:{width:`${f.value/l*100}%`}})}),o.jsx(r2,{side:"top",className:"text-xs",children:o.jsxs("div",{className:"flex items-center gap-1.5",children:[o.jsx("div",{className:`w-2 h-2 rounded-sm ${f.color} flex-shrink-0`}),o.jsx("span",{className:"font-medium",children:f.label}),o.jsxs("span",{className:"opacity-80",children:[In(f.value)," (",f.percent,"%)"]})]})})]},f.key))})})}),a?.(l,e)]})}function xR(e,n){return[{key:"input",value:e,color:Pt.input,label:"Input"},{key:"output",value:n,color:Pt.output,label:"Output"}]}function yR(e){return[{key:"system",value:e.system,color:Pt.system,label:"System"},{key:"user",value:e.user,color:Pt.user,label:"User"},{key:"assistant",value:e.assistant,color:Pt.assistant,label:"Assistant"},{key:"toolCalls",value:e.toolCalls,color:Pt.toolCalls,label:"Tool Calls"},{key:"toolResults",value:e.toolResults,color:Pt.toolResults,label:"Tool Results"}]}function o2({composition:e,className:n=""}){const{system:r,user:a,assistant:l,toolCalls:c,toolResults:d,total:f}=e;if(f===0)return o.jsx("div",{className:`text-xs text-muted-foreground ${n}`,children:"No composition data available"});const m=[{label:"System",value:r,color:Pt.system},{label:"User",value:a,color:Pt.user},{label:"Assistant",value:l,color:Pt.assistant},{label:"Tool Calls",value:c,color:Pt.toolCalls},{label:"Tool Results",value:d,color:Pt.toolResults}].filter(h=>h.value>0);return o.jsx("div",{className:`space-y-1.5 ${n}`,children:m.map(h=>{const g=Math.round(h.value/f*100);return o.jsxs("div",{className:"flex items-center gap-2 text-xs",children:[o.jsx("div",{className:`w-2 h-2 rounded-sm ${h.color}`}),o.jsx("span",{className:"text-muted-foreground w-20",children:h.label}),o.jsx("div",{className:"flex-1 h-3 bg-muted/30 rounded overflow-hidden",children:o.jsx("div",{className:`h-full ${h.color} transition-all duration-300`,style:{width:`${g}%`}})}),o.jsxs("span",{className:"font-mono w-10 text-right text-muted-foreground",children:[g,"%"]})]},h.label)})})}function vR({turn:e,index:n,maxValue:r,maxCompositionValue:a,cumulativeInput:l,cumulativeOutput:c,cumulativeComposition:d,showCumulative:f,viewMode:m}){const[h,g]=w.useState(!1),x=f?l:e.input_tokens,y=f?c:e.output_tokens,b=f?d:e.composition,j=new Date(e.timestamp*1e3).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit"});return o.jsxs("div",{className:"border-b border-muted/50 last:border-0",children:[o.jsxs("div",{className:"flex items-center gap-3 py-2 px-2 hover:bg-muted/30 cursor-pointer transition-colors",onClick:()=>g(!h),children:[o.jsx("div",{className:"w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs font-medium flex-shrink-0",children:n+1}),o.jsx("div",{className:"flex-1 min-w-0",children:m==="tokens"?o.jsx(lb,{segments:xR(x,y),maxValue:r,height:20,renderLabel:(N,S)=>o.jsxs("div",{className:"flex items-center gap-1 text-xs font-mono text-muted-foreground min-w-[80px] justify-end",children:[o.jsxs("span",{className:"text-blue-600 dark:text-blue-400",children:["↑",In(S[0]?.value||0)]}),o.jsx("span",{children:"/"}),o.jsxs("span",{className:"text-emerald-600 dark:text-emerald-400",children:["↓",In(S[1]?.value||0)]})]})}):o.jsx(lb,{segments:yR(b),maxValue:a,height:20,renderLabel:N=>o.jsxs("div",{className:"text-xs font-mono text-muted-foreground min-w-[50px] text-right",children:[In(Math.round(N/4)),"~"]})})}),o.jsx("div",{className:"text-muted-foreground flex-shrink-0",children:h?o.jsx(Rt,{className:"h-4 w-4"}):o.jsx(en,{className:"h-4 w-4"})})]}),h&&o.jsx("div",{className:"pb-3",children:o.jsxs("div",{className:"flex items-start gap-3 px-2",children:[o.jsx("div",{className:"w-6 flex justify-center flex-shrink-0",children:o.jsx("div",{className:"w-px h-full bg-muted"})}),o.jsx("div",{className:"flex-1 min-w-0",children:o.jsxs("div",{className:"flex items-start gap-2",children:[o.jsx("div",{className:"text-muted-foreground text-xs mt-1",children:"└─"}),o.jsxs("div",{className:"flex-1 space-y-3",children:[o.jsxs("div",{className:"grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground",children:[o.jsxs("div",{children:["Time: ",o.jsx("span",{className:"font-mono text-foreground",children:j})]}),o.jsxs("div",{children:["Duration: ",o.jsxs("span",{className:"font-mono text-foreground",children:[e.duration_ms.toFixed(0),"ms"]})]}),e.model&&o.jsxs("div",{children:["Model: ",o.jsx("span",{className:"font-mono text-foreground",children:e.model})]}),e.entity_id&&o.jsxs("div",{children:["Entity: ",o.jsx("span",{className:"font-mono text-foreground",children:e.entity_id})]})]}),m==="tokens"&&o.jsxs("div",{className:"flex gap-4 text-xs",children:[o.jsxs("div",{children:[o.jsx("span",{className:"text-blue-600 dark:text-blue-400",children:"Input:"})," ",o.jsx("span",{className:"font-mono",children:e.input_tokens.toLocaleString()})]}),o.jsxs("div",{children:[o.jsx("span",{className:"text-emerald-600 dark:text-emerald-400",children:"Output:"})," ",o.jsx("span",{className:"font-mono",children:e.output_tokens.toLocaleString()})]}),o.jsxs("div",{children:[o.jsx("span",{className:"text-muted-foreground",children:"Total:"})," ",o.jsx("span",{className:"font-mono",children:e.total_tokens.toLocaleString()})]})]}),m==="composition"&&e.composition.total>0&&o.jsxs("div",{children:[o.jsxs("div",{className:"text-xs text-muted-foreground mb-2 flex items-center gap-1",children:[o.jsx(Fs,{className:"h-3 w-3"}),"Context Composition (estimated from ~",In(Math.round(e.composition.total/4))," tokens)"]}),o.jsx(o2,{composition:e.composition})]})]})]})})]})})]})}function wh({label:e,value:n,icon:r,color:a="default"}){const l={default:"text-muted-foreground",blue:"text-blue-600 dark:text-blue-400",green:"text-emerald-600 dark:text-emerald-400"}[a];return o.jsxs("div",{className:"flex items-center gap-2 p-2 bg-muted/30 rounded",children:[o.jsx(r,{className:`h-4 w-4 ${l}`}),o.jsxs("div",{className:"flex-1 min-w-0",children:[o.jsx("div",{className:"text-xs text-muted-foreground truncate",children:e}),o.jsx("div",{className:"font-mono text-sm font-medium",children:n})]})]})}function bR({events:e}){const n=le(x=>x.contextInspectorViewMode),r=le(x=>x.setContextInspectorViewMode),a=le(x=>x.contextInspectorCumulative),l=le(x=>x.setContextInspectorCumulative),c=w.useMemo(()=>hR(e),[e]),d=w.useMemo(()=>pR(c),[c]),f=w.useMemo(()=>gR(c),[c]),m=w.useMemo(()=>c.length===0?0:a?d.totalTokens:0,[c,a,d.totalTokens]),h=w.useMemo(()=>c.length===0?0:a?f.total:0,[c,a,f.total]),g=w.useMemo(()=>{let x=0,y=0,b={system:0,user:0,assistant:0,toolCalls:0,toolResults:0,total:0};return c.map(j=>(x+=j.input_tokens,y+=j.output_tokens,b={system:b.system+j.composition.system,user:b.user+j.composition.user,assistant:b.assistant+j.composition.assistant,toolCalls:b.toolCalls+j.composition.toolCalls,toolResults:b.toolResults+j.composition.toolResults,total:b.total+j.composition.total},{input:x,output:y,composition:{...b}}))},[c]);return c.length===0?o.jsxs("div",{className:"flex flex-col items-center text-center p-6 pt-9",children:[o.jsx(ha,{className:"h-8 w-8 text-muted-foreground mb-3"}),o.jsx("div",{className:"text-sm font-medium mb-1",children:"No Data"}),o.jsxs("div",{className:"text-xs text-muted-foreground max-w-[200px]",children:["Run"," ",o.jsx("span",{className:"font-mono bg-accent/10 px-1 rounded",children:"devui --instrumentation"})," ","and start a conversation."]})]}):o.jsxs("div",{className:"h-full flex flex-col",children:[o.jsxs("div",{className:"p-3 border-b flex-shrink-0 space-y-2",children:[o.jsxs("div",{className:"flex items-center justify-between gap-2",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(ha,{className:"h-4 w-4"}),o.jsx("span",{className:"font-medium text-sm",children:"Context Inspector"}),o.jsxs(ut,{variant:"outline",className:"text-xs",children:[c.length," turn",c.length!==1?"s":""]})]}),o.jsxs("label",{className:"flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer",children:[o.jsx(co,{checked:a,onCheckedChange:x=>l(x===!0),className:"h-3.5 w-3.5"}),o.jsx("span",{children:"Cumulative"})]})]}),o.jsxs("div",{className:"flex items-center bg-muted rounded-md p-1",children:[o.jsx("button",{onClick:()=>r("tokens"),className:`flex-1 px-3 py-1.5 text-xs rounded transition-colors ${n==="tokens"?"bg-background shadow-sm font-medium":"text-muted-foreground hover:text-foreground"}`,children:"Tokens"}),o.jsx("button",{onClick:()=>r("composition"),className:`flex-1 px-3 py-1.5 text-xs rounded transition-colors ${n==="composition"?"bg-background shadow-sm font-medium":"text-muted-foreground hover:text-foreground"}`,children:"Composition"})]}),o.jsx("div",{className:"text-xs text-muted-foreground",children:n==="tokens"?"Token usage per turn":"Context breakdown by message type (chars)"})]}),o.jsx(Wn,{className:"flex-1",children:o.jsxs("div",{className:"p-3 space-y-4",children:[o.jsxs("div",{className:"flex items-center gap-4 text-xs px-1 flex-wrap",children:[n==="tokens"?o.jsxs(o.Fragment,{children:[o.jsxs("div",{className:"flex items-center gap-1.5",children:[o.jsx("div",{className:`w-3 h-3 rounded ${Pt.input}`}),o.jsx("span",{className:"text-muted-foreground",children:"Input (↑)"})]}),o.jsxs("div",{className:"flex items-center gap-1.5",children:[o.jsx("div",{className:`w-3 h-3 rounded ${Pt.output}`}),o.jsx("span",{className:"text-muted-foreground",children:"Output (↓)"})]})]}):o.jsxs(o.Fragment,{children:[o.jsxs("div",{className:"flex items-center gap-1.5",children:[o.jsx("div",{className:`w-2.5 h-2.5 rounded-sm ${Pt.system}`}),o.jsx("span",{className:"text-muted-foreground",children:"System"})]}),o.jsxs("div",{className:"flex items-center gap-1.5",children:[o.jsx("div",{className:`w-2.5 h-2.5 rounded-sm ${Pt.user}`}),o.jsx("span",{className:"text-muted-foreground",children:"User"})]}),o.jsxs("div",{className:"flex items-center gap-1.5",children:[o.jsx("div",{className:`w-2.5 h-2.5 rounded-sm ${Pt.assistant}`}),o.jsx("span",{className:"text-muted-foreground",children:"Assistant"})]}),o.jsxs("div",{className:"flex items-center gap-1.5",children:[o.jsx("div",{className:`w-2.5 h-2.5 rounded-sm ${Pt.toolCalls}`}),o.jsx("span",{className:"text-muted-foreground",children:"Tools"})]}),o.jsxs("div",{className:"flex items-center gap-1.5",children:[o.jsx("div",{className:`w-2.5 h-2.5 rounded-sm ${Pt.toolResults}`}),o.jsx("span",{className:"text-muted-foreground",children:"Results"})]})]}),o.jsx("div",{className:"flex-1"}),o.jsxs("div",{className:"flex items-center gap-1 text-muted-foreground",children:[o.jsx(Fs,{className:"h-3 w-3"}),o.jsx("span",{children:"Click for details"})]})]}),o.jsx("div",{className:"border rounded-lg overflow-hidden",children:c.map((x,y)=>o.jsx(vR,{turn:x,index:y,maxValue:m,maxCompositionValue:h,cumulativeInput:g[y]?.input||0,cumulativeOutput:g[y]?.output||0,cumulativeComposition:g[y]?.composition||x.composition,showCumulative:a,viewMode:n},x.response_id))}),o.jsxs("div",{className:"border rounded-lg overflow-hidden",children:[o.jsx("div",{className:"p-3 bg-muted/30 border-b",children:o.jsx("span",{className:"text-xs font-medium",children:"Session Summary"})}),o.jsxs("div",{className:"p-3 space-y-3",children:[o.jsxs("div",{className:"grid grid-cols-3 gap-2",children:[o.jsx(wh,{label:"Total Tokens",value:In(d.totalTokens),icon:JA}),o.jsx(wh,{label:"Input",value:In(d.totalInput),icon:ha,color:"blue"}),o.jsx(wh,{label:"Output",value:In(d.totalOutput),icon:ha,color:"green"})]}),c.length>1&&o.jsxs("div",{className:"grid grid-cols-2 gap-x-4 gap-y-1 text-xs pt-2 border-t border-muted/50",children:[o.jsxs("div",{className:"flex justify-between",children:[o.jsx("span",{className:"text-muted-foreground",children:"Avg per turn:"}),o.jsx("span",{className:"font-mono",children:In(d.avgTotal)})]}),o.jsxs("div",{className:"flex justify-between",children:[o.jsx("span",{className:"text-muted-foreground",children:"Peak turn:"}),o.jsx("span",{className:"font-mono",children:In(d.peakTotal)})]}),o.jsxs("div",{className:"flex justify-between",children:[o.jsx("span",{className:"text-muted-foreground",children:"Avg input:"}),o.jsx("span",{className:"font-mono text-blue-600 dark:text-blue-400",children:In(d.avgInput)})]}),o.jsxs("div",{className:"flex justify-between",children:[o.jsx("span",{className:"text-muted-foreground",children:"Avg output:"}),o.jsx("span",{className:"font-mono text-emerald-600 dark:text-emerald-400",children:In(d.avgOutput)})]})]}),f.total>0&&o.jsx("div",{className:"pt-3 border-t border-muted/50",children:o.jsxs("div",{className:"flex items-start gap-2",children:[o.jsx("div",{className:"text-muted-foreground text-xs mt-0.5",children:"└─"}),o.jsxs("div",{className:"flex-1",children:[o.jsxs("div",{className:"text-xs text-muted-foreground mb-2 flex items-center gap-1",children:[o.jsx(Fs,{className:"h-3 w-3"}),"Total Composition (all turns)"]}),o.jsx(o2,{composition:f})]})]})})]})]})]})})]})}function a2(){return o.jsx("div",{className:"flex items-center gap-2 py-3 px-2",children:o.jsx("div",{className:"flex-1 border-t border-border/50"})})}function i2(e){const n=[];let r=!1;for(let a=0;a100&&(l.includes(` -`)||l.trim().match(/[.!?]\s*$/))&&(n.push({type:"response.output_text.delta",delta:l.trim()}),l="");continue}c.type!=="response.usage.complete"&&n.push(c)}for(const[,c]of r)if(c.arguments.trim()&&c.arguments.trim().length>2){const d=a.get(c.callId)||c.name||"unknown";n.push({type:"response.function_call.complete",data:{name:d,arguments:c.arguments,call_id:c.callId}})}return l.trim()&&n.push({type:"response.output_text.delta",delta:l.trim()}),n}function wR(e){switch(e.type){case"response.output_text.delta":if("delta"in e){const n=e.delta||"";return n.length>60?`${n.slice(0,60)}...`:n}return"Text output";case"response.function_call.complete":if("data"in e&&e.data){const n=e.data;let r=n.name||"unknown";(!r||r==="unknown")&&(r="function_call");const a=n.arguments?typeof n.arguments=="string"?n.arguments.slice(0,30):JSON.stringify(n.arguments).slice(0,30):"";return`Calling ${r}(${a}${a.length>=30?"...":""})`}return"Function call";case"response.function_call_arguments.delta":return"delta"in e&&e.delta?`Function arg delta: ${e.delta.slice(0,30)}${e.delta.length>30?"...":""}`:"Function arguments...";case"response.function_result.complete":{const r=e.output.slice(0,40);return`Function result: ${r}${r.length>=40?"...":""}`}case"response.output_item.added":{const n=e;return n.item.type==="function_call"?`Tool call: ${n.item.name}`:"Output item added"}case"response.workflow_event.completed":return"data"in e&&e.data?`Executor: ${e.data.executor_id||"unknown"}`:"Workflow event";case"response.trace.completed":return"data"in e&&e.data?`Trace: ${e.data.operation_name||"unknown"}`:"Trace event";case"response.completed":if("response"in e&&e.response&&"usage"in e.response){const r=e.response.usage;if(r)return`Response complete (${r.total_tokens} tokens)`}return"Response complete";case"response.done":return"Response complete";case"error":return"message"in e&&typeof e.message=="string"?e.message:"Error occurred";default:return`${e.type}`}}function NR(e){switch(e){case"response.output_text.delta":return eg;case"response.function_call.complete":case"response.function_call.delta":case"response.function_call_arguments.delta":return _a;case"response.function_result.complete":return nn;case"response.output_item.added":return nn;case"response.workflow_event.completed":return Qp;case"response.trace.completed":return Bu;case"response.completed":return nn;case"response.done":return nn;case"error":return kl;default:return hs}}function jR(e){switch(e){case"response.output_text.delta":return"text-gray-600 dark:text-gray-400";case"response.function_call.complete":case"response.function_call.delta":case"response.function_call_arguments.delta":return"text-blue-600 dark:text-blue-400";case"response.function_result.complete":return"text-green-600 dark:text-green-400";case"response.output_item.added":return"text-green-600 dark:text-green-400";case"response.workflow_event.completed":return"text-purple-600 dark:text-purple-400";case"response.trace.completed":return"text-orange-600 dark:text-orange-400";case"response.completed":return"text-green-600 dark:text-green-400";case"response.done":return"text-green-600 dark:text-green-400";case"error":return"text-red-600 dark:text-red-400";default:return"text-gray-600 dark:text-gray-400"}}function SR({event:e}){const[n,r]=w.useState(!1),a=e.type||"unknown",l=NR(a),c=jR(a),d="_uiTimestamp"in e&&typeof e._uiTimestamp=="number"?new Date(e._uiTimestamp*1e3).toLocaleTimeString():new Date().toLocaleTimeString(),f=wR(e),m=e.type==="response.function_call.complete"&&"data"in e&&e.data||e.type==="response.function_result.complete"||e.type==="response.output_item.added"&&zr(e)!==null||e.type==="response.workflow_event.completed"&&"data"in e&&e.data||e.type==="response.trace.completed"&&"data"in e&&e.data||e.type==="response.trace.completed"&&"data"in e&&e.data||e.type==="response.output_text.delta"&&"delta"in e&&e.delta&&e.delta.length>100||e.type==="response.completed"&&"response"in e&&e.response||e.type==="error";return o.jsxs("div",{className:"border-l-2 border-muted pl-3 py-2 hover:bg-muted/50 transition-colors",children:[o.jsxs("div",{className:"flex items-center gap-2 text-xs text-muted-foreground mb-1",children:[o.jsx(l,{className:`h-3 w-3 ${c}`}),o.jsx("span",{className:"font-mono",children:d}),o.jsx(ut,{variant:"outline",className:"text-xs py-0",children:e.type?e.type.replace("response.",""):"unknown"})]}),o.jsxs("div",{className:"text-sm",children:[o.jsxs("div",{className:`flex items-center gap-2 ${m?"cursor-pointer":""}`,onClick:()=>m&&r(!n),children:[m&&o.jsx("div",{className:"text-muted-foreground",children:n?o.jsx(Rt,{className:"h-3 w-3"}):o.jsx(en,{className:"h-3 w-3"})}),o.jsx("div",{className:"text-muted-foreground flex-1",children:m&&f.length>80?`${f.slice(0,80)}...`:f})]}),n&&m&&o.jsx("div",{className:"mt-2 ml-5 p-3 bg-muted/30 rounded border",children:o.jsx(_R,{event:e})})]})]})}function _R({event:e}){if(e.type==="error"){const n=e;return o.jsxs("div",{className:"space-y-2",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(kl,{className:"h-4 w-4 text-red-500"}),o.jsx("span",{className:"font-semibold text-sm",children:"Error Details"})]}),o.jsxs("div",{className:"text-xs",children:[n.message&&o.jsxs("div",{className:"mb-2",children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Message:"}),o.jsx("div",{className:"mt-1",children:o.jsx("pre",{className:"text-xs bg-destructive/10 border border-destructive/30 rounded p-2 text-destructive whitespace-pre-wrap break-all",children:n.message})})]}),n.code&&o.jsxs("div",{className:"mb-2",children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Code:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.code})]}),n.param&&o.jsxs("div",{className:"mb-2",children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Parameter:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.param})]}),o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Raw Event:"}),o.jsx("div",{className:"mt-1",children:o.jsx("pre",{className:"text-xs bg-background border rounded p-2 whitespace-pre-wrap break-all max-h-32 overflow-auto",children:JSON.stringify(e,null,2)})})]})]})]})}switch(e.type){case"response.function_call.complete":if("data"in e&&e.data){const n=e.data;return o.jsxs("div",{className:"space-y-2",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(_a,{className:"h-4 w-4 text-blue-500"}),o.jsx("span",{className:"font-semibold text-sm",children:"Function Call"})]}),o.jsxs("div",{className:"grid grid-cols-1 gap-2 text-xs",children:[o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Function:"}),o.jsx("span",{className:"ml-2 font-mono bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded",children:n.name||"unknown"})]}),n.call_id&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Call ID:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.call_id})]}),n.arguments&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Arguments:"}),o.jsx("div",{className:"mt-1 max-h-32 overflow-auto",children:o.jsx("pre",{className:"text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all",children:typeof n.arguments=="string"?n.arguments:JSON.stringify(n.arguments,null,1)})})]})]})]})}break;case"response.function_result.complete":{const n=e;return o.jsxs("div",{className:"space-y-2",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(nn,{className:"h-4 w-4 text-green-500"}),o.jsx("span",{className:"font-semibold text-sm",children:"Function Result"})]}),o.jsxs("div",{className:"grid grid-cols-1 gap-2 text-xs",children:[o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Call ID:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.call_id})]}),o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Status:"}),o.jsx("span",{className:`ml-2 px-2 py-1 rounded text-xs font-medium ${n.status==="completed"?"bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200":"bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"}`,children:n.status})]}),o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Output:"}),o.jsx("div",{className:"mt-1 max-h-32 overflow-auto",children:o.jsx("pre",{className:"text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all",children:n.output})})]})]})]})}case"response.output_item.added":{const n=zr(e);if(n)return o.jsxs("div",{className:"space-y-2",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(nn,{className:"h-4 w-4 text-green-500"}),o.jsx("span",{className:"font-semibold text-sm",children:"Function Result"})]}),o.jsxs("div",{className:"grid grid-cols-1 gap-2 text-xs",children:[o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Call ID:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.call_id})]}),o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Status:"}),o.jsx("span",{className:`ml-2 px-2 py-1 rounded text-xs font-medium ${n.status==="completed"?"bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200":"bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"}`,children:n.status})]}),o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Output:"}),o.jsx("div",{className:"mt-1 max-h-32 overflow-auto",children:o.jsx("pre",{className:"text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all",children:n.output})})]})]})]});break}case"response.workflow_event.completed":if("data"in e&&e.data){const n=e.data;return o.jsxs("div",{className:"space-y-2",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(Qp,{className:"h-4 w-4 text-purple-500"}),o.jsx("span",{className:"font-semibold text-sm",children:"Workflow Event"})]}),o.jsxs("div",{className:"grid grid-cols-1 gap-2 text-xs",children:[o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Event Type:"}),o.jsx("span",{className:"ml-2 font-mono bg-purple-100 dark:bg-purple-900 px-2 py-1 rounded",children:n.event_type||"unknown"})]}),n.executor_id&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Executor:"}),o.jsx("span",{className:"ml-2 font-mono",children:n.executor_id})]}),n.timestamp&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Timestamp:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.timestamp})]}),n.data&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Data:"}),o.jsx("div",{className:"mt-1 max-h-32 overflow-auto",children:o.jsx("pre",{className:"text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all",children:typeof n.data=="string"?n.data:JSON.stringify(n.data,null,1)})})]})]})]})}break;case"response.trace.completed":if("data"in e&&e.data){const n=e.data;return o.jsxs("div",{className:"space-y-2",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(Bu,{className:"h-4 w-4 text-orange-500"}),o.jsx("span",{className:"font-semibold text-sm",children:"Trace Event"})]}),o.jsxs("div",{className:"grid grid-cols-1 gap-2 text-xs",children:[o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Operation:"}),o.jsx("span",{className:"ml-2 font-mono bg-orange-100 dark:bg-orange-900 px-2 py-1 rounded",children:n.operation_name||"unknown"})]}),n.span_id&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Span ID:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.span_id})]}),n.trace_id&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Trace ID:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.trace_id})]}),n.duration_ms&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Duration:"}),o.jsxs("span",{className:"ml-2 font-mono text-xs",children:[Number(n.duration_ms).toFixed(2),"ms"]})]}),n.status&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Status:"}),o.jsx("span",{className:`ml-2 px-2 py-1 rounded text-xs font-medium ${n.status==="StatusCode.UNSET"||n.status==="OK"?"bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200":"bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"}`,children:n.status||"unknown"})]}),n.entity_id&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Entity:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.entity_id})]}),n.attributes&&Object.keys(n.attributes).length>0&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Attributes:"}),o.jsx("div",{className:"mt-1 max-h-32 overflow-auto",children:o.jsx("pre",{className:"text-xs bg-background border rounded p-2 whitespace-pre-wrap break-all",children:l2(n.attributes)})})]})]})]})}break;case"response.output_text.delta":if("delta"in e&&e.delta)return o.jsxs("div",{className:"space-y-2",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(eg,{className:"h-4 w-4 text-gray-500"}),o.jsx("span",{className:"font-semibold text-sm",children:"Text Output"})]}),o.jsx("div",{className:"max-h-32 overflow-auto",children:o.jsx("pre",{className:"text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all",children:e.delta})})]});break;case"response.completed":if("response"in e&&e.response){const r=e.response;return o.jsx("div",{className:"space-y-2",children:o.jsxs("div",{className:"grid grid-cols-1 gap-2 text-xs",children:[r.usage&&o.jsxs(o.Fragment,{children:[o.jsx("div",{children:o.jsx("span",{className:"font-medium text-muted-foreground",children:"Usage:"})}),o.jsxs("div",{className:"ml-4 space-y-1",children:[o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Input tokens:"}),o.jsx("span",{className:"ml-2 font-mono",children:r.usage.input_tokens})]}),o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Output tokens:"}),o.jsx("span",{className:"ml-2 font-mono",children:r.usage.output_tokens})]}),o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Total tokens:"}),o.jsx("span",{className:"ml-2 font-mono bg-green-100 dark:bg-green-900 px-2 py-1 rounded",children:r.usage.total_tokens})]})]})]}),r.id&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Response ID:"}),o.jsx("span",{className:"ml-2 font-mono text-xs break-all",children:r.id})]}),r.model&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Model:"}),o.jsx("span",{className:"ml-2 font-mono text-xs break-all",children:r.model})]})]})})}break;default:return o.jsx("div",{className:"text-xs text-muted-foreground",children:o.jsx("pre",{className:"bg-background border rounded p-2 overflow-auto max-h-32",children:JSON.stringify(e,null,2)})})}return null}function ER({events:e,isStreaming:n}){const r=w.useRef(null),a=gg(e),c=[...i2(a)].reverse();return o.jsxs("div",{className:"h-full flex flex-col",children:[o.jsxs("div",{className:"flex items-center justify-between p-3 border-b",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(Qp,{className:"h-4 w-4"}),o.jsx("span",{className:"font-medium",children:"Events"}),o.jsxs(ut,{variant:"outline",children:[a.length,e.length>a.length?` (${e.length} raw)`:""]})]}),n&&o.jsxs("div",{className:"flex items-center gap-1 text-xs text-muted-foreground",children:[o.jsx("div",{className:"h-2 w-2 animate-pulse rounded-full bg-green-500 dark:bg-green-400"}),"Streaming"]})]}),o.jsx(Wn,{ref:r,className:"flex-1",children:o.jsx("div",{className:"p-3",children:a.length===0?o.jsx("div",{className:"text-center text-muted-foreground text-sm py-8",children:e.length===0?"No events yet. Start a conversation to see real-time events.":"Processing events... Accumulated events will appear here."}):o.jsx("div",{className:"space-y-2",children:c.map((d,f)=>"type"in d&&d.type==="separator"?o.jsx(a2,{},d.id):o.jsx(SR,{event:d},`${d.type}-${f}`))})})})]})}function CR(e){const n=new Map;for(const a of e){if(!("data"in a))continue;const c=a.data.response_id||"unknown";n.has(c)||n.set(c,[]),n.get(c).push(a)}const r=[];for(const[a,l]of n){const c=new Map,d=[];for(const y of l){if(!("data"in y))continue;const b=y.data,j=b.span_id||`span_${Math.random()}`;c.set(j,{event:y,data:b,children:[]})}for(const y of l){if(!("data"in y))continue;const b=y.data,j=b.span_id||"",N=b.parent_span_id,S=c.get(j);S&&(N&&c.has(N)?c.get(N).children.push(S):d.push(S))}d.sort((y,b)=>(y.data.start_time||0)-(b.data.start_time||0));const f=y=>{y.children.sort((b,j)=>(b.data.start_time||0)-(j.data.start_time||0)),y.children.forEach(f)};d.forEach(f);const m=l[0],h=m&&"data"in m?m.data:null,g=Math.min(...l.map(y=>("data"in y?y.data:null)?.start_time||Date.now()/1e3)),x=l.reduce((y,b)=>{const j="data"in b?b.data:null;return y+(j?.duration_ms||0)},0);r.push({response_id:a,timestamp:g,traces:d,totalDuration:x,entity_id:h?.entity_id})}return r.sort((a,l)=>l.timestamp-a.timestamp),r}function Su(e){if(typeof e=="string"){const n=e.trim();if(n.startsWith("[")||n.startsWith("{"))try{const r=JSON.parse(e);return Su(r)}catch{return e}return e}if(Array.isArray(e))return e.map(Su);if(e!==null&&typeof e=="object"){const n={};for(const[r,a]of Object.entries(e))n[r]=Su(a);return n}return e}function l2(e){try{const n=Su(e);return JSON.stringify(n,null,2)}catch{return JSON.stringify(e,null,2)}}function kR(e){return e.includes("invoke_agent")||e.includes("Agent")?"bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200":e.includes("chat")||e.includes("Chat")?"bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200":e.includes("tool")||e.includes("execute")?"bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200":"bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200"}function c2({node:e,depth:n=0}){const[r,a]=w.useState(n<2),[l,c]=w.useState(!1),{data:d}=e,f=d.operation_name||"Unknown",m=d.duration_ms?`${Number(d.duration_ms).toFixed(1)}ms`:"",h=e.children.length>0,g=d.attributes?.["gen_ai.usage.input_tokens"],x=d.attributes?.["gen_ai.usage.output_tokens"],y=g!==void 0||x!==void 0;return o.jsxs("div",{className:"relative",children:[n>0&&o.jsx("div",{className:"absolute left-0 top-0 bottom-0 border-l-2 border-muted",style:{marginLeft:`${(n-1)*16+8}px`}}),o.jsxs("div",{className:"flex items-center gap-2 py-1.5 hover:bg-muted/50 rounded transition-colors",style:{paddingLeft:`${n*16}px`},children:[o.jsx("button",{onClick:()=>h?a(!r):c(!l),className:"w-4 h-4 flex items-center justify-center text-muted-foreground hover:text-foreground",children:h?r?o.jsx(Rt,{className:"h-3 w-3"}):o.jsx(en,{className:"h-3 w-3"}):l?o.jsx(Rt,{className:"h-3 w-3"}):o.jsx(en,{className:"h-3 w-3"})}),o.jsx("span",{className:`text-xs px-1.5 py-0.5 rounded font-medium ${kR(f)}`,children:f.replace("ChatAgent.","").replace("invoke_agent ","")}),m&&o.jsx("span",{className:"text-xs text-muted-foreground font-mono",children:m}),y&&o.jsxs("span",{className:"text-xs text-muted-foreground font-mono",children:[g!==void 0&&o.jsxs("span",{children:["↑",String(g)]}),g!==void 0&&x!==void 0&&o.jsx("span",{className:"mx-0.5",children:"/"}),x!==void 0&&o.jsxs("span",{children:["↓",String(x)]})]})]}),l&&!h&&o.jsx("div",{className:"ml-4 mt-1 mb-2 p-2 bg-muted/30 rounded border text-xs",style:{marginLeft:`${n*16+20}px`},children:o.jsxs("div",{className:"space-y-1",children:[d.span_id&&o.jsxs("div",{className:"flex gap-2",children:[o.jsx("span",{className:"text-muted-foreground w-20",children:"Span ID:"}),o.jsx("span",{className:"font-mono text-xs break-all",children:d.span_id})]}),d.trace_id&&o.jsxs("div",{className:"flex gap-2",children:[o.jsx("span",{className:"text-muted-foreground w-20",children:"Trace ID:"}),o.jsx("span",{className:"font-mono text-xs break-all",children:d.trace_id})]}),d.status&&o.jsxs("div",{className:"flex gap-2",children:[o.jsx("span",{className:"text-muted-foreground w-20",children:"Status:"}),o.jsx("span",{className:`px-1.5 py-0.5 rounded text-xs ${d.status==="StatusCode.UNSET"||d.status==="OK"?"bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200":"bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"}`,children:d.status})]}),d.attributes&&Object.keys(d.attributes).length>0&&o.jsxs("div",{className:"mt-2",children:[o.jsx("span",{className:"text-muted-foreground block mb-1",children:"Attributes:"}),o.jsx("pre",{className:"text-xs bg-background border rounded p-2 overflow-auto max-h-32 whitespace-pre-wrap break-all",children:l2(d.attributes)})]})]})}),h&&r&&o.jsx("div",{children:e.children.map((b,j)=>o.jsx(c2,{node:b,depth:n+1},b.data.span_id||j))})]})}function TR({group:e}){const[n,r]=w.useState(!0),a=new Date(e.timestamp*1e3).toLocaleTimeString(),l=e.totalDuration>0?`${e.totalDuration.toFixed(0)}ms`:"",c=e.traces.reduce((d,f)=>{const m=h=>1+h.children.reduce((g,x)=>g+m(x),0);return d+m(f)},0);return o.jsxs("div",{className:"border rounded-lg overflow-hidden",children:[o.jsxs("div",{className:"flex items-center gap-2 p-2 bg-muted/50 cursor-pointer hover:bg-muted/70 transition-colors",onClick:()=>r(!n),children:[o.jsx("div",{className:"text-muted-foreground",children:n?o.jsx(Rt,{className:"h-4 w-4"}):o.jsx(en,{className:"h-4 w-4"})}),o.jsx("span",{className:"font-mono text-xs text-muted-foreground",children:a}),e.entity_id&&o.jsx(ut,{variant:"outline",className:"text-xs py-0",children:e.entity_id.replace("agent_","").replace("workflow_","")}),o.jsx("div",{className:"flex-1"}),l&&o.jsx(ut,{variant:"secondary",className:"text-xs py-0",children:l}),o.jsxs("span",{className:"text-xs text-muted-foreground",children:[c," span",c!==1?"s":""]})]}),n&&o.jsx("div",{className:"p-2 border-t",children:e.traces.map((d,f)=>o.jsx(c2,{node:d,depth:0},d.data.span_id||f))})]})}function AR({events:e}){const n=le(c=>c.debugTraceSubTab),r=le(c=>c.setDebugTraceSubTab),a=e.filter(c=>c.type==="response.trace.completed"),l=CR(a);return o.jsxs("div",{className:"h-full flex flex-col",children:[o.jsxs("div",{className:"flex items-center gap-2 p-3 border-b",children:[o.jsx(Bu,{className:"h-4 w-4"}),o.jsx("span",{className:"font-medium",children:"Traces"}),o.jsx(ut,{variant:"outline",children:a.length}),o.jsx("div",{className:"flex-1"}),o.jsxs("div",{className:"flex items-center bg-muted rounded-md p-1 min-w-0",children:[o.jsx("button",{onClick:()=>r("spans"),className:`px-3 py-1.5 text-xs rounded transition-colors truncate ${n==="spans"?"bg-background shadow-sm font-medium":"text-muted-foreground hover:text-foreground"}`,children:"OTel Spans"}),o.jsxs("button",{onClick:()=>r("context"),className:`px-3 py-1.5 text-xs rounded transition-colors flex items-center gap-1.5 min-w-0 ${n==="context"?"bg-background shadow-sm font-medium":"text-muted-foreground hover:text-foreground"}`,children:[o.jsx(ha,{className:"h-3.5 w-3.5 flex-shrink-0"}),o.jsx("span",{className:"truncate",children:"Context Inspector"})]})]})]}),n==="spans"?o.jsxs("div",{className:"flex-1 flex flex-col min-h-0",children:[a.length>0&&o.jsx("div",{className:"p-3 border-b flex-shrink-0",children:o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(Bu,{className:"h-4 w-4"}),o.jsx("span",{className:"font-medium text-sm",children:"OTel Spans"}),o.jsxs(ut,{variant:"outline",className:"text-xs",children:[l.length," turn",l.length!==1?"s":""]})]})}),a.length===0?o.jsxs("div",{className:"flex flex-col items-center text-center p-6 pt-9",children:[o.jsx(ha,{className:"h-8 w-8 text-muted-foreground mb-3"}),o.jsx("div",{className:"text-sm font-medium mb-1",children:"No Data"}),o.jsxs("div",{className:"text-xs text-muted-foreground max-w-[200px]",children:["Run"," ",o.jsx("span",{className:"font-mono bg-accent/10 px-1 rounded",children:"devui --instrumentation"})," ","and start a conversation."]})]}):o.jsx(Wn,{className:"flex-1",children:o.jsx("div",{className:"p-3",children:o.jsx("div",{className:"space-y-3",children:l.map(c=>o.jsx(TR,{group:c},c.response_id))})})})]}):o.jsx(bR,{events:e})]})}function MR({events:e}){const n=gg(e),r=[],a=n.filter(m=>m.type==="response.function_call.complete"),l=e.filter(m=>zr(m)!==null),c=new Map;l.forEach(m=>{const h=zr(m);h&&c.set(h.call_id,m)}),a.forEach(m=>{if(r.push(m),"data"in m&&m.data&&m.data.call_id){const h=String(m.data.call_id),g=c.get(h);g&&(r.push(g),c.delete(h))}}),c.forEach(m=>{r.push(m)});const f=[...i2(r)].reverse();return o.jsxs("div",{className:"h-full flex flex-col",children:[o.jsxs("div",{className:"flex items-center gap-2 p-3 border-b",children:[o.jsx(_a,{className:"h-4 w-4"}),o.jsx("span",{className:"font-medium",children:"Tools"}),o.jsx(ut,{variant:"outline",children:r.length})]}),o.jsx(Wn,{className:"flex-1",children:o.jsx("div",{className:"p-3",children:r.length===0?o.jsx("div",{className:"text-center text-muted-foreground text-sm py-8",children:"No tool executions yet. Tool calls will appear here during conversations."}):o.jsx("div",{className:"space-y-3",children:f.map((m,h)=>"type"in m&&m.type==="separator"?o.jsx(a2,{},m.id):o.jsx(RR,{event:m},h))})})})]})}function RR({event:e}){const n="_uiTimestamp"in e&&typeof e._uiTimestamp=="number"?new Date(e._uiTimestamp*1e3).toLocaleTimeString():new Date().toLocaleTimeString(),r=e.type==="response.function_call.complete",a=zr(e),l=a!==null;if(!r&&!l)return null;const c=r&&"data"in e?e.data:null;return o.jsxs("div",{className:"border rounded p-3",children:[o.jsxs("div",{className:"flex items-center justify-between mb-2",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(og,{className:"h-4 w-4 text-yellow-600 dark:text-yellow-400"}),o.jsx("span",{className:"font-medium text-sm",children:r?"Tool Call":"Tool Result"}),r&&c&&c.name!==void 0&&o.jsxs("span",{className:"text-xs text-muted-foreground",children:["(",String(c.name),")"]})]}),o.jsx("span",{className:"text-xs text-muted-foreground font-mono",children:n})]}),r&&c&&o.jsxs("div",{className:"p-2 bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded",children:[o.jsxs("div",{className:"flex items-center gap-2 mb-2",children:[o.jsx(_a,{className:"h-3 w-3 text-blue-600 dark:text-blue-400"}),o.jsx("span",{className:"text-xs font-mono bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded",children:"CALL"}),o.jsx("span",{className:"font-medium text-sm",children:String(c.name||"unknown")})]}),c.arguments!==void 0&&o.jsxs("div",{className:"text-xs",children:[o.jsx("span",{className:"text-muted-foreground mb-1 block",children:"Arguments:"}),o.jsx("pre",{className:"p-2 bg-background border rounded text-xs overflow-auto max-h-32 max-w-full break-all whitespace-pre-wrap",children:typeof c.arguments=="string"?c.arguments:JSON.stringify(c.arguments,null,1)})]})]}),l&&a&&o.jsxs("div",{className:"p-2 bg-green-50 dark:bg-green-950/50 border border-green-200 dark:border-green-800 rounded",children:[o.jsxs("div",{className:"flex items-center gap-2 mb-2",children:[o.jsx(nn,{className:"h-3 w-3 text-green-600 dark:text-green-400"}),o.jsx("span",{className:"text-xs font-mono bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded",children:"RESULT"}),a.status!=="completed"&&o.jsx("span",{className:"ml-auto px-2 py-1 rounded text-xs font-medium bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200",children:a.status})]}),o.jsxs("div",{className:"text-xs space-y-1",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx("span",{className:"text-muted-foreground",children:"Call ID:"}),o.jsx("span",{className:"font-mono text-xs break-all",children:a.call_id})]}),o.jsxs("div",{children:[o.jsx("span",{className:"text-muted-foreground block mb-1",children:"Output:"}),o.jsx("pre",{className:"p-2 bg-background border rounded text-xs overflow-auto max-h-32 break-all whitespace-pre-wrap",children:a.output})]})]})]})]})}function DR({events:e,isStreaming:n=!1,onMinimize:r}){const a=le(d=>d.debugPanelTab),l=le(d=>d.setDebugPanelTab),c=w.useMemo(()=>{const d=gg(e),f=d.length,m=e.filter(g=>g.type==="response.trace.completed").length,h=d.filter(g=>g.type==="response.function_call.complete").length+e.filter(g=>zr(g)!==null).length;return{eventsCount:f,tracesCount:m,toolsCount:h}},[e]);return o.jsx("div",{className:"flex-1 border-l flex flex-col min-h-0",children:o.jsxs(D5,{value:a,onValueChange:d=>l(d),className:"flex-1 flex flex-col min-h-0",children:[o.jsxs("div",{className:"px-3 pt-3 flex items-center gap-2 flex-shrink-0",children:[o.jsxs($N,{className:"flex-1",children:[o.jsxs(Nu,{value:"events",className:"flex-1 gap-1.5",children:["Events",c.eventsCount>0&&o.jsx("span",{className:"text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center",children:c.eventsCount})]}),o.jsxs(Nu,{value:"traces",className:"flex-1 gap-1.5",children:["Traces",c.tracesCount>0&&o.jsx("span",{className:"text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center",children:c.tracesCount})]}),o.jsxs(Nu,{value:"tools",className:"flex-1 gap-1.5",children:["Tools",c.toolsCount>0&&o.jsx("span",{className:"text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center",children:c.toolsCount})]})]}),r&&o.jsx(Le,{variant:"ghost",size:"sm",onClick:r,className:"h-8 w-8 p-0 flex-shrink-0",title:"Minimize debug panel",children:o.jsx(en,{className:"h-4 w-4"})})]}),o.jsx(ju,{value:"events",className:"flex-1 mt-0 overflow-hidden",children:o.jsx(ER,{events:e,isStreaming:n})}),o.jsx(ju,{value:"traces",className:"flex-1 mt-0 overflow-hidden",children:o.jsx(AR,{events:e})}),o.jsx(ju,{value:"tools",className:"flex-1 mt-0 overflow-hidden",children:o.jsx(MR,{events:e})})]})})}function Ir({open:e,onOpenChange:n,children:r}){if(!e)return null;const a=()=>{n(!1)},l=d=>{d.stopPropagation()},c=d=>{d.stopPropagation()};return o.jsxs("div",{className:"fixed inset-0 z-50 flex items-center justify-center",children:[o.jsx("div",{className:"absolute inset-0 bg-black/50",onClick:a}),o.jsx("div",{className:"relative z-10",onClick:l,onMouseDown:c,onMouseUp:d=>d.stopPropagation(),children:r})]})}function Lr({children:e,className:n=""}){const a=n.includes("w-[")||n.includes("w-full")||n.includes("max-w-")?"":"max-w-lg w-full";return o.jsx("div",{className:`relative bg-background border rounded-lg shadow-lg max-h-[90vh] overflow-hidden ${a} ${n}`,children:e})}function $r({children:e,className:n=""}){return o.jsx("div",{className:`space-y-2 ${n}`,children:e})}function Pr({children:e,className:n=""}){return o.jsx("h2",{className:`text-lg font-semibold ${n}`,children:e})}function OR({children:e,className:n=""}){return o.jsx("p",{className:`text-sm text-muted-foreground ${n}`,children:e})}function So({onClose:e}){return o.jsx(Le,{variant:"ghost",size:"sm",onClick:e,className:"absolute top-4 right-4 h-8 w-8 p-0 rounded-sm opacity-70 hover:opacity-100",children:o.jsx(Ea,{className:"h-4 w-4"})})}function zR({children:e}){return o.jsx("div",{className:"flex justify-end gap-2 p-4 border-t bg-muted/50",children:e})}function as({className:e,type:n,...r}){return o.jsx("input",{type:n,"data-slot":"input",className:We("file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm","focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]","aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",e),...r})}var IR="Label",u2=w.forwardRef((e,n)=>o.jsx(Ye.label,{...e,ref:n,onMouseDown:r=>{r.target.closest("button, input, select, textarea")||(e.onMouseDown?.(r),!r.defaultPrevented&&r.detail>1&&r.preventDefault())}}));u2.displayName=IR;var LR=u2;function kt({className:e,...n}){return o.jsx(LR,{"data-slot":"label",className:We("flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",e),...n})}var Td="Switch",[$R,e$]=Kn(Td),[PR,HR]=$R(Td),d2=w.forwardRef((e,n)=>{const{__scopeSwitch:r,name:a,checked:l,defaultChecked:c,required:d,disabled:f,value:m="on",onCheckedChange:h,form:g,...x}=e,[y,b]=w.useState(null),j=rt(n,E=>b(E)),N=w.useRef(!1),S=y?g||!!y.closest("form"):!0,[_,A]=Ar({prop:l,defaultProp:c??!1,onChange:h,caller:Td});return o.jsxs(PR,{scope:r,checked:_,disabled:f,children:[o.jsx(Ye.button,{type:"button",role:"switch","aria-checked":_,"aria-required":d,"data-state":p2(_),"data-disabled":f?"":void 0,disabled:f,value:m,...x,ref:j,onClick:ke(e.onClick,E=>{A(M=>!M),S&&(N.current=E.isPropagationStopped(),N.current||E.stopPropagation())})}),S&&o.jsx(h2,{control:y,bubbles:!N.current,name:a,value:m,checked:_,required:d,disabled:f,form:g,style:{transform:"translateX(-100%)"}})]})});d2.displayName=Td;var f2="SwitchThumb",m2=w.forwardRef((e,n)=>{const{__scopeSwitch:r,...a}=e,l=HR(f2,r);return o.jsx(Ye.span,{"data-state":p2(l.checked),"data-disabled":l.disabled?"":void 0,...a,ref:n})});m2.displayName=f2;var UR="SwitchBubbleInput",h2=w.forwardRef(({__scopeSwitch:e,control:n,checked:r,bubbles:a=!0,...l},c)=>{const d=w.useRef(null),f=rt(d,c),m=fg(r),h=Lp(n);return w.useEffect(()=>{const g=d.current;if(!g)return;const x=window.HTMLInputElement.prototype,b=Object.getOwnPropertyDescriptor(x,"checked").set;if(m!==r&&b){const j=new Event("click",{bubbles:a});b.call(g,r),g.dispatchEvent(j)}},[m,r,a]),o.jsx("input",{type:"checkbox","aria-hidden":!0,defaultChecked:r,...l,tabIndex:-1,ref:f,style:{...l.style,...h,position:"absolute",pointerEvents:"none",opacity:0,margin:0}})});h2.displayName=UR;function p2(e){return e?"checked":"unchecked"}var g2=d2,BR=m2;const Wi=w.forwardRef(({className:e,...n},r)=>o.jsx(g2,{className:We("peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",e),...n,ref:r,children:o.jsx(BR,{className:We("pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0")})}));Wi.displayName=g2.displayName;const VR=["gpt-4.1","gpt-4.1-mini","o1","o1-mini","o3-mini"];function cb({open:e,onOpenChange:n,onBackendUrlChange:r}){const[a,l]=w.useState("general"),{oaiMode:c,setOAIMode:d,azureDeploymentEnabled:f,setAzureDeploymentEnabled:m,authRequired:h,serverCapabilities:g,serverVersion:x,runtime:y,uiMode:b,streamingEnabled:j,setStreamingEnabled:N}=le(),S="",[_,A]=w.useState(()=>localStorage.getItem("devui_backend_url")||S),[E,M]=w.useState(_),[T,D]=w.useState(!!localStorage.getItem("devui_auth_token")),[z,H]=w.useState(""),q=()=>{try{new URL(E),localStorage.setItem("devui_backend_url",E),A(E),r?.(E),n(!1),window.location.reload()}catch{alert("Please enter a valid URL (e.g., http://localhost:8080)")}},X=()=>{localStorage.removeItem("devui_backend_url"),M(S),A(S),r?.(S),window.location.reload()},W=()=>{z.trim()&&(localStorage.setItem("devui_auth_token",z.trim()),D(!0),H(""),window.location.reload())},G=()=>{localStorage.removeItem("devui_auth_token"),D(!1),H(""),window.location.reload()},ne=E!==_,B=!localStorage.getItem("devui_backend_url");return o.jsx(Ir,{open:e,onOpenChange:n,children:o.jsxs(Lr,{className:"w-[600px] max-w-[90vw] flex flex-col max-h-[85vh]",children:[o.jsx($r,{className:"p-6 pb-2 flex-shrink-0",children:o.jsx(Pr,{children:"Settings"})}),o.jsx(So,{onClose:()=>n(!1)}),o.jsxs("div",{className:"flex border-b px-6 flex-shrink-0",children:[o.jsxs("button",{onClick:()=>l("general"),className:`px-4 py-2 text-sm font-medium transition-colors relative ${a==="general"?"text-foreground":"text-muted-foreground hover:text-foreground"}`,children:["General",a==="general"&&o.jsx("div",{className:"absolute bottom-0 left-0 right-0 h-0.5 bg-primary"})]}),g.openai_proxy&&o.jsxs("button",{onClick:()=>l("proxy"),className:`px-4 py-2 text-sm font-medium transition-colors relative ${a==="proxy"?"text-foreground":"text-muted-foreground hover:text-foreground"}`,children:["OpenAI Proxy",a==="proxy"&&o.jsx("div",{className:"absolute bottom-0 left-0 right-0 h-0.5 bg-primary"})]}),o.jsxs("button",{onClick:()=>l("about"),className:`px-4 py-2 text-sm font-medium transition-colors relative ${a==="about"?"text-foreground":"text-muted-foreground hover:text-foreground"}`,children:["About",a==="about"&&o.jsx("div",{className:"absolute bottom-0 left-0 right-0 h-0.5 bg-primary"})]})]}),o.jsxs("div",{className:"px-6 pb-6 overflow-y-auto flex-1 min-h-[400px]",children:[a==="general"&&o.jsxs("div",{className:"space-y-6 pt-4",children:[o.jsxs("div",{className:"space-y-3",children:[o.jsxs("div",{className:"flex items-center justify-between",children:[o.jsx(kt,{htmlFor:"backend-url",className:"text-sm font-medium",children:"Backend URL"}),!B&&o.jsxs(Le,{variant:"ghost",size:"sm",onClick:X,className:"h-7 text-xs",title:"Reset to default",children:[o.jsx(sg,{className:"h-3 w-3 mr-1"}),"Reset"]})]}),o.jsx(as,{id:"backend-url",type:"url",value:E,onChange:U=>M(U.target.value),placeholder:"http://localhost:8080",className:"font-mono text-sm"}),o.jsxs("p",{className:"text-xs text-muted-foreground",children:["Default: ",o.jsx("span",{className:"font-mono",children:S})]}),o.jsx("div",{className:"flex gap-2 pt-2 min-h-[36px]",children:ne&&o.jsxs(o.Fragment,{children:[o.jsx(Le,{onClick:q,size:"sm",className:"flex-1",children:"Apply & Reload"}),o.jsx(Le,{onClick:()=>M(_),variant:"outline",size:"sm",className:"flex-1",children:"Cancel"})]})})]}),(h||T)&&o.jsxs("div",{className:"space-y-3 border-t pt-6",children:[o.jsxs("div",{className:"flex items-center justify-between",children:[o.jsx(kt,{className:"text-sm font-medium",children:"Authentication Token"}),!h&&T&&o.jsx("span",{className:"text-xs text-muted-foreground",children:"(Not required by current backend)"})]}),T?o.jsxs("div",{className:"space-y-3",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(as,{type:"password",value:"••••••••••••••••••••",disabled:!0,className:"font-mono text-sm flex-1"}),o.jsx(Le,{variant:"destructive",size:"sm",onClick:G,className:"flex-shrink-0",children:"Clear"})]}),o.jsx("p",{className:"text-xs text-green-600 dark:text-green-400",children:"✓ Token configured and stored locally"})]}):o.jsxs("div",{className:"space-y-3",children:[o.jsx(as,{type:"password",value:z,onChange:U=>H(U.target.value),placeholder:"Enter bearer token",className:"font-mono text-sm",onKeyDown:U=>{U.key==="Enter"&&z.trim()&&W()}}),o.jsx(Le,{onClick:W,size:"sm",disabled:!z.trim(),className:"w-full",children:"Save & Reload"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:h?"Required by backend (started with --auth flag)":"Not required by current backend"})]})]}),g.deployment&&o.jsxs("div",{className:"space-y-3 border-t pt-6",children:[o.jsxs("div",{className:"flex items-center justify-between",children:[o.jsxs("div",{className:"space-y-0.5",children:[o.jsx(kt,{className:"text-sm font-medium",children:"Azure Deployment"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Enable one-click deployment to Azure Container Apps"})]}),o.jsx(Wi,{checked:f,onCheckedChange:m})]}),o.jsxs("details",{className:"group",children:[o.jsxs("summary",{className:"cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1",children:[o.jsx(en,{className:"h-3 w-3 transition-transform group-open:rotate-90"}),"Learn more about Azure deployment"]}),o.jsxs("div",{className:"mt-3 space-y-3 pl-4",children:[o.jsx("p",{className:"text-xs text-muted-foreground leading-relaxed",children:'When enabled, agents that support deployment will show a "Deploy to Azure" button. This allows you to deploy your agent to Azure Container Apps directly from DevUI.'}),o.jsxs("div",{className:"space-y-1.5",children:[o.jsx("p",{className:"text-xs font-medium",children:"When enabled:"}),o.jsxs("ul",{className:"text-xs text-muted-foreground space-y-0.5 list-disc list-inside",children:[o.jsx("li",{children:'Shows "Deploy to Azure" for supported agents'}),o.jsx("li",{children:"Requires Azure CLI and proper authentication"}),o.jsx("li",{children:"Backend must have deployment capabilities enabled"})]})]}),o.jsxs("div",{className:"space-y-1.5",children:[o.jsx("p",{className:"text-xs font-medium",children:"When disabled:"}),o.jsxs("ul",{className:"text-xs text-muted-foreground space-y-0.5 list-disc list-inside",children:[o.jsx("li",{children:'Shows "Deployment Guide" for all agents'}),o.jsx("li",{children:"Provides Docker templates and manual deployment instructions"}),o.jsx("li",{children:"No backend deployment capabilities required"})]})]})]})]})]}),o.jsx("div",{className:"space-y-3 border-t pt-6",children:o.jsxs("div",{className:"flex items-center justify-between",children:[o.jsxs("div",{className:"space-y-0.5",children:[o.jsx(kt,{className:"text-sm font-medium",children:"Show Tool Calls"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Display function/tool calls and results in chat messages"})]}),o.jsx(Wi,{checked:le.getState().showToolCalls,onCheckedChange:U=>le.getState().setShowToolCalls(U)})]})}),o.jsxs("div",{className:"space-y-3 border-t pt-6",children:[o.jsxs("div",{className:"flex items-center justify-between",children:[o.jsxs("div",{className:"space-y-0.5",children:[o.jsx(kt,{className:"text-sm font-medium",children:"Streaming Mode"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Stream responses token-by-token as they're generated"})]}),o.jsx(Wi,{checked:j,onCheckedChange:N})]}),!j&&o.jsxs("div",{className:"flex items-start gap-2 text-xs text-amber-600 dark:text-amber-400 bg-amber-500/10 p-3 rounded",children:[o.jsx(Fs,{className:"h-3.5 w-3.5 flex-shrink-0 mt-0.5"}),o.jsxs("div",{children:[o.jsx("p",{className:"font-medium",children:"Non-streaming mode limitations:"}),o.jsxs("ul",{className:"mt-1 space-y-0.5 list-disc list-inside text-amber-600/80 dark:text-amber-400/80",children:[o.jsx("li",{children:"Tool calls won't display in real-time"}),o.jsx("li",{children:"No typing indicator during generation"}),o.jsx("li",{children:"Response appears all at once when complete"})]})]})]})]})]}),a==="proxy"&&g.openai_proxy&&o.jsxs("div",{className:"space-y-6 pt-4",children:[o.jsxs("div",{className:"space-y-4",children:[o.jsxs("div",{className:"flex items-center justify-between",children:[o.jsxs("div",{className:"space-y-0.5",children:[o.jsx(kt,{className:"text-base font-medium",children:"OpenAI Proxy Mode"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Route requests through DevUI backend to OpenAI API"})]}),o.jsx(Wi,{checked:c.enabled,onCheckedChange:U=>d({...c,enabled:U})})]}),!c.enabled&&o.jsx("div",{className:"bordder border-muted bg-muted/30 rounded-lg p-4 space-y-3",children:o.jsxs("div",{className:"flex items-start gap-2",children:[o.jsx(Fs,{className:"h-4 w-4 flex-shrink-0 mt-0.5 text-blue-600 dark:text-blue-400"}),o.jsxs("div",{className:"space-y-2",children:[o.jsx("p",{className:"text-sm font-medium",children:"About OpenAI Proxy Mode"}),o.jsxs("p",{className:"text-xs text-muted-foreground leading-relaxed",children:["When enabled, your chat requests are sent to your DevUI backend"," ",o.jsxs("span",{className:"font-mono font-semibold",children:["(",_,")"]}),", which then forwards them to OpenAI's API. This keeps your"," ",o.jsx("span",{className:"font-mono font-semibold",children:"OPENAI_API_KEY"})," ","secure on the server instead of exposing it in the browser."]}),o.jsxs("div",{className:"space-y-1.5 pt-1",children:[o.jsx("p",{className:"text-xs font-medium",children:"Requirements:"}),o.jsxs("ul",{className:"text-xs text-muted-foreground space-y-0.5 list-disc list-inside",children:[o.jsxs("li",{children:["Backend must have"," ",o.jsx("span",{className:"font-mono",children:"OPENAI_API_KEY"})," ","configured"]}),o.jsx("li",{children:"Backend must support OpenAI Responses API proxying (DevUI does)"})]})]}),o.jsxs("div",{className:"space-y-1.5 pt-1",children:[o.jsx("p",{className:"text-xs font-medium",children:"Why use this?"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Quickly test and compare OpenAI models directly through the DevUI interface without creating custom agents or exposing API keys in the browser."})]})]})]})}),c.enabled&&o.jsxs("div",{className:"space-y-4 pl-4 border-l-2 border-muted",children:[o.jsxs("div",{className:"space-y-2",children:[o.jsx(kt,{className:"text-sm font-medium",children:"Model"}),o.jsx(as,{type:"text",value:c.model,onChange:U=>d({...c,model:U.target.value}),placeholder:"gpt-4.1-mini",className:"font-mono text-sm"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Enter any OpenAI model ID (e.g., gpt-4.1, o1, o3-mini)"})]}),o.jsxs("div",{className:"space-y-2",children:[o.jsx(kt,{className:"text-xs text-muted-foreground",children:"Common presets"}),o.jsx("div",{className:"flex flex-wrap gap-2",children:VR.map(U=>o.jsx(Le,{variant:c.model===U?"default":"outline",size:"sm",onClick:()=>d({...c,model:U}),className:"text-xs h-7",children:U},U))})]}),o.jsxs("details",{className:"group",children:[o.jsxs("summary",{className:"cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1",children:[o.jsx(en,{className:"h-3 w-3 transition-transform group-open:rotate-90"}),"Advanced Parameters (optional)"]}),o.jsxs("div",{className:"space-y-3 mt-3 pl-4",children:[o.jsxs("div",{className:"space-y-1",children:[o.jsx(kt,{className:"text-xs",children:"Temperature"}),o.jsx(as,{type:"number",step:"0.1",min:"0",max:"2",value:c.temperature??"",onChange:U=>d({...c,temperature:U.target.value?parseFloat(U.target.value):void 0}),placeholder:"1.0 (default)",className:"text-sm"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Controls randomness (0-2)"})]}),o.jsxs("div",{className:"space-y-1",children:[o.jsx(kt,{className:"text-xs",children:"Max Output Tokens"}),o.jsx(as,{type:"number",min:"1",value:c.max_output_tokens??"",onChange:U=>d({...c,max_output_tokens:U.target.value?parseInt(U.target.value):void 0}),placeholder:"Auto",className:"text-sm"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Maximum tokens in response"})]}),o.jsxs("div",{className:"space-y-1",children:[o.jsx(kt,{className:"text-xs",children:"Top P"}),o.jsx(as,{type:"number",step:"0.1",min:"0",max:"1",value:c.top_p??"",onChange:U=>d({...c,top_p:U.target.value?parseFloat(U.target.value):void 0}),placeholder:"1.0 (default)",className:"text-sm"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Nucleus sampling (0-1)"})]}),o.jsxs("div",{className:"space-y-1",children:[o.jsx(kt,{className:"text-xs",children:"Reasoning Effort (o-series models)"}),o.jsxs("select",{value:c.reasoning_effort??"",onChange:U=>d({...c,reasoning_effort:U.target.value?U.target.value:void 0}),className:"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",children:[o.jsx("option",{value:"",children:"Auto (default)"}),o.jsx("option",{value:"minimal",children:"Minimal"}),o.jsx("option",{value:"low",children:"Low"}),o.jsx("option",{value:"medium",children:"Medium"}),o.jsx("option",{value:"high",children:"High"})]}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Constrains reasoning effort (faster/cheaper vs thorough)"})]})]})]})]})]}),c.enabled&&o.jsxs("div",{className:"flex items-start gap-2 text-xs text-muted-foreground bg-muted/50 p-3 rounded",children:[o.jsx(Fs,{className:"h-3.5 w-3.5 flex-shrink-0 mt-0.5"}),o.jsx("div",{className:"space-y-1",children:o.jsxs("p",{children:["Requests route through"," ",o.jsx("span",{className:"font-mono font-semibold",children:_})," ","to OpenAI API. Server must have"," ",o.jsx("span",{className:"font-mono font-semibold",children:"OPENAI_API_KEY"})," ","configured."]})})]})]}),a==="about"&&o.jsxs("div",{className:"space-y-4 pt-4",children:[o.jsx("p",{className:"text-sm text-muted-foreground",children:"DevUI is a sample app for getting started with Agent Framework."}),o.jsxs("div",{className:"space-y-2 text-sm",children:[o.jsxs("div",{className:"flex justify-between",children:[o.jsx("span",{className:"text-muted-foreground",children:"Version:"}),o.jsx("span",{className:"font-mono",children:x||"Unknown"})]}),o.jsxs("div",{className:"flex justify-between",children:[o.jsx("span",{className:"text-muted-foreground",children:"Runtime:"}),o.jsx("span",{className:"font-mono capitalize",children:y||"Unknown"})]}),o.jsxs("div",{className:"flex justify-between",children:[o.jsx("span",{className:"text-muted-foreground",children:"UI Mode:"}),o.jsx("span",{className:"font-mono capitalize",children:b||"Unknown"})]})]}),(g||h!==void 0)&&o.jsxs("div",{className:"space-y-2 pt-2",children:[o.jsx("p",{className:"text-xs font-medium text-muted-foreground uppercase tracking-wide",children:"Capabilities"}),o.jsxs("div",{className:"space-y-1 text-sm",children:[g?.instrumentation!==void 0&&o.jsxs("div",{className:"flex justify-between items-center",children:[o.jsx("span",{className:"text-muted-foreground",children:"Instrumentation:"}),o.jsx("span",{className:`text-xs px-2 py-0.5 rounded-full ${g.instrumentation?"bg-green-500/10 text-green-600 dark:text-green-400":"bg-muted text-muted-foreground"}`,children:g.instrumentation?"Enabled":"Disabled"})]}),g?.openai_proxy!==void 0&&o.jsxs("div",{className:"flex justify-between items-center",children:[o.jsx("span",{className:"text-muted-foreground",children:"OpenAI Proxy:"}),o.jsx("span",{className:`text-xs px-2 py-0.5 rounded-full ${g.openai_proxy?"bg-green-500/10 text-green-600 dark:text-green-400":"bg-muted text-muted-foreground"}`,children:g.openai_proxy?"Available":"Not Configured"})]}),g?.deployment!==void 0&&o.jsxs("div",{className:"flex justify-between items-center",children:[o.jsx("span",{className:"text-muted-foreground",children:"Deployment:"}),o.jsx("span",{className:`text-xs px-2 py-0.5 rounded-full ${g.deployment?"bg-green-500/10 text-green-600 dark:text-green-400":"bg-muted text-muted-foreground"}`,children:g.deployment?"Available":"Disabled"})]}),h!==void 0&&o.jsxs("div",{className:"flex justify-between items-center",children:[o.jsx("span",{className:"text-muted-foreground",children:"Authentication:"}),o.jsx("span",{className:`text-xs px-2 py-0.5 rounded-full ${h?"bg-blue-500/10 text-blue-600 dark:text-blue-400":"bg-muted text-muted-foreground"}`,children:h?"Required":"Not Required"})]})]})]}),o.jsx("div",{className:"flex justify-center pt-2",children:o.jsxs(Le,{variant:"outline",size:"sm",onClick:()=>window.open("https://github.com/microsoft/agent-framework","_blank"),className:"text-xs",children:[o.jsx(Hu,{className:"h-3 w-3 mr-1"}),"Learn More about Agent Framework"]})})]})]})]})})}const qR="modulepreload",FR=function(e,n){return new URL(e,n).href},ub={},_u=function(n,r,a){let l=Promise.resolve();if(r&&r.length>0){let h=function(g){return Promise.all(g.map(x=>Promise.resolve(x).then(y=>({status:"fulfilled",value:y}),y=>({status:"rejected",reason:y}))))};const d=document.getElementsByTagName("link"),f=document.querySelector("meta[property=csp-nonce]"),m=f?.nonce||f?.getAttribute("nonce");l=h(r.map(g=>{if(g=FR(g,a),g in ub)return;ub[g]=!0;const x=g.endsWith(".css"),y=x?'[rel="stylesheet"]':"";if(a)for(let j=d.length-1;j>=0;j--){const N=d[j];if(N.href===g&&(!x||N.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${g}"]${y}`))return;const b=document.createElement("link");if(b.rel=x?"stylesheet":qR,x||(b.as="script"),b.crossOrigin="",b.href=g,m&&b.setAttribute("nonce",m),document.head.appendChild(b),x)return new Promise((j,N)=>{b.addEventListener("load",j),b.addEventListener("error",()=>N(new Error(`Unable to preload CSS for ${g}`)))})}))}function c(d){const f=new Event("vite:preloadError",{cancelable:!0});if(f.payload=d,window.dispatchEvent(f),!f.defaultPrevented)throw d}return l.then(d=>{for(const f of d||[])f.status==="rejected"&&c(f.reason);return n().catch(c)})},x2="devui_streaming_state_",y2=1440*60*1e3;function Yu(e){return`${x2}${e}`}function YR(e){let n="";for(const r of e)r.type==="response.output_text.delta"&&"delta"in r&&(n+=r.delta);return n}function v2(e){try{const n=Yu(e.conversationId),r=JSON.stringify(e);localStorage.setItem(n,r)}catch(n){console.error("Failed to save streaming state:",n);try{b2();const r=Yu(e.conversationId),a=JSON.stringify(e);localStorage.setItem(r,a)}catch{console.error("Failed to save streaming state even after cleanup")}}}function ba(e){try{const n=Yu(e),r=localStorage.getItem(n);if(!r)return null;const a=JSON.parse(r);return Date.now()-a.timestamp>y2?(Eu(e),null):a.completed?null:a}catch(n){return console.error("Failed to load streaming state:",n),null}}function Nh(e,n,r,a){try{const l=ba(e),c="sequence_number"in n?n.sequence_number:void 0,d=l?[...l.events,n]:[n],f={conversationId:e,responseId:r,lastMessageId:a,lastSequenceNumber:c??l?.lastSequenceNumber??-1,events:d,timestamp:Date.now(),completed:n.type==="response.completed"||n.type==="response.failed",accumulatedText:YR(d)};v2(f)}catch(l){console.error("Failed to update streaming state:",l)}}function jh(e){try{const n=ba(e);n&&(n.completed=!0,n.timestamp=Date.now(),v2(n))}catch(n){console.error("Failed to mark streaming as completed:",n)}}function Eu(e){try{const n=Yu(e);localStorage.removeItem(n)}catch(n){console.error("Failed to clear streaming state:",n)}}function b2(){try{const e=Object.keys(localStorage),n=Date.now();for(const r of e)if(r.startsWith(x2))try{const a=localStorage.getItem(r);if(a){const l=JSON.parse(a);(n-l.timestamp>y2||l.completed)&&localStorage.removeItem(r)}}catch{localStorage.removeItem(r)}}catch(e){console.error("Failed to clear expired streaming states:",e)}}function GR(){b2()}function w2(){const[e,n]=w.useState(!1),r=w.useRef(null),a=w.useCallback(()=>(r.current=new AbortController,n(!1),r.current.signal),[]),l=w.useCallback(()=>{r.current&&(n(!0),r.current.abort(),r.current=null)},[]),c=w.useCallback(()=>{n(!1)},[]),d=w.useCallback(()=>{r.current&&(r.current.abort(),r.current=null)},[]);return{isCancelling:e,createAbortSignal:a,handleCancel:l,resetCancelling:c,cleanup:d}}function Gu(e){return e instanceof DOMException&&e.name==="AbortError"}function XR(e={}){const{onDrop:n,disabled:r=!1}=e,[a,l]=w.useState(!1),[c,d]=w.useState([]),f=w.useRef(0),m=w.useCallback(b=>{b.preventDefault(),b.stopPropagation(),!r&&(f.current++,b.dataTransfer.items&&b.dataTransfer.items.length>0&&l(!0))},[r]),h=w.useCallback(b=>{b.preventDefault(),b.stopPropagation(),!r&&(f.current--,f.current===0&&l(!1))},[r]),g=w.useCallback(b=>{b.preventDefault(),b.stopPropagation()},[]),x=w.useCallback(b=>{if(b.preventDefault(),b.stopPropagation(),l(!1),f.current=0,r)return;const j=Array.from(b.dataTransfer.files);j.length>0&&(d(j),n?.(j))},[r,n]),y=w.useCallback(()=>{d([])},[]);return{isDragOver:a,droppedFiles:c,clearDroppedFiles:y,dragHandlers:{onDragEnter:m,onDragLeave:h,onDragOver:g,onDrop:x}}}const ZR="",WR=1e3,Sh=10;function KR(){const e=localStorage.getItem("devui_backend_url");return e||ZR}function QR(e){return new Promise(n=>setTimeout(n,e))}class JR{baseUrl;authToken=null;constructor(n){this.baseUrl=n||KR(),this.authToken=localStorage.getItem("devui_auth_token")}setBaseUrl(n){this.baseUrl=n}getBaseUrl(){return this.baseUrl}setAuthToken(n){this.authToken=n,n?localStorage.setItem("devui_auth_token",n):localStorage.removeItem("devui_auth_token")}getAuthToken(){return this.authToken}clearAuthToken(){this.setAuthToken(null)}async request(n,r={}){const a=`${this.baseUrl}${n}`,l={"Content-Type":"application/json",...r.headers};this.authToken&&(l.Authorization=`Bearer ${this.authToken}`);const c=await fetch(a,{...r,headers:l});if(!c.ok){if(c.status===401)throw this.clearAuthToken(),new Error("UNAUTHORIZED");let d=`API request failed: ${c.status} ${c.statusText}`;try{const f=await c.json();f.detail?typeof f.detail=="string"?d=f.detail:typeof f.detail=="object"&&f.detail.error?.message&&(d=f.detail.error.message):f.error?.message&&(d=f.error.message)}catch{}throw new Error(d)}return c.json()}async getHealth(){return this.request("/health")}async getMeta(){return this.request("/meta")}async getEntities(){const r=(await this.request("/v1/entities")).entities.map(c=>{if(c.type==="agent")return{id:c.id,name:c.name,description:c.description,type:"agent",source:c.source||"directory",tools:(c.tools||[]).map(d=>typeof d=="string"?d:JSON.stringify(d)),has_env:!!(c.required_env_vars&&c.required_env_vars.length>0),module_path:typeof c.metadata?.module_path=="string"?c.metadata.module_path:void 0,required_env_vars:c.required_env_vars,metadata:c.metadata,deployment_supported:c.deployment_supported,deployment_reason:c.deployment_reason,instructions:c.instructions,model_id:c.model_id,chat_client_type:c.chat_client_type,context_providers:c.context_providers,middleware:c.middleware};{const d=c.executors||c.tools||[];let f=c.start_executor_id||"";if(!f&&d.length>0){const m=d[0];typeof m=="string"&&(f=m)}return{id:c.id,name:c.name,description:c.description,type:"workflow",source:c.source||"directory",executors:d.map(m=>typeof m=="string"?m:JSON.stringify(m)),has_env:!!(c.required_env_vars&&c.required_env_vars.length>0),module_path:typeof c.metadata?.module_path=="string"?c.metadata.module_path:void 0,required_env_vars:c.required_env_vars,metadata:c.metadata,deployment_supported:c.deployment_supported,deployment_reason:c.deployment_reason,input_schema:c.input_schema||{type:"string"},input_type_name:c.input_type_name||"Input",start_executor_id:f,tools:[]}}}),a=r.filter(c=>c.type==="agent"),l=r.filter(c=>c.type==="workflow");return{entities:r,agents:a,workflows:l}}async getAgents(){const{agents:n}=await this.getEntities();return n}async getWorkflows(){const{workflows:n}=await this.getEntities();return n}async getAgentInfo(n){return this.request(`/v1/entities/${n}/info?type=agent`)}async getWorkflowInfo(n){return this.request(`/v1/entities/${n}/info?type=workflow`)}async reloadEntity(n){return this.request(`/v1/entities/${n}/reload`,{method:"POST"})}async createConversation(n){const{oaiMode:r}=await _u(()=>Promise.resolve().then(()=>wu),void 0,import.meta.url).then(c=>({oaiMode:c.useDevUIStore.getState().oaiMode})),a={};r.enabled&&(a["X-Proxy-Backend"]="openai");const l=await this.request("/v1/conversations",{method:"POST",headers:a,body:JSON.stringify({metadata:n})});return{id:l.id,object:"conversation",created_at:l.created_at,metadata:l.metadata}}async listConversations(n){const r=n?`/v1/conversations?agent_id=${encodeURIComponent(n)}`:"/v1/conversations",a=await this.request(r);return{data:a.data.map(l=>({id:l.id,object:"conversation",created_at:l.created_at,metadata:l.metadata})),has_more:a.has_more}}async getConversation(n){const r=await this.request(`/v1/conversations/${n}`);return{id:r.id,object:"conversation",created_at:r.created_at,metadata:r.metadata}}async deleteConversation(n){try{return await this.request(`/v1/conversations/${n}`,{method:"DELETE"}),Eu(n),!0}catch{return!1}}async listConversationItems(n,r){const a=new URLSearchParams;r?.limit&&a.set("limit",r.limit.toString()),r?.after&&a.set("after",r.after),r?.order&&a.set("order",r.order);const l=a.toString(),c=`/v1/conversations/${n}/items${l?`?${l}`:""}`;return this.request(c)}async getConversationItem(n,r){const a=`/v1/conversations/${n}/items/${r}`;return this.request(a)}async deleteConversationItem(n,r){const a=await fetch(`${this.baseUrl}/v1/conversations/${n}/items/${r}`,{method:"DELETE"});if(!a.ok)throw new Error(`Failed to delete item: ${a.statusText}`)}async*streamOpenAIResponse(n,r,a,l){const{oaiMode:c}=await _u(()=>Promise.resolve().then(()=>wu),void 0,import.meta.url).then(x=>({oaiMode:x.useDevUIStore.getState().oaiMode}));c.enabled&&(n.model=c.model,c.temperature!==void 0&&(n.temperature=c.temperature),c.max_output_tokens!==void 0&&(n.max_output_tokens=c.max_output_tokens),c.top_p!==void 0&&(n.top_p=c.top_p),c.instructions!==void 0&&(n.instructions=c.instructions),c.reasoning_effort!==void 0&&(n.reasoning={effort:c.reasoning_effort}));let d=-1,f=0,m=!1,h=l,g;if(r){const x=ba(r);if(x)if(l||(h=x.responseId),d=x.lastSequenceNumber,g=x.lastMessageId,l)m=x.events.length>0;else for(const y of x.events)m=!0,yield y}for(;f<=Sh;)try{let x;if(h){const N=new URLSearchParams;N.set("stream","true"),d>=0&&N.set("starting_after",d.toString());const S=`${this.baseUrl}/v1/responses/${h}?${N.toString()}`,_={Accept:"text/event-stream"};this.authToken&&(_.Authorization=`Bearer ${this.authToken}`),x=await fetch(S,{method:"GET",headers:_,signal:a})}else{const N=`${this.baseUrl}/v1/responses`,S={"Content-Type":"application/json",Accept:"text/event-stream"};c.enabled&&(S["X-Proxy-Backend"]="openai"),this.authToken&&(S.Authorization=`Bearer ${this.authToken}`),x=await fetch(N,{method:"POST",headers:S,body:JSON.stringify(n),signal:a})}if(!x.ok){if(x.status===401)throw this.clearAuthToken(),new Error("UNAUTHORIZED");if(x.status>=400&&x.status<500){let S=`Client error ${x.status}`;try{const _=await x.json();_.error&&_.error.message?S=_.error.message:_.detail&&(S=_.detail)}catch{}throw new Error(`CLIENT_ERROR: ${S}`)}let N=`Request failed with status ${x.status}`;try{const S=await x.json();S.error&&S.error.message?N=S.error.message:S.detail&&(N=S.detail)}catch{}throw new Error(N)}const y=x.body?.getReader();if(!y)throw new Error("Response body is not readable");const b=new TextDecoder;let j="";try{for(;;){if(a?.aborted)throw new DOMException("Request aborted","AbortError");const{done:N,value:S}=await y.read();if(N){r&&jh(r);return}const _=b.decode(S,{stream:!0});j+=_;const A=j.split(` +`)||l.trim().match(/[.!?]\s*$/))&&(n.push({type:"response.output_text.delta",delta:l.trim()}),l="");continue}c.type!=="response.usage.complete"&&n.push(c)}for(const[,c]of r)if(c.arguments.trim()&&c.arguments.trim().length>2){const d=a.get(c.callId)||c.name||"unknown";n.push({type:"response.function_call.complete",data:{name:d,arguments:c.arguments,call_id:c.callId}})}return l.trim()&&n.push({type:"response.output_text.delta",delta:l.trim()}),n}function wR(e){switch(e.type){case"response.output_text.delta":if("delta"in e){const n=e.delta||"";return n.length>60?`${n.slice(0,60)}...`:n}return"Text output";case"response.function_call.complete":if("data"in e&&e.data){const n=e.data;let r=n.name||"unknown";(!r||r==="unknown")&&(r="function_call");const a=n.arguments?typeof n.arguments=="string"?n.arguments.slice(0,30):JSON.stringify(n.arguments).slice(0,30):"";return`Calling ${r}(${a}${a.length>=30?"...":""})`}return"Function call";case"response.function_call_arguments.delta":return"delta"in e&&e.delta?`Function arg delta: ${e.delta.slice(0,30)}${e.delta.length>30?"...":""}`:"Function arguments...";case"response.function_result.complete":{const r=e.output.slice(0,40);return`Function result: ${r}${r.length>=40?"...":""}`}case"response.output_item.added":{const n=e;return n.item.type==="function_call"?`Tool call: ${n.item.name}`:"Output item added"}case"response.workflow_event.completed":return"data"in e&&e.data?`Executor: ${e.data.executor_id||"unknown"}`:"Workflow event";case"response.trace.completed":return"data"in e&&e.data?`Trace: ${e.data.operation_name||"unknown"}`:"Trace event";case"response.completed":if("response"in e&&e.response&&"usage"in e.response){const r=e.response.usage;if(r)return`Response complete (${r.total_tokens} tokens)`}return"Response complete";case"response.done":return"Response complete";case"error":return"message"in e&&typeof e.message=="string"?e.message:"Error occurred";default:return`${e.type}`}}function NR(e){switch(e){case"response.output_text.delta":return eg;case"response.function_call.complete":case"response.function_call.delta":case"response.function_call_arguments.delta":return _a;case"response.function_result.complete":return nn;case"response.output_item.added":return nn;case"response.workflow_event.completed":return Qp;case"response.trace.completed":return Bu;case"response.completed":return nn;case"response.done":return nn;case"error":return kl;default:return hs}}function jR(e){switch(e){case"response.output_text.delta":return"text-gray-600 dark:text-gray-400";case"response.function_call.complete":case"response.function_call.delta":case"response.function_call_arguments.delta":return"text-blue-600 dark:text-blue-400";case"response.function_result.complete":return"text-green-600 dark:text-green-400";case"response.output_item.added":return"text-green-600 dark:text-green-400";case"response.workflow_event.completed":return"text-purple-600 dark:text-purple-400";case"response.trace.completed":return"text-orange-600 dark:text-orange-400";case"response.completed":return"text-green-600 dark:text-green-400";case"response.done":return"text-green-600 dark:text-green-400";case"error":return"text-red-600 dark:text-red-400";default:return"text-gray-600 dark:text-gray-400"}}function SR({event:e}){const[n,r]=w.useState(!1),a=e.type||"unknown",l=NR(a),c=jR(a),d="_uiTimestamp"in e&&typeof e._uiTimestamp=="number"?new Date(e._uiTimestamp*1e3).toLocaleTimeString():new Date().toLocaleTimeString(),f=wR(e),m=e.type==="response.function_call.complete"&&"data"in e&&e.data||e.type==="response.function_result.complete"||e.type==="response.output_item.added"&&zr(e)!==null||e.type==="response.workflow_event.completed"&&"data"in e&&e.data||e.type==="response.trace.completed"&&"data"in e&&e.data||e.type==="response.trace.completed"&&"data"in e&&e.data||e.type==="response.output_text.delta"&&"delta"in e&&e.delta&&e.delta.length>100||e.type==="response.completed"&&"response"in e&&e.response||e.type==="error";return o.jsxs("div",{className:"border-l-2 border-muted pl-3 py-2 hover:bg-muted/50 transition-colors",children:[o.jsxs("div",{className:"flex items-center gap-2 text-xs text-muted-foreground mb-1",children:[o.jsx(l,{className:`h-3 w-3 ${c}`}),o.jsx("span",{className:"font-mono",children:d}),o.jsx(ut,{variant:"outline",className:"text-xs py-0",children:e.type?e.type.replace("response.",""):"unknown"})]}),o.jsxs("div",{className:"text-sm",children:[o.jsxs("div",{className:`flex items-center gap-2 ${m?"cursor-pointer":""}`,onClick:()=>m&&r(!n),children:[m&&o.jsx("div",{className:"text-muted-foreground",children:n?o.jsx(Rt,{className:"h-3 w-3"}):o.jsx(en,{className:"h-3 w-3"})}),o.jsx("div",{className:"text-muted-foreground flex-1",children:m&&f.length>80?`${f.slice(0,80)}...`:f})]}),n&&m&&o.jsx("div",{className:"mt-2 ml-5 p-3 bg-muted/30 rounded border",children:o.jsx(_R,{event:e})})]})]})}function _R({event:e}){if(e.type==="error"){const n=e;return o.jsxs("div",{className:"space-y-2",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(kl,{className:"h-4 w-4 text-red-500"}),o.jsx("span",{className:"font-semibold text-sm",children:"Error Details"})]}),o.jsxs("div",{className:"text-xs",children:[n.message&&o.jsxs("div",{className:"mb-2",children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Message:"}),o.jsx("div",{className:"mt-1",children:o.jsx("pre",{className:"text-xs bg-destructive/10 border border-destructive/30 rounded p-2 text-destructive whitespace-pre-wrap break-all",children:n.message})})]}),n.code&&o.jsxs("div",{className:"mb-2",children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Code:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.code})]}),n.param&&o.jsxs("div",{className:"mb-2",children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Parameter:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.param})]}),o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Raw Event:"}),o.jsx("div",{className:"mt-1",children:o.jsx("pre",{className:"text-xs bg-background border rounded p-2 whitespace-pre-wrap break-all max-h-32 overflow-auto",children:JSON.stringify(e,null,2)})})]})]})]})}switch(e.type){case"response.function_call.complete":if("data"in e&&e.data){const n=e.data;return o.jsxs("div",{className:"space-y-2",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(_a,{className:"h-4 w-4 text-blue-500"}),o.jsx("span",{className:"font-semibold text-sm",children:"Function Call"})]}),o.jsxs("div",{className:"grid grid-cols-1 gap-2 text-xs",children:[o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Function:"}),o.jsx("span",{className:"ml-2 font-mono bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded",children:n.name||"unknown"})]}),n.call_id&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Call ID:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.call_id})]}),n.arguments&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Arguments:"}),o.jsx("div",{className:"mt-1 max-h-32 overflow-auto",children:o.jsx("pre",{className:"text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all",children:typeof n.arguments=="string"?n.arguments:JSON.stringify(n.arguments,null,1)})})]})]})]})}break;case"response.function_result.complete":{const n=e;return o.jsxs("div",{className:"space-y-2",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(nn,{className:"h-4 w-4 text-green-500"}),o.jsx("span",{className:"font-semibold text-sm",children:"Function Result"})]}),o.jsxs("div",{className:"grid grid-cols-1 gap-2 text-xs",children:[o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Call ID:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.call_id})]}),o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Status:"}),o.jsx("span",{className:`ml-2 px-2 py-1 rounded text-xs font-medium ${n.status==="completed"?"bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200":"bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"}`,children:n.status})]}),o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Output:"}),o.jsx("div",{className:"mt-1 max-h-32 overflow-auto",children:o.jsx("pre",{className:"text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all",children:n.output})})]})]})]})}case"response.output_item.added":{const n=zr(e);if(n)return o.jsxs("div",{className:"space-y-2",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(nn,{className:"h-4 w-4 text-green-500"}),o.jsx("span",{className:"font-semibold text-sm",children:"Function Result"})]}),o.jsxs("div",{className:"grid grid-cols-1 gap-2 text-xs",children:[o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Call ID:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.call_id})]}),o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Status:"}),o.jsx("span",{className:`ml-2 px-2 py-1 rounded text-xs font-medium ${n.status==="completed"?"bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200":"bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"}`,children:n.status})]}),o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Output:"}),o.jsx("div",{className:"mt-1 max-h-32 overflow-auto",children:o.jsx("pre",{className:"text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all",children:n.output})})]})]})]});break}case"response.workflow_event.completed":if("data"in e&&e.data){const n=e.data;return o.jsxs("div",{className:"space-y-2",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(Qp,{className:"h-4 w-4 text-purple-500"}),o.jsx("span",{className:"font-semibold text-sm",children:"Workflow Event"})]}),o.jsxs("div",{className:"grid grid-cols-1 gap-2 text-xs",children:[o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Event Type:"}),o.jsx("span",{className:"ml-2 font-mono bg-purple-100 dark:bg-purple-900 px-2 py-1 rounded",children:n.event_type||"unknown"})]}),n.executor_id&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Executor:"}),o.jsx("span",{className:"ml-2 font-mono",children:n.executor_id})]}),n.timestamp&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Timestamp:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.timestamp})]}),n.data&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Data:"}),o.jsx("div",{className:"mt-1 max-h-32 overflow-auto",children:o.jsx("pre",{className:"text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all",children:typeof n.data=="string"?n.data:JSON.stringify(n.data,null,1)})})]})]})]})}break;case"response.trace.completed":if("data"in e&&e.data){const n=e.data;return o.jsxs("div",{className:"space-y-2",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(Bu,{className:"h-4 w-4 text-orange-500"}),o.jsx("span",{className:"font-semibold text-sm",children:"Trace Event"})]}),o.jsxs("div",{className:"grid grid-cols-1 gap-2 text-xs",children:[o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Operation:"}),o.jsx("span",{className:"ml-2 font-mono bg-orange-100 dark:bg-orange-900 px-2 py-1 rounded",children:n.operation_name||"unknown"})]}),n.span_id&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Span ID:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.span_id})]}),n.trace_id&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Trace ID:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.trace_id})]}),n.duration_ms&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Duration:"}),o.jsxs("span",{className:"ml-2 font-mono text-xs",children:[Number(n.duration_ms).toFixed(2),"ms"]})]}),n.status&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Status:"}),o.jsx("span",{className:`ml-2 px-2 py-1 rounded text-xs font-medium ${n.status==="StatusCode.UNSET"||n.status==="OK"?"bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200":"bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"}`,children:n.status||"unknown"})]}),n.entity_id&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Entity:"}),o.jsx("span",{className:"ml-2 font-mono text-xs",children:n.entity_id})]}),n.attributes&&Object.keys(n.attributes).length>0&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Attributes:"}),o.jsx("div",{className:"mt-1 max-h-32 overflow-auto",children:o.jsx("pre",{className:"text-xs bg-background border rounded p-2 whitespace-pre-wrap break-all",children:l2(n.attributes)})})]})]})]})}break;case"response.output_text.delta":if("delta"in e&&e.delta)return o.jsxs("div",{className:"space-y-2",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(eg,{className:"h-4 w-4 text-gray-500"}),o.jsx("span",{className:"font-semibold text-sm",children:"Text Output"})]}),o.jsx("div",{className:"max-h-32 overflow-auto",children:o.jsx("pre",{className:"text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all",children:e.delta})})]});break;case"response.completed":if("response"in e&&e.response){const r=e.response;return o.jsx("div",{className:"space-y-2",children:o.jsxs("div",{className:"grid grid-cols-1 gap-2 text-xs",children:[r.usage&&o.jsxs(o.Fragment,{children:[o.jsx("div",{children:o.jsx("span",{className:"font-medium text-muted-foreground",children:"Usage:"})}),o.jsxs("div",{className:"ml-4 space-y-1",children:[o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Input tokens:"}),o.jsx("span",{className:"ml-2 font-mono",children:r.usage.input_tokens})]}),o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Output tokens:"}),o.jsx("span",{className:"ml-2 font-mono",children:r.usage.output_tokens})]}),o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Total tokens:"}),o.jsx("span",{className:"ml-2 font-mono bg-green-100 dark:bg-green-900 px-2 py-1 rounded",children:r.usage.total_tokens})]})]})]}),r.id&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Response ID:"}),o.jsx("span",{className:"ml-2 font-mono text-xs break-all",children:r.id})]}),r.model&&o.jsxs("div",{children:[o.jsx("span",{className:"font-medium text-muted-foreground",children:"Model:"}),o.jsx("span",{className:"ml-2 font-mono text-xs break-all",children:r.model})]})]})})}break;default:return o.jsx("div",{className:"text-xs text-muted-foreground",children:o.jsx("pre",{className:"bg-background border rounded p-2 overflow-auto max-h-32",children:JSON.stringify(e,null,2)})})}return null}function ER({events:e,isStreaming:n}){const r=w.useRef(null),a=gg(e),c=[...i2(a)].reverse();return o.jsxs("div",{className:"h-full flex flex-col",children:[o.jsxs("div",{className:"flex items-center justify-between p-3 border-b",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(Qp,{className:"h-4 w-4"}),o.jsx("span",{className:"font-medium",children:"Events"}),o.jsxs(ut,{variant:"outline",children:[a.length,e.length>a.length?` (${e.length} raw)`:""]})]}),n&&o.jsxs("div",{className:"flex items-center gap-1 text-xs text-muted-foreground",children:[o.jsx("div",{className:"h-2 w-2 animate-pulse rounded-full bg-green-500 dark:bg-green-400"}),"Streaming"]})]}),o.jsx(Wn,{ref:r,className:"flex-1",children:o.jsx("div",{className:"p-3",children:a.length===0?o.jsx("div",{className:"text-center text-muted-foreground text-sm py-8",children:e.length===0?"No events yet. Start a conversation to see real-time events.":"Processing events... Accumulated events will appear here."}):o.jsx("div",{className:"space-y-2",children:c.map((d,f)=>"type"in d&&d.type==="separator"?o.jsx(a2,{},d.id):o.jsx(SR,{event:d},`${d.type}-${f}`))})})})]})}function CR(e){const n=new Map;for(const a of e){if(!("data"in a))continue;const c=a.data.response_id||"unknown";n.has(c)||n.set(c,[]),n.get(c).push(a)}const r=[];for(const[a,l]of n){const c=new Map,d=[];for(const y of l){if(!("data"in y))continue;const b=y.data,j=b.span_id||`span_${Math.random()}`;c.set(j,{event:y,data:b,children:[]})}for(const y of l){if(!("data"in y))continue;const b=y.data,j=b.span_id||"",N=b.parent_span_id,S=c.get(j);S&&(N&&c.has(N)?c.get(N).children.push(S):d.push(S))}d.sort((y,b)=>(y.data.start_time||0)-(b.data.start_time||0));const f=y=>{y.children.sort((b,j)=>(b.data.start_time||0)-(j.data.start_time||0)),y.children.forEach(f)};d.forEach(f);const m=l[0],h=m&&"data"in m?m.data:null,g=Math.min(...l.map(y=>("data"in y?y.data:null)?.start_time||Date.now()/1e3)),x=l.reduce((y,b)=>{const j="data"in b?b.data:null;return y+(j?.duration_ms||0)},0);r.push({response_id:a,timestamp:g,traces:d,totalDuration:x,entity_id:h?.entity_id})}return r.sort((a,l)=>l.timestamp-a.timestamp),r}function Su(e){if(typeof e=="string"){const n=e.trim();if(n.startsWith("[")||n.startsWith("{"))try{const r=JSON.parse(e);return Su(r)}catch{return e}return e}if(Array.isArray(e))return e.map(Su);if(e!==null&&typeof e=="object"){const n={};for(const[r,a]of Object.entries(e))n[r]=Su(a);return n}return e}function l2(e){try{const n=Su(e);return JSON.stringify(n,null,2)}catch{return JSON.stringify(e,null,2)}}function kR(e){return e.includes("invoke_agent")||e.includes("Agent")?"bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200":e.includes("chat")||e.includes("Chat")?"bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200":e.includes("tool")||e.includes("execute")?"bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200":"bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200"}function c2({node:e,depth:n=0}){const[r,a]=w.useState(n<2),[l,c]=w.useState(!1),{data:d}=e,f=d.operation_name||"Unknown",m=d.duration_ms?`${Number(d.duration_ms).toFixed(1)}ms`:"",h=e.children.length>0,g=d.attributes?.["gen_ai.usage.input_tokens"],x=d.attributes?.["gen_ai.usage.output_tokens"],y=g!==void 0||x!==void 0;return o.jsxs("div",{className:"relative",children:[n>0&&o.jsx("div",{className:"absolute left-0 top-0 bottom-0 border-l-2 border-muted",style:{marginLeft:`${(n-1)*16+8}px`}}),o.jsxs("div",{className:"flex items-center gap-2 py-1.5 hover:bg-muted/50 rounded transition-colors",style:{paddingLeft:`${n*16}px`},children:[o.jsx("button",{onClick:()=>h?a(!r):c(!l),className:"w-4 h-4 flex items-center justify-center text-muted-foreground hover:text-foreground",children:h?r?o.jsx(Rt,{className:"h-3 w-3"}):o.jsx(en,{className:"h-3 w-3"}):l?o.jsx(Rt,{className:"h-3 w-3"}):o.jsx(en,{className:"h-3 w-3"})}),o.jsx("span",{className:`text-xs px-1.5 py-0.5 rounded font-medium ${kR(f)}`,children:f.replace("Agent.","").replace("invoke_agent ","")}),m&&o.jsx("span",{className:"text-xs text-muted-foreground font-mono",children:m}),y&&o.jsxs("span",{className:"text-xs text-muted-foreground font-mono",children:[g!==void 0&&o.jsxs("span",{children:["↑",String(g)]}),g!==void 0&&x!==void 0&&o.jsx("span",{className:"mx-0.5",children:"/"}),x!==void 0&&o.jsxs("span",{children:["↓",String(x)]})]})]}),l&&!h&&o.jsx("div",{className:"ml-4 mt-1 mb-2 p-2 bg-muted/30 rounded border text-xs",style:{marginLeft:`${n*16+20}px`},children:o.jsxs("div",{className:"space-y-1",children:[d.span_id&&o.jsxs("div",{className:"flex gap-2",children:[o.jsx("span",{className:"text-muted-foreground w-20",children:"Span ID:"}),o.jsx("span",{className:"font-mono text-xs break-all",children:d.span_id})]}),d.trace_id&&o.jsxs("div",{className:"flex gap-2",children:[o.jsx("span",{className:"text-muted-foreground w-20",children:"Trace ID:"}),o.jsx("span",{className:"font-mono text-xs break-all",children:d.trace_id})]}),d.status&&o.jsxs("div",{className:"flex gap-2",children:[o.jsx("span",{className:"text-muted-foreground w-20",children:"Status:"}),o.jsx("span",{className:`px-1.5 py-0.5 rounded text-xs ${d.status==="StatusCode.UNSET"||d.status==="OK"?"bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200":"bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"}`,children:d.status})]}),d.attributes&&Object.keys(d.attributes).length>0&&o.jsxs("div",{className:"mt-2",children:[o.jsx("span",{className:"text-muted-foreground block mb-1",children:"Attributes:"}),o.jsx("pre",{className:"text-xs bg-background border rounded p-2 overflow-auto max-h-32 whitespace-pre-wrap break-all",children:l2(d.attributes)})]})]})}),h&&r&&o.jsx("div",{children:e.children.map((b,j)=>o.jsx(c2,{node:b,depth:n+1},b.data.span_id||j))})]})}function TR({group:e}){const[n,r]=w.useState(!0),a=new Date(e.timestamp*1e3).toLocaleTimeString(),l=e.totalDuration>0?`${e.totalDuration.toFixed(0)}ms`:"",c=e.traces.reduce((d,f)=>{const m=h=>1+h.children.reduce((g,x)=>g+m(x),0);return d+m(f)},0);return o.jsxs("div",{className:"border rounded-lg overflow-hidden",children:[o.jsxs("div",{className:"flex items-center gap-2 p-2 bg-muted/50 cursor-pointer hover:bg-muted/70 transition-colors",onClick:()=>r(!n),children:[o.jsx("div",{className:"text-muted-foreground",children:n?o.jsx(Rt,{className:"h-4 w-4"}):o.jsx(en,{className:"h-4 w-4"})}),o.jsx("span",{className:"font-mono text-xs text-muted-foreground",children:a}),e.entity_id&&o.jsx(ut,{variant:"outline",className:"text-xs py-0",children:e.entity_id.replace("agent_","").replace("workflow_","")}),o.jsx("div",{className:"flex-1"}),l&&o.jsx(ut,{variant:"secondary",className:"text-xs py-0",children:l}),o.jsxs("span",{className:"text-xs text-muted-foreground",children:[c," span",c!==1?"s":""]})]}),n&&o.jsx("div",{className:"p-2 border-t",children:e.traces.map((d,f)=>o.jsx(c2,{node:d,depth:0},d.data.span_id||f))})]})}function AR({events:e}){const n=le(c=>c.debugTraceSubTab),r=le(c=>c.setDebugTraceSubTab),a=e.filter(c=>c.type==="response.trace.completed"),l=CR(a);return o.jsxs("div",{className:"h-full flex flex-col",children:[o.jsxs("div",{className:"flex items-center gap-2 p-3 border-b",children:[o.jsx(Bu,{className:"h-4 w-4"}),o.jsx("span",{className:"font-medium",children:"Traces"}),o.jsx(ut,{variant:"outline",children:a.length}),o.jsx("div",{className:"flex-1"}),o.jsxs("div",{className:"flex items-center bg-muted rounded-md p-1 min-w-0",children:[o.jsx("button",{onClick:()=>r("spans"),className:`px-3 py-1.5 text-xs rounded transition-colors truncate ${n==="spans"?"bg-background shadow-sm font-medium":"text-muted-foreground hover:text-foreground"}`,children:"OTel Spans"}),o.jsxs("button",{onClick:()=>r("context"),className:`px-3 py-1.5 text-xs rounded transition-colors flex items-center gap-1.5 min-w-0 ${n==="context"?"bg-background shadow-sm font-medium":"text-muted-foreground hover:text-foreground"}`,children:[o.jsx(ha,{className:"h-3.5 w-3.5 flex-shrink-0"}),o.jsx("span",{className:"truncate",children:"Context Inspector"})]})]})]}),n==="spans"?o.jsxs("div",{className:"flex-1 flex flex-col min-h-0",children:[a.length>0&&o.jsx("div",{className:"p-3 border-b flex-shrink-0",children:o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(Bu,{className:"h-4 w-4"}),o.jsx("span",{className:"font-medium text-sm",children:"OTel Spans"}),o.jsxs(ut,{variant:"outline",className:"text-xs",children:[l.length," turn",l.length!==1?"s":""]})]})}),a.length===0?o.jsxs("div",{className:"flex flex-col items-center text-center p-6 pt-9",children:[o.jsx(ha,{className:"h-8 w-8 text-muted-foreground mb-3"}),o.jsx("div",{className:"text-sm font-medium mb-1",children:"No Data"}),o.jsxs("div",{className:"text-xs text-muted-foreground max-w-[200px]",children:["Run"," ",o.jsx("span",{className:"font-mono bg-accent/10 px-1 rounded",children:"devui --instrumentation"})," ","and start a conversation."]})]}):o.jsx(Wn,{className:"flex-1",children:o.jsx("div",{className:"p-3",children:o.jsx("div",{className:"space-y-3",children:l.map(c=>o.jsx(TR,{group:c},c.response_id))})})})]}):o.jsx(bR,{events:e})]})}function MR({events:e}){const n=gg(e),r=[],a=n.filter(m=>m.type==="response.function_call.complete"),l=e.filter(m=>zr(m)!==null),c=new Map;l.forEach(m=>{const h=zr(m);h&&c.set(h.call_id,m)}),a.forEach(m=>{if(r.push(m),"data"in m&&m.data&&m.data.call_id){const h=String(m.data.call_id),g=c.get(h);g&&(r.push(g),c.delete(h))}}),c.forEach(m=>{r.push(m)});const f=[...i2(r)].reverse();return o.jsxs("div",{className:"h-full flex flex-col",children:[o.jsxs("div",{className:"flex items-center gap-2 p-3 border-b",children:[o.jsx(_a,{className:"h-4 w-4"}),o.jsx("span",{className:"font-medium",children:"Tools"}),o.jsx(ut,{variant:"outline",children:r.length})]}),o.jsx(Wn,{className:"flex-1",children:o.jsx("div",{className:"p-3",children:r.length===0?o.jsx("div",{className:"text-center text-muted-foreground text-sm py-8",children:"No tool executions yet. Tool calls will appear here during conversations."}):o.jsx("div",{className:"space-y-3",children:f.map((m,h)=>"type"in m&&m.type==="separator"?o.jsx(a2,{},m.id):o.jsx(RR,{event:m},h))})})})]})}function RR({event:e}){const n="_uiTimestamp"in e&&typeof e._uiTimestamp=="number"?new Date(e._uiTimestamp*1e3).toLocaleTimeString():new Date().toLocaleTimeString(),r=e.type==="response.function_call.complete",a=zr(e),l=a!==null;if(!r&&!l)return null;const c=r&&"data"in e?e.data:null;return o.jsxs("div",{className:"border rounded p-3",children:[o.jsxs("div",{className:"flex items-center justify-between mb-2",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(og,{className:"h-4 w-4 text-yellow-600 dark:text-yellow-400"}),o.jsx("span",{className:"font-medium text-sm",children:r?"Tool Call":"Tool Result"}),r&&c&&c.name!==void 0&&o.jsxs("span",{className:"text-xs text-muted-foreground",children:["(",String(c.name),")"]})]}),o.jsx("span",{className:"text-xs text-muted-foreground font-mono",children:n})]}),r&&c&&o.jsxs("div",{className:"p-2 bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded",children:[o.jsxs("div",{className:"flex items-center gap-2 mb-2",children:[o.jsx(_a,{className:"h-3 w-3 text-blue-600 dark:text-blue-400"}),o.jsx("span",{className:"text-xs font-mono bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded",children:"CALL"}),o.jsx("span",{className:"font-medium text-sm",children:String(c.name||"unknown")})]}),c.arguments!==void 0&&o.jsxs("div",{className:"text-xs",children:[o.jsx("span",{className:"text-muted-foreground mb-1 block",children:"Arguments:"}),o.jsx("pre",{className:"p-2 bg-background border rounded text-xs overflow-auto max-h-32 max-w-full break-all whitespace-pre-wrap",children:typeof c.arguments=="string"?c.arguments:JSON.stringify(c.arguments,null,1)})]})]}),l&&a&&o.jsxs("div",{className:"p-2 bg-green-50 dark:bg-green-950/50 border border-green-200 dark:border-green-800 rounded",children:[o.jsxs("div",{className:"flex items-center gap-2 mb-2",children:[o.jsx(nn,{className:"h-3 w-3 text-green-600 dark:text-green-400"}),o.jsx("span",{className:"text-xs font-mono bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded",children:"RESULT"}),a.status!=="completed"&&o.jsx("span",{className:"ml-auto px-2 py-1 rounded text-xs font-medium bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200",children:a.status})]}),o.jsxs("div",{className:"text-xs space-y-1",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx("span",{className:"text-muted-foreground",children:"Call ID:"}),o.jsx("span",{className:"font-mono text-xs break-all",children:a.call_id})]}),o.jsxs("div",{children:[o.jsx("span",{className:"text-muted-foreground block mb-1",children:"Output:"}),o.jsx("pre",{className:"p-2 bg-background border rounded text-xs overflow-auto max-h-32 break-all whitespace-pre-wrap",children:a.output})]})]})]})]})}function DR({events:e,isStreaming:n=!1,onMinimize:r}){const a=le(d=>d.debugPanelTab),l=le(d=>d.setDebugPanelTab),c=w.useMemo(()=>{const d=gg(e),f=d.length,m=e.filter(g=>g.type==="response.trace.completed").length,h=d.filter(g=>g.type==="response.function_call.complete").length+e.filter(g=>zr(g)!==null).length;return{eventsCount:f,tracesCount:m,toolsCount:h}},[e]);return o.jsx("div",{className:"flex-1 border-l flex flex-col min-h-0",children:o.jsxs(D5,{value:a,onValueChange:d=>l(d),className:"flex-1 flex flex-col min-h-0",children:[o.jsxs("div",{className:"px-3 pt-3 flex items-center gap-2 flex-shrink-0",children:[o.jsxs($N,{className:"flex-1",children:[o.jsxs(Nu,{value:"events",className:"flex-1 gap-1.5",children:["Events",c.eventsCount>0&&o.jsx("span",{className:"text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center",children:c.eventsCount})]}),o.jsxs(Nu,{value:"traces",className:"flex-1 gap-1.5",children:["Traces",c.tracesCount>0&&o.jsx("span",{className:"text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center",children:c.tracesCount})]}),o.jsxs(Nu,{value:"tools",className:"flex-1 gap-1.5",children:["Tools",c.toolsCount>0&&o.jsx("span",{className:"text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center",children:c.toolsCount})]})]}),r&&o.jsx(Le,{variant:"ghost",size:"sm",onClick:r,className:"h-8 w-8 p-0 flex-shrink-0",title:"Minimize debug panel",children:o.jsx(en,{className:"h-4 w-4"})})]}),o.jsx(ju,{value:"events",className:"flex-1 mt-0 overflow-hidden",children:o.jsx(ER,{events:e,isStreaming:n})}),o.jsx(ju,{value:"traces",className:"flex-1 mt-0 overflow-hidden",children:o.jsx(AR,{events:e})}),o.jsx(ju,{value:"tools",className:"flex-1 mt-0 overflow-hidden",children:o.jsx(MR,{events:e})})]})})}function Ir({open:e,onOpenChange:n,children:r}){if(!e)return null;const a=()=>{n(!1)},l=d=>{d.stopPropagation()},c=d=>{d.stopPropagation()};return o.jsxs("div",{className:"fixed inset-0 z-50 flex items-center justify-center",children:[o.jsx("div",{className:"absolute inset-0 bg-black/50",onClick:a}),o.jsx("div",{className:"relative z-10",onClick:l,onMouseDown:c,onMouseUp:d=>d.stopPropagation(),children:r})]})}function Lr({children:e,className:n=""}){const a=n.includes("w-[")||n.includes("w-full")||n.includes("max-w-")?"":"max-w-lg w-full";return o.jsx("div",{className:`relative bg-background border rounded-lg shadow-lg max-h-[90vh] overflow-hidden ${a} ${n}`,children:e})}function $r({children:e,className:n=""}){return o.jsx("div",{className:`space-y-2 ${n}`,children:e})}function Pr({children:e,className:n=""}){return o.jsx("h2",{className:`text-lg font-semibold ${n}`,children:e})}function OR({children:e,className:n=""}){return o.jsx("p",{className:`text-sm text-muted-foreground ${n}`,children:e})}function So({onClose:e}){return o.jsx(Le,{variant:"ghost",size:"sm",onClick:e,className:"absolute top-4 right-4 h-8 w-8 p-0 rounded-sm opacity-70 hover:opacity-100",children:o.jsx(Ea,{className:"h-4 w-4"})})}function zR({children:e}){return o.jsx("div",{className:"flex justify-end gap-2 p-4 border-t bg-muted/50",children:e})}function as({className:e,type:n,...r}){return o.jsx("input",{type:n,"data-slot":"input",className:We("file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm","focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]","aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",e),...r})}var IR="Label",u2=w.forwardRef((e,n)=>o.jsx(Ye.label,{...e,ref:n,onMouseDown:r=>{r.target.closest("button, input, select, textarea")||(e.onMouseDown?.(r),!r.defaultPrevented&&r.detail>1&&r.preventDefault())}}));u2.displayName=IR;var LR=u2;function kt({className:e,...n}){return o.jsx(LR,{"data-slot":"label",className:We("flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",e),...n})}var Td="Switch",[$R,e$]=Kn(Td),[PR,HR]=$R(Td),d2=w.forwardRef((e,n)=>{const{__scopeSwitch:r,name:a,checked:l,defaultChecked:c,required:d,disabled:f,value:m="on",onCheckedChange:h,form:g,...x}=e,[y,b]=w.useState(null),j=rt(n,E=>b(E)),N=w.useRef(!1),S=y?g||!!y.closest("form"):!0,[_,A]=Ar({prop:l,defaultProp:c??!1,onChange:h,caller:Td});return o.jsxs(PR,{scope:r,checked:_,disabled:f,children:[o.jsx(Ye.button,{type:"button",role:"switch","aria-checked":_,"aria-required":d,"data-state":p2(_),"data-disabled":f?"":void 0,disabled:f,value:m,...x,ref:j,onClick:ke(e.onClick,E=>{A(M=>!M),S&&(N.current=E.isPropagationStopped(),N.current||E.stopPropagation())})}),S&&o.jsx(h2,{control:y,bubbles:!N.current,name:a,value:m,checked:_,required:d,disabled:f,form:g,style:{transform:"translateX(-100%)"}})]})});d2.displayName=Td;var f2="SwitchThumb",m2=w.forwardRef((e,n)=>{const{__scopeSwitch:r,...a}=e,l=HR(f2,r);return o.jsx(Ye.span,{"data-state":p2(l.checked),"data-disabled":l.disabled?"":void 0,...a,ref:n})});m2.displayName=f2;var UR="SwitchBubbleInput",h2=w.forwardRef(({__scopeSwitch:e,control:n,checked:r,bubbles:a=!0,...l},c)=>{const d=w.useRef(null),f=rt(d,c),m=fg(r),h=Lp(n);return w.useEffect(()=>{const g=d.current;if(!g)return;const x=window.HTMLInputElement.prototype,b=Object.getOwnPropertyDescriptor(x,"checked").set;if(m!==r&&b){const j=new Event("click",{bubbles:a});b.call(g,r),g.dispatchEvent(j)}},[m,r,a]),o.jsx("input",{type:"checkbox","aria-hidden":!0,defaultChecked:r,...l,tabIndex:-1,ref:f,style:{...l.style,...h,position:"absolute",pointerEvents:"none",opacity:0,margin:0}})});h2.displayName=UR;function p2(e){return e?"checked":"unchecked"}var g2=d2,BR=m2;const Wi=w.forwardRef(({className:e,...n},r)=>o.jsx(g2,{className:We("peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",e),...n,ref:r,children:o.jsx(BR,{className:We("pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0")})}));Wi.displayName=g2.displayName;const VR=["gpt-4.1","gpt-4.1-mini","o1","o1-mini","o3-mini"];function cb({open:e,onOpenChange:n,onBackendUrlChange:r}){const[a,l]=w.useState("general"),{oaiMode:c,setOAIMode:d,azureDeploymentEnabled:f,setAzureDeploymentEnabled:m,authRequired:h,serverCapabilities:g,serverVersion:x,runtime:y,uiMode:b,streamingEnabled:j,setStreamingEnabled:N}=le(),S="",[_,A]=w.useState(()=>localStorage.getItem("devui_backend_url")||S),[E,M]=w.useState(_),[T,D]=w.useState(!!localStorage.getItem("devui_auth_token")),[z,H]=w.useState(""),q=()=>{try{new URL(E),localStorage.setItem("devui_backend_url",E),A(E),r?.(E),n(!1),window.location.reload()}catch{alert("Please enter a valid URL (e.g., http://localhost:8080)")}},X=()=>{localStorage.removeItem("devui_backend_url"),M(S),A(S),r?.(S),window.location.reload()},W=()=>{z.trim()&&(localStorage.setItem("devui_auth_token",z.trim()),D(!0),H(""),window.location.reload())},G=()=>{localStorage.removeItem("devui_auth_token"),D(!1),H(""),window.location.reload()},ne=E!==_,B=!localStorage.getItem("devui_backend_url");return o.jsx(Ir,{open:e,onOpenChange:n,children:o.jsxs(Lr,{className:"w-[600px] max-w-[90vw] flex flex-col max-h-[85vh]",children:[o.jsx($r,{className:"p-6 pb-2 flex-shrink-0",children:o.jsx(Pr,{children:"Settings"})}),o.jsx(So,{onClose:()=>n(!1)}),o.jsxs("div",{className:"flex border-b px-6 flex-shrink-0",children:[o.jsxs("button",{onClick:()=>l("general"),className:`px-4 py-2 text-sm font-medium transition-colors relative ${a==="general"?"text-foreground":"text-muted-foreground hover:text-foreground"}`,children:["General",a==="general"&&o.jsx("div",{className:"absolute bottom-0 left-0 right-0 h-0.5 bg-primary"})]}),g.openai_proxy&&o.jsxs("button",{onClick:()=>l("proxy"),className:`px-4 py-2 text-sm font-medium transition-colors relative ${a==="proxy"?"text-foreground":"text-muted-foreground hover:text-foreground"}`,children:["OpenAI Proxy",a==="proxy"&&o.jsx("div",{className:"absolute bottom-0 left-0 right-0 h-0.5 bg-primary"})]}),o.jsxs("button",{onClick:()=>l("about"),className:`px-4 py-2 text-sm font-medium transition-colors relative ${a==="about"?"text-foreground":"text-muted-foreground hover:text-foreground"}`,children:["About",a==="about"&&o.jsx("div",{className:"absolute bottom-0 left-0 right-0 h-0.5 bg-primary"})]})]}),o.jsxs("div",{className:"px-6 pb-6 overflow-y-auto flex-1 min-h-[400px]",children:[a==="general"&&o.jsxs("div",{className:"space-y-6 pt-4",children:[o.jsxs("div",{className:"space-y-3",children:[o.jsxs("div",{className:"flex items-center justify-between",children:[o.jsx(kt,{htmlFor:"backend-url",className:"text-sm font-medium",children:"Backend URL"}),!B&&o.jsxs(Le,{variant:"ghost",size:"sm",onClick:X,className:"h-7 text-xs",title:"Reset to default",children:[o.jsx(sg,{className:"h-3 w-3 mr-1"}),"Reset"]})]}),o.jsx(as,{id:"backend-url",type:"url",value:E,onChange:U=>M(U.target.value),placeholder:"http://localhost:8080",className:"font-mono text-sm"}),o.jsxs("p",{className:"text-xs text-muted-foreground",children:["Default: ",o.jsx("span",{className:"font-mono",children:S})]}),o.jsx("div",{className:"flex gap-2 pt-2 min-h-[36px]",children:ne&&o.jsxs(o.Fragment,{children:[o.jsx(Le,{onClick:q,size:"sm",className:"flex-1",children:"Apply & Reload"}),o.jsx(Le,{onClick:()=>M(_),variant:"outline",size:"sm",className:"flex-1",children:"Cancel"})]})})]}),(h||T)&&o.jsxs("div",{className:"space-y-3 border-t pt-6",children:[o.jsxs("div",{className:"flex items-center justify-between",children:[o.jsx(kt,{className:"text-sm font-medium",children:"Authentication Token"}),!h&&T&&o.jsx("span",{className:"text-xs text-muted-foreground",children:"(Not required by current backend)"})]}),T?o.jsxs("div",{className:"space-y-3",children:[o.jsxs("div",{className:"flex items-center gap-2",children:[o.jsx(as,{type:"password",value:"••••••••••••••••••••",disabled:!0,className:"font-mono text-sm flex-1"}),o.jsx(Le,{variant:"destructive",size:"sm",onClick:G,className:"flex-shrink-0",children:"Clear"})]}),o.jsx("p",{className:"text-xs text-green-600 dark:text-green-400",children:"✓ Token configured and stored locally"})]}):o.jsxs("div",{className:"space-y-3",children:[o.jsx(as,{type:"password",value:z,onChange:U=>H(U.target.value),placeholder:"Enter bearer token",className:"font-mono text-sm",onKeyDown:U=>{U.key==="Enter"&&z.trim()&&W()}}),o.jsx(Le,{onClick:W,size:"sm",disabled:!z.trim(),className:"w-full",children:"Save & Reload"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:h?"Required by backend (started with --auth flag)":"Not required by current backend"})]})]}),g.deployment&&o.jsxs("div",{className:"space-y-3 border-t pt-6",children:[o.jsxs("div",{className:"flex items-center justify-between",children:[o.jsxs("div",{className:"space-y-0.5",children:[o.jsx(kt,{className:"text-sm font-medium",children:"Azure Deployment"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Enable one-click deployment to Azure Container Apps"})]}),o.jsx(Wi,{checked:f,onCheckedChange:m})]}),o.jsxs("details",{className:"group",children:[o.jsxs("summary",{className:"cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1",children:[o.jsx(en,{className:"h-3 w-3 transition-transform group-open:rotate-90"}),"Learn more about Azure deployment"]}),o.jsxs("div",{className:"mt-3 space-y-3 pl-4",children:[o.jsx("p",{className:"text-xs text-muted-foreground leading-relaxed",children:'When enabled, agents that support deployment will show a "Deploy to Azure" button. This allows you to deploy your agent to Azure Container Apps directly from DevUI.'}),o.jsxs("div",{className:"space-y-1.5",children:[o.jsx("p",{className:"text-xs font-medium",children:"When enabled:"}),o.jsxs("ul",{className:"text-xs text-muted-foreground space-y-0.5 list-disc list-inside",children:[o.jsx("li",{children:'Shows "Deploy to Azure" for supported agents'}),o.jsx("li",{children:"Requires Azure CLI and proper authentication"}),o.jsx("li",{children:"Backend must have deployment capabilities enabled"})]})]}),o.jsxs("div",{className:"space-y-1.5",children:[o.jsx("p",{className:"text-xs font-medium",children:"When disabled:"}),o.jsxs("ul",{className:"text-xs text-muted-foreground space-y-0.5 list-disc list-inside",children:[o.jsx("li",{children:'Shows "Deployment Guide" for all agents'}),o.jsx("li",{children:"Provides Docker templates and manual deployment instructions"}),o.jsx("li",{children:"No backend deployment capabilities required"})]})]})]})]})]}),o.jsx("div",{className:"space-y-3 border-t pt-6",children:o.jsxs("div",{className:"flex items-center justify-between",children:[o.jsxs("div",{className:"space-y-0.5",children:[o.jsx(kt,{className:"text-sm font-medium",children:"Show Tool Calls"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Display function/tool calls and results in chat messages"})]}),o.jsx(Wi,{checked:le.getState().showToolCalls,onCheckedChange:U=>le.getState().setShowToolCalls(U)})]})}),o.jsxs("div",{className:"space-y-3 border-t pt-6",children:[o.jsxs("div",{className:"flex items-center justify-between",children:[o.jsxs("div",{className:"space-y-0.5",children:[o.jsx(kt,{className:"text-sm font-medium",children:"Streaming Mode"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Stream responses token-by-token as they're generated"})]}),o.jsx(Wi,{checked:j,onCheckedChange:N})]}),!j&&o.jsxs("div",{className:"flex items-start gap-2 text-xs text-amber-600 dark:text-amber-400 bg-amber-500/10 p-3 rounded",children:[o.jsx(Fs,{className:"h-3.5 w-3.5 flex-shrink-0 mt-0.5"}),o.jsxs("div",{children:[o.jsx("p",{className:"font-medium",children:"Non-streaming mode limitations:"}),o.jsxs("ul",{className:"mt-1 space-y-0.5 list-disc list-inside text-amber-600/80 dark:text-amber-400/80",children:[o.jsx("li",{children:"Tool calls won't display in real-time"}),o.jsx("li",{children:"No typing indicator during generation"}),o.jsx("li",{children:"Response appears all at once when complete"})]})]})]})]})]}),a==="proxy"&&g.openai_proxy&&o.jsxs("div",{className:"space-y-6 pt-4",children:[o.jsxs("div",{className:"space-y-4",children:[o.jsxs("div",{className:"flex items-center justify-between",children:[o.jsxs("div",{className:"space-y-0.5",children:[o.jsx(kt,{className:"text-base font-medium",children:"OpenAI Proxy Mode"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Route requests through DevUI backend to OpenAI API"})]}),o.jsx(Wi,{checked:c.enabled,onCheckedChange:U=>d({...c,enabled:U})})]}),!c.enabled&&o.jsx("div",{className:"bordder border-muted bg-muted/30 rounded-lg p-4 space-y-3",children:o.jsxs("div",{className:"flex items-start gap-2",children:[o.jsx(Fs,{className:"h-4 w-4 flex-shrink-0 mt-0.5 text-blue-600 dark:text-blue-400"}),o.jsxs("div",{className:"space-y-2",children:[o.jsx("p",{className:"text-sm font-medium",children:"About OpenAI Proxy Mode"}),o.jsxs("p",{className:"text-xs text-muted-foreground leading-relaxed",children:["When enabled, your chat requests are sent to your DevUI backend"," ",o.jsxs("span",{className:"font-mono font-semibold",children:["(",_,")"]}),", which then forwards them to OpenAI's API. This keeps your"," ",o.jsx("span",{className:"font-mono font-semibold",children:"OPENAI_API_KEY"})," ","secure on the server instead of exposing it in the browser."]}),o.jsxs("div",{className:"space-y-1.5 pt-1",children:[o.jsx("p",{className:"text-xs font-medium",children:"Requirements:"}),o.jsxs("ul",{className:"text-xs text-muted-foreground space-y-0.5 list-disc list-inside",children:[o.jsxs("li",{children:["Backend must have"," ",o.jsx("span",{className:"font-mono",children:"OPENAI_API_KEY"})," ","configured"]}),o.jsx("li",{children:"Backend must support OpenAI Responses API proxying (DevUI does)"})]})]}),o.jsxs("div",{className:"space-y-1.5 pt-1",children:[o.jsx("p",{className:"text-xs font-medium",children:"Why use this?"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Quickly test and compare OpenAI models directly through the DevUI interface without creating custom agents or exposing API keys in the browser."})]})]})]})}),c.enabled&&o.jsxs("div",{className:"space-y-4 pl-4 border-l-2 border-muted",children:[o.jsxs("div",{className:"space-y-2",children:[o.jsx(kt,{className:"text-sm font-medium",children:"Model"}),o.jsx(as,{type:"text",value:c.model,onChange:U=>d({...c,model:U.target.value}),placeholder:"gpt-4.1-mini",className:"font-mono text-sm"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Enter any OpenAI model ID (e.g., gpt-4.1, o1, o3-mini)"})]}),o.jsxs("div",{className:"space-y-2",children:[o.jsx(kt,{className:"text-xs text-muted-foreground",children:"Common presets"}),o.jsx("div",{className:"flex flex-wrap gap-2",children:VR.map(U=>o.jsx(Le,{variant:c.model===U?"default":"outline",size:"sm",onClick:()=>d({...c,model:U}),className:"text-xs h-7",children:U},U))})]}),o.jsxs("details",{className:"group",children:[o.jsxs("summary",{className:"cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1",children:[o.jsx(en,{className:"h-3 w-3 transition-transform group-open:rotate-90"}),"Advanced Parameters (optional)"]}),o.jsxs("div",{className:"space-y-3 mt-3 pl-4",children:[o.jsxs("div",{className:"space-y-1",children:[o.jsx(kt,{className:"text-xs",children:"Temperature"}),o.jsx(as,{type:"number",step:"0.1",min:"0",max:"2",value:c.temperature??"",onChange:U=>d({...c,temperature:U.target.value?parseFloat(U.target.value):void 0}),placeholder:"1.0 (default)",className:"text-sm"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Controls randomness (0-2)"})]}),o.jsxs("div",{className:"space-y-1",children:[o.jsx(kt,{className:"text-xs",children:"Max Output Tokens"}),o.jsx(as,{type:"number",min:"1",value:c.max_output_tokens??"",onChange:U=>d({...c,max_output_tokens:U.target.value?parseInt(U.target.value):void 0}),placeholder:"Auto",className:"text-sm"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Maximum tokens in response"})]}),o.jsxs("div",{className:"space-y-1",children:[o.jsx(kt,{className:"text-xs",children:"Top P"}),o.jsx(as,{type:"number",step:"0.1",min:"0",max:"1",value:c.top_p??"",onChange:U=>d({...c,top_p:U.target.value?parseFloat(U.target.value):void 0}),placeholder:"1.0 (default)",className:"text-sm"}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Nucleus sampling (0-1)"})]}),o.jsxs("div",{className:"space-y-1",children:[o.jsx(kt,{className:"text-xs",children:"Reasoning Effort (o-series models)"}),o.jsxs("select",{value:c.reasoning_effort??"",onChange:U=>d({...c,reasoning_effort:U.target.value?U.target.value:void 0}),className:"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",children:[o.jsx("option",{value:"",children:"Auto (default)"}),o.jsx("option",{value:"minimal",children:"Minimal"}),o.jsx("option",{value:"low",children:"Low"}),o.jsx("option",{value:"medium",children:"Medium"}),o.jsx("option",{value:"high",children:"High"})]}),o.jsx("p",{className:"text-xs text-muted-foreground",children:"Constrains reasoning effort (faster/cheaper vs thorough)"})]})]})]})]})]}),c.enabled&&o.jsxs("div",{className:"flex items-start gap-2 text-xs text-muted-foreground bg-muted/50 p-3 rounded",children:[o.jsx(Fs,{className:"h-3.5 w-3.5 flex-shrink-0 mt-0.5"}),o.jsx("div",{className:"space-y-1",children:o.jsxs("p",{children:["Requests route through"," ",o.jsx("span",{className:"font-mono font-semibold",children:_})," ","to OpenAI API. Server must have"," ",o.jsx("span",{className:"font-mono font-semibold",children:"OPENAI_API_KEY"})," ","configured."]})})]})]}),a==="about"&&o.jsxs("div",{className:"space-y-4 pt-4",children:[o.jsx("p",{className:"text-sm text-muted-foreground",children:"DevUI is a sample app for getting started with Agent Framework."}),o.jsxs("div",{className:"space-y-2 text-sm",children:[o.jsxs("div",{className:"flex justify-between",children:[o.jsx("span",{className:"text-muted-foreground",children:"Version:"}),o.jsx("span",{className:"font-mono",children:x||"Unknown"})]}),o.jsxs("div",{className:"flex justify-between",children:[o.jsx("span",{className:"text-muted-foreground",children:"Runtime:"}),o.jsx("span",{className:"font-mono capitalize",children:y||"Unknown"})]}),o.jsxs("div",{className:"flex justify-between",children:[o.jsx("span",{className:"text-muted-foreground",children:"UI Mode:"}),o.jsx("span",{className:"font-mono capitalize",children:b||"Unknown"})]})]}),(g||h!==void 0)&&o.jsxs("div",{className:"space-y-2 pt-2",children:[o.jsx("p",{className:"text-xs font-medium text-muted-foreground uppercase tracking-wide",children:"Capabilities"}),o.jsxs("div",{className:"space-y-1 text-sm",children:[g?.instrumentation!==void 0&&o.jsxs("div",{className:"flex justify-between items-center",children:[o.jsx("span",{className:"text-muted-foreground",children:"Instrumentation:"}),o.jsx("span",{className:`text-xs px-2 py-0.5 rounded-full ${g.instrumentation?"bg-green-500/10 text-green-600 dark:text-green-400":"bg-muted text-muted-foreground"}`,children:g.instrumentation?"Enabled":"Disabled"})]}),g?.openai_proxy!==void 0&&o.jsxs("div",{className:"flex justify-between items-center",children:[o.jsx("span",{className:"text-muted-foreground",children:"OpenAI Proxy:"}),o.jsx("span",{className:`text-xs px-2 py-0.5 rounded-full ${g.openai_proxy?"bg-green-500/10 text-green-600 dark:text-green-400":"bg-muted text-muted-foreground"}`,children:g.openai_proxy?"Available":"Not Configured"})]}),g?.deployment!==void 0&&o.jsxs("div",{className:"flex justify-between items-center",children:[o.jsx("span",{className:"text-muted-foreground",children:"Deployment:"}),o.jsx("span",{className:`text-xs px-2 py-0.5 rounded-full ${g.deployment?"bg-green-500/10 text-green-600 dark:text-green-400":"bg-muted text-muted-foreground"}`,children:g.deployment?"Available":"Disabled"})]}),h!==void 0&&o.jsxs("div",{className:"flex justify-between items-center",children:[o.jsx("span",{className:"text-muted-foreground",children:"Authentication:"}),o.jsx("span",{className:`text-xs px-2 py-0.5 rounded-full ${h?"bg-blue-500/10 text-blue-600 dark:text-blue-400":"bg-muted text-muted-foreground"}`,children:h?"Required":"Not Required"})]})]})]}),o.jsx("div",{className:"flex justify-center pt-2",children:o.jsxs(Le,{variant:"outline",size:"sm",onClick:()=>window.open("https://github.com/microsoft/agent-framework","_blank"),className:"text-xs",children:[o.jsx(Hu,{className:"h-3 w-3 mr-1"}),"Learn More about Agent Framework"]})})]})]})]})})}const qR="modulepreload",FR=function(e,n){return new URL(e,n).href},ub={},_u=function(n,r,a){let l=Promise.resolve();if(r&&r.length>0){let h=function(g){return Promise.all(g.map(x=>Promise.resolve(x).then(y=>({status:"fulfilled",value:y}),y=>({status:"rejected",reason:y}))))};const d=document.getElementsByTagName("link"),f=document.querySelector("meta[property=csp-nonce]"),m=f?.nonce||f?.getAttribute("nonce");l=h(r.map(g=>{if(g=FR(g,a),g in ub)return;ub[g]=!0;const x=g.endsWith(".css"),y=x?'[rel="stylesheet"]':"";if(a)for(let j=d.length-1;j>=0;j--){const N=d[j];if(N.href===g&&(!x||N.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${g}"]${y}`))return;const b=document.createElement("link");if(b.rel=x?"stylesheet":qR,x||(b.as="script"),b.crossOrigin="",b.href=g,m&&b.setAttribute("nonce",m),document.head.appendChild(b),x)return new Promise((j,N)=>{b.addEventListener("load",j),b.addEventListener("error",()=>N(new Error(`Unable to preload CSS for ${g}`)))})}))}function c(d){const f=new Event("vite:preloadError",{cancelable:!0});if(f.payload=d,window.dispatchEvent(f),!f.defaultPrevented)throw d}return l.then(d=>{for(const f of d||[])f.status==="rejected"&&c(f.reason);return n().catch(c)})},x2="devui_streaming_state_",y2=1440*60*1e3;function Yu(e){return`${x2}${e}`}function YR(e){let n="";for(const r of e)r.type==="response.output_text.delta"&&"delta"in r&&(n+=r.delta);return n}function v2(e){try{const n=Yu(e.conversationId),r=JSON.stringify(e);localStorage.setItem(n,r)}catch(n){console.error("Failed to save streaming state:",n);try{b2();const r=Yu(e.conversationId),a=JSON.stringify(e);localStorage.setItem(r,a)}catch{console.error("Failed to save streaming state even after cleanup")}}}function ba(e){try{const n=Yu(e),r=localStorage.getItem(n);if(!r)return null;const a=JSON.parse(r);return Date.now()-a.timestamp>y2?(Eu(e),null):a.completed?null:a}catch(n){return console.error("Failed to load streaming state:",n),null}}function Nh(e,n,r,a){try{const l=ba(e),c="sequence_number"in n?n.sequence_number:void 0,d=l?[...l.events,n]:[n],f={conversationId:e,responseId:r,lastMessageId:a,lastSequenceNumber:c??l?.lastSequenceNumber??-1,events:d,timestamp:Date.now(),completed:n.type==="response.completed"||n.type==="response.failed",accumulatedText:YR(d)};v2(f)}catch(l){console.error("Failed to update streaming state:",l)}}function jh(e){try{const n=ba(e);n&&(n.completed=!0,n.timestamp=Date.now(),v2(n))}catch(n){console.error("Failed to mark streaming as completed:",n)}}function Eu(e){try{const n=Yu(e);localStorage.removeItem(n)}catch(n){console.error("Failed to clear streaming state:",n)}}function b2(){try{const e=Object.keys(localStorage),n=Date.now();for(const r of e)if(r.startsWith(x2))try{const a=localStorage.getItem(r);if(a){const l=JSON.parse(a);(n-l.timestamp>y2||l.completed)&&localStorage.removeItem(r)}}catch{localStorage.removeItem(r)}}catch(e){console.error("Failed to clear expired streaming states:",e)}}function GR(){b2()}function w2(){const[e,n]=w.useState(!1),r=w.useRef(null),a=w.useCallback(()=>(r.current=new AbortController,n(!1),r.current.signal),[]),l=w.useCallback(()=>{r.current&&(n(!0),r.current.abort(),r.current=null)},[]),c=w.useCallback(()=>{n(!1)},[]),d=w.useCallback(()=>{r.current&&(r.current.abort(),r.current=null)},[]);return{isCancelling:e,createAbortSignal:a,handleCancel:l,resetCancelling:c,cleanup:d}}function Gu(e){return e instanceof DOMException&&e.name==="AbortError"}function XR(e={}){const{onDrop:n,disabled:r=!1}=e,[a,l]=w.useState(!1),[c,d]=w.useState([]),f=w.useRef(0),m=w.useCallback(b=>{b.preventDefault(),b.stopPropagation(),!r&&(f.current++,b.dataTransfer.items&&b.dataTransfer.items.length>0&&l(!0))},[r]),h=w.useCallback(b=>{b.preventDefault(),b.stopPropagation(),!r&&(f.current--,f.current===0&&l(!1))},[r]),g=w.useCallback(b=>{b.preventDefault(),b.stopPropagation()},[]),x=w.useCallback(b=>{if(b.preventDefault(),b.stopPropagation(),l(!1),f.current=0,r)return;const j=Array.from(b.dataTransfer.files);j.length>0&&(d(j),n?.(j))},[r,n]),y=w.useCallback(()=>{d([])},[]);return{isDragOver:a,droppedFiles:c,clearDroppedFiles:y,dragHandlers:{onDragEnter:m,onDragLeave:h,onDragOver:g,onDrop:x}}}const ZR="",WR=1e3,Sh=10;function KR(){const e=localStorage.getItem("devui_backend_url");return e||ZR}function QR(e){return new Promise(n=>setTimeout(n,e))}class JR{baseUrl;authToken=null;constructor(n){this.baseUrl=n||KR(),this.authToken=localStorage.getItem("devui_auth_token")}setBaseUrl(n){this.baseUrl=n}getBaseUrl(){return this.baseUrl}setAuthToken(n){this.authToken=n,n?localStorage.setItem("devui_auth_token",n):localStorage.removeItem("devui_auth_token")}getAuthToken(){return this.authToken}clearAuthToken(){this.setAuthToken(null)}async request(n,r={}){const a=`${this.baseUrl}${n}`,l={"Content-Type":"application/json",...r.headers};this.authToken&&(l.Authorization=`Bearer ${this.authToken}`);const c=await fetch(a,{...r,headers:l});if(!c.ok){if(c.status===401)throw this.clearAuthToken(),new Error("UNAUTHORIZED");let d=`API request failed: ${c.status} ${c.statusText}`;try{const f=await c.json();f.detail?typeof f.detail=="string"?d=f.detail:typeof f.detail=="object"&&f.detail.error?.message&&(d=f.detail.error.message):f.error?.message&&(d=f.error.message)}catch{}throw new Error(d)}return c.json()}async getHealth(){return this.request("/health")}async getMeta(){return this.request("/meta")}async getEntities(){const r=(await this.request("/v1/entities")).entities.map(c=>{if(c.type==="agent")return{id:c.id,name:c.name,description:c.description,type:"agent",source:c.source||"directory",tools:(c.tools||[]).map(d=>typeof d=="string"?d:JSON.stringify(d)),has_env:!!(c.required_env_vars&&c.required_env_vars.length>0),module_path:typeof c.metadata?.module_path=="string"?c.metadata.module_path:void 0,required_env_vars:c.required_env_vars,metadata:c.metadata,deployment_supported:c.deployment_supported,deployment_reason:c.deployment_reason,instructions:c.instructions,model_id:c.model_id,chat_client_type:c.chat_client_type,context_providers:c.context_providers,middleware:c.middleware};{const d=c.executors||c.tools||[];let f=c.start_executor_id||"";if(!f&&d.length>0){const m=d[0];typeof m=="string"&&(f=m)}return{id:c.id,name:c.name,description:c.description,type:"workflow",source:c.source||"directory",executors:d.map(m=>typeof m=="string"?m:JSON.stringify(m)),has_env:!!(c.required_env_vars&&c.required_env_vars.length>0),module_path:typeof c.metadata?.module_path=="string"?c.metadata.module_path:void 0,required_env_vars:c.required_env_vars,metadata:c.metadata,deployment_supported:c.deployment_supported,deployment_reason:c.deployment_reason,input_schema:c.input_schema||{type:"string"},input_type_name:c.input_type_name||"Input",start_executor_id:f,tools:[]}}}),a=r.filter(c=>c.type==="agent"),l=r.filter(c=>c.type==="workflow");return{entities:r,agents:a,workflows:l}}async getAgents(){const{agents:n}=await this.getEntities();return n}async getWorkflows(){const{workflows:n}=await this.getEntities();return n}async getAgentInfo(n){return this.request(`/v1/entities/${n}/info?type=agent`)}async getWorkflowInfo(n){return this.request(`/v1/entities/${n}/info?type=workflow`)}async reloadEntity(n){return this.request(`/v1/entities/${n}/reload`,{method:"POST"})}async createConversation(n){const{oaiMode:r}=await _u(()=>Promise.resolve().then(()=>wu),void 0,import.meta.url).then(c=>({oaiMode:c.useDevUIStore.getState().oaiMode})),a={};r.enabled&&(a["X-Proxy-Backend"]="openai");const l=await this.request("/v1/conversations",{method:"POST",headers:a,body:JSON.stringify({metadata:n})});return{id:l.id,object:"conversation",created_at:l.created_at,metadata:l.metadata}}async listConversations(n){const r=n?`/v1/conversations?agent_id=${encodeURIComponent(n)}`:"/v1/conversations",a=await this.request(r);return{data:a.data.map(l=>({id:l.id,object:"conversation",created_at:l.created_at,metadata:l.metadata})),has_more:a.has_more}}async getConversation(n){const r=await this.request(`/v1/conversations/${n}`);return{id:r.id,object:"conversation",created_at:r.created_at,metadata:r.metadata}}async deleteConversation(n){try{return await this.request(`/v1/conversations/${n}`,{method:"DELETE"}),Eu(n),!0}catch{return!1}}async listConversationItems(n,r){const a=new URLSearchParams;r?.limit&&a.set("limit",r.limit.toString()),r?.after&&a.set("after",r.after),r?.order&&a.set("order",r.order);const l=a.toString(),c=`/v1/conversations/${n}/items${l?`?${l}`:""}`;return this.request(c)}async getConversationItem(n,r){const a=`/v1/conversations/${n}/items/${r}`;return this.request(a)}async deleteConversationItem(n,r){const a=await fetch(`${this.baseUrl}/v1/conversations/${n}/items/${r}`,{method:"DELETE"});if(!a.ok)throw new Error(`Failed to delete item: ${a.statusText}`)}async*streamOpenAIResponse(n,r,a,l){const{oaiMode:c}=await _u(()=>Promise.resolve().then(()=>wu),void 0,import.meta.url).then(x=>({oaiMode:x.useDevUIStore.getState().oaiMode}));c.enabled&&(n.model=c.model,c.temperature!==void 0&&(n.temperature=c.temperature),c.max_output_tokens!==void 0&&(n.max_output_tokens=c.max_output_tokens),c.top_p!==void 0&&(n.top_p=c.top_p),c.instructions!==void 0&&(n.instructions=c.instructions),c.reasoning_effort!==void 0&&(n.reasoning={effort:c.reasoning_effort}));let d=-1,f=0,m=!1,h=l,g;if(r){const x=ba(r);if(x)if(l||(h=x.responseId),d=x.lastSequenceNumber,g=x.lastMessageId,l)m=x.events.length>0;else for(const y of x.events)m=!0,yield y}for(;f<=Sh;)try{let x;if(h){const N=new URLSearchParams;N.set("stream","true"),d>=0&&N.set("starting_after",d.toString());const S=`${this.baseUrl}/v1/responses/${h}?${N.toString()}`,_={Accept:"text/event-stream"};this.authToken&&(_.Authorization=`Bearer ${this.authToken}`),x=await fetch(S,{method:"GET",headers:_,signal:a})}else{const N=`${this.baseUrl}/v1/responses`,S={"Content-Type":"application/json",Accept:"text/event-stream"};c.enabled&&(S["X-Proxy-Backend"]="openai"),this.authToken&&(S.Authorization=`Bearer ${this.authToken}`),x=await fetch(N,{method:"POST",headers:S,body:JSON.stringify(n),signal:a})}if(!x.ok){if(x.status===401)throw this.clearAuthToken(),new Error("UNAUTHORIZED");if(x.status>=400&&x.status<500){let S=`Client error ${x.status}`;try{const _=await x.json();_.error&&_.error.message?S=_.error.message:_.detail&&(S=_.detail)}catch{}throw new Error(`CLIENT_ERROR: ${S}`)}let N=`Request failed with status ${x.status}`;try{const S=await x.json();S.error&&S.error.message?N=S.error.message:S.detail&&(N=S.detail)}catch{}throw new Error(N)}const y=x.body?.getReader();if(!y)throw new Error("Response body is not readable");const b=new TextDecoder;let j="";try{for(;;){if(a?.aborted)throw new DOMException("Request aborted","AbortError");const{done:N,value:S}=await y.read();if(N){r&&jh(r);return}const _=b.decode(S,{stream:!0});j+=_;const A=j.split(` `);j=A.pop()||"";for(const E of A)if(E.startsWith("data: ")){const M=E.slice(6);if(M==="[DONE]"){r&&jh(r);return}try{const T=JSON.parse(M);if("response"in T&&T.response&&typeof T.response=="object"&&"id"in T.response){const z=T.response.id;(!h||h!==z)&&(h=z)}else if("id"in T&&typeof T.id=="string"&&T.id.startsWith("resp_")){const z=T.id;(!h||h!==z)&&(h=z)}"item_id"in T&&T.item_id&&(g=T.item_id);const D="sequence_number"in T?T.sequence_number:void 0;if(D!==void 0)if(m&&D<=1&&d>1)r&&Eu(r),yield{type:"error",message:"Connection lost - previous response failed. Starting new response."},d=D,m=!0,r&&h&&Nh(r,T,h,g),yield T;else{if(D<=d)continue;d=D,m=!0,r&&h&&Nh(r,T,h,g),yield T}else m=!0,r&&h&&Nh(r,T,h,g),yield T}catch(T){console.error("Failed to parse OpenAI SSE event:",T)}}}}finally{y.releaseLock()}}catch(x){const y=x instanceof Error?x.message:String(x);if(Gu(x))throw r&&jh(r),x;if(y==="UNAUTHORIZED"||y.startsWith("CLIENT_ERROR:"))throw x;if(f++,f>Sh)throw new Error(`Connection failed after ${Sh} retry attempts: ${y}`);const b=Math.min(WR*Math.pow(2,f-1),3e4);await QR(b)}}async*streamAgentExecutionOpenAI(n,r,a,l){const c={metadata:{entity_id:n},input:r.input,stream:!0,conversation:r.conversation_id};return yield*this.streamAgentExecutionOpenAIDirect(n,c,r.conversation_id,a,l)}async*streamAgentExecutionOpenAIDirect(n,r,a,l,c){yield*this.streamOpenAIResponse(r,a,l,c)}async*streamWorkflowExecutionOpenAI(n,r,a){const l={metadata:{entity_id:n},input:JSON.stringify(r.input_data||{}),stream:!0,conversation:r.conversation_id,extra_body:r.checkpoint_id?{entity_id:n,checkpoint_id:r.checkpoint_id}:void 0};yield*this.streamOpenAIResponse(l,r.conversation_id,a)}async runAgentSync(n,r){const{oaiMode:a}=await _u(()=>Promise.resolve().then(()=>wu),void 0,import.meta.url).then(d=>({oaiMode:d.useDevUIStore.getState().oaiMode})),l={metadata:{entity_id:n},input:r.input,stream:!1,conversation:r.conversation_id};a.enabled&&(l.model=a.model,a.temperature!==void 0&&(l.temperature=a.temperature),a.max_output_tokens!==void 0&&(l.max_output_tokens=a.max_output_tokens));const c={};return a.enabled&&(c["X-Proxy-Backend"]="openai"),this.request("/v1/responses",{method:"POST",headers:c,body:JSON.stringify(l)})}async runWorkflowSync(n,r){const a={metadata:{entity_id:n},input:JSON.stringify(r.input_data||{}),stream:!1,conversation:r.conversation_id,extra_body:r.checkpoint_id?{entity_id:n,checkpoint_id:r.checkpoint_id}:void 0};return this.request("/v1/responses",{method:"POST",body:JSON.stringify(a)})}clearStreamingState(n){Eu(n)}async*streamDeployment(n){const r=await fetch(`${this.baseUrl}/v1/deployments`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({...n,stream:!0})});if(!r.ok)throw new Error(`Deployment failed: ${r.statusText}`);const a=r.body?.getReader();if(!a)throw new Error("No response body");const l=new TextDecoder;let c="";try{for(;;){const{done:d,value:f}=await a.read();if(d)break;c+=l.decode(f,{stream:!0});const m=c.split(` `);c=m.pop()||"";for(const h of m)if(h.startsWith("data: ")){const g=h.slice(6);if(g==="[DONE]")return;try{yield JSON.parse(g)}catch(x){yield{type:"deploy.error",message:`Failed to parse deployment event: ${x instanceof Error?x.message:"Unknown error"}`}}}}}catch(d){throw yield{type:"deploy.failed",message:`Stream interrupted: ${d instanceof Error?d.message:"Unknown error"}`},d}finally{a.releaseLock()}}async listWorkflowSessions(n){const r=`/v1/conversations?entity_id=${encodeURIComponent(n)}&type=workflow_session`;return{data:(await this.request(r)).data.map(c=>({conversation_id:c.id,entity_id:c.metadata?.entity_id||n,created_at:c.created_at,metadata:{name:c.metadata?.name||`Session ${new Date(c.created_at*1e3).toLocaleString()}`,description:c.metadata?.description,type:"workflow_session",checkpoint_summary:c.metadata?.checkpoint_summary}}))}}async createWorkflowSession(n,r){const a={entity_id:n,type:"workflow_session",name:r?.name||`Session ${new Date().toLocaleString()}`,...r?.description&&{description:r.description}},l=await this.createConversation(a);return{conversation_id:l.id,entity_id:n,created_at:l.created_at,metadata:{name:a.name,description:a.description,type:"workflow_session"}}}async deleteWorkflowSession(n,r){if(!await this.deleteConversation(r))throw new Error("Failed to delete workflow session")}}const Ze=new JR;function eD({open:e,onClose:n,agentName:r="Agent",entity:a}){const c=le(C=>C.azureDeploymentEnabled)&&(a?.deployment_supported??!1),[d,f]=w.useState(c?"azure":"docker"),[m,h]=w.useState(null),g=w.useRef(null),x=w.useRef(null),y=le(C=>C.isDeploying),b=le(C=>C.deploymentLogs),j=le(C=>C.lastDeployment),N=le(C=>C.startDeployment),S=le(C=>C.addDeploymentLog),_=le(C=>C.setDeploymentResult),A=le(C=>C.stopDeployment),E=le(C=>C.clearDeploymentState),M=C=>{const $=C.toLowerCase().replace(/[_\s]+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/--+/g,"-").replace(/^[^a-z]+/,"").replace(/-$/,"");return($.match(/^[a-z]/)?$:`app-${$}`).substring(0,31)},T=a?M(a.id):"",[D,z]=w.useState("my-test-rg"),[H,q]=w.useState(T),[X,W]=w.useState("eastus"),[G,ne]=w.useState(null);w.useEffect(()=>{if(a){const C=M(a.id);q(C);const $=B(C);ne($)}},[a?.id]),w.useEffect(()=>{x.current&&b.length>0&&(x.current.scrollTop=x.current.scrollHeight)},[b]);const B=C=>C?C.length>=32?"App name must be less than 32 characters":/^[a-z0-9-]+$/.test(C)?/^[a-z]/.test(C)?/[a-z0-9]$/.test(C)?C.includes("--")?"App name cannot contain consecutive hyphens (--)":null:"App name must end with a letter or number":"App name must start with a lowercase letter":"App name must contain only lowercase letters, numbers, and hyphens (no underscores or uppercase)":null;w.useEffect(()=>()=>{g.current&&clearTimeout(g.current)},[]);const U=async()=>{if(!a?.id||!D||!H)return;const C=D.trim(),$=H.trim(),Y=B($);if(Y){ne(Y);return}try{N();for await(const V of Ze.streamDeployment({entity_id:a.id,resource_group:C,app_name:$,region:X,ui_mode:"user"}))S(V.message),V.type==="deploy.completed"&&V.url&&V.auth_token?_({url:V.url,authToken:V.auth_token}):V.type==="deploy.failed"&&A()}catch(V){S(`Error: ${V instanceof Error?V.message:"Deployment failed"}`),A()}},R=async(C,$)=>{try{await navigator.clipboard.writeText(C),h($),g.current&&clearTimeout(g.current),g.current=setTimeout(()=>{h(null),g.current=null},2e3)}catch{h(null)}},L=`# Dockerfile for ${r} FROM python:3.11-slim @@ -516,7 +516,7 @@ az acr build --registry myregistry \\ --registry-server myregistry.azurecr.io \\ --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL_ID=gpt-4o-mini`})]}),o.jsxs("div",{className:"border-l-2 border-primary pl-3",children:[o.jsxs("div",{className:"flex items-center gap-2 mb-1",children:[o.jsx("div",{className:"w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold",children:"5"}),o.jsx("h5",{className:"font-medium text-sm",children:"Get Application URL"})]}),o.jsx("pre",{className:"bg-muted p-2 rounded text-xs overflow-x-auto border mt-2",children:`az containerapp show --name ${r.toLowerCase()}-app \\ --resource-group myResourceGroup \\ - --query properties.configuration.ingress.fqdn`})]})]})]}),o.jsxs("div",{className:"bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3",children:[o.jsx("h4",{className:"text-sm font-semibold mb-2",children:"Learn More"}),o.jsx("p",{className:"text-xs text-muted-foreground mb-3",children:"Explore Azure Container Apps documentation for advanced features like scaling, monitoring, and CI/CD integration."}),o.jsx(Le,{size:"sm",variant:"outline",className:"w-full",asChild:!0,children:o.jsxs("a",{href:"https://learn.microsoft.com/azure/container-apps/",target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Hu,{className:"h-3 w-3 mr-1"}),"View Azure Container Apps Documentation"]})})]})]})]})]})})})]})})}function tD({className:e,...n}){return o.jsx("div",{"data-slot":"card",className:We("bg-card text-card-foreground flex flex-col gap-6 rounded border py-6 shadow-sm",e),...n})}function nD({className:e,...n}){return o.jsx("div",{"data-slot":"card-header",className:We("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",e),...n})}function N2({className:e,...n}){return o.jsx("div",{"data-slot":"card-title",className:We("leading-none font-semibold",e),...n})}function sD({className:e,...n}){return o.jsx("div",{"data-slot":"card-description",className:We("text-muted-foreground text-sm",e),...n})}function rD({className:e,...n}){return o.jsx("div",{"data-slot":"card-content",className:We("px-6",e),...n})}function oD({className:e,...n}){return o.jsx("div",{"data-slot":"card-footer",className:We("flex items-center px-6 [.border-t]:pt-6",e),...n})}const Cr=[{id:"foundry-weather-agent",name:"Azure AI Weather Agent",description:"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/foundry_agent/agent.py",tags:["azure-ai","foundry","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure AI Agent integration","Azure CLI authentication","Mock weather tools"],requiredEnvVars:[{name:"AZURE_AI_PROJECT_ENDPOINT",description:"Azure AI Foundry project endpoint URL",required:!0,example:"https://your-project.api.azureml.ms"},{name:"FOUNDRY_MODEL_DEPLOYMENT_NAME",description:"Name of the deployed model in Azure AI Foundry",required:!0,example:"gpt-4o"}]},{id:"weather-agent-azure",name:"Azure OpenAI Weather Agent",description:"Weather agent using Azure OpenAI with API key authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/weather_agent_azure/agent.py",tags:["azure","openai","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure OpenAI integration","API key authentication","Function calling","Mock weather tools"],requiredEnvVars:[{name:"AZURE_OPENAI_API_KEY",description:"Azure OpenAI API key",required:!0},{name:"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME",description:"Name of the deployed model in Azure OpenAI",required:!0,example:"gpt-4o"},{name:"AZURE_OPENAI_ENDPOINT",description:"Azure OpenAI endpoint URL",required:!0,example:"https://your-resource.openai.azure.com"}]},{id:"spam-workflow",name:"Spam Detection Workflow",description:"5-step workflow demonstrating email spam detection with branching logic",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/spam_workflow/workflow.py",tags:["workflow","branching","multi-step"],author:"Microsoft",difficulty:"beginner",features:["Sequential execution","Conditional branching","Mock spam detection"]},{id:"fanout-workflow",name:"Complex Fan-In/Fan-Out Workflow",description:"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/fanout_workflow/workflow.py",tags:["workflow","fan-out","fan-in","parallel"],author:"Microsoft",difficulty:"advanced",features:["Fan-out pattern","Parallel execution","Complex state management","Multi-stage processing"]}];Cr.filter(e=>e.type==="agent"),Cr.filter(e=>e.type==="workflow"),Cr.filter(e=>e.difficulty==="beginner"),Cr.filter(e=>e.difficulty==="intermediate"),Cr.filter(e=>e.difficulty==="advanced");const aD=e=>{switch(e){case"beginner":return"bg-green-100 text-green-700 border-green-200";case"intermediate":return"bg-yellow-100 text-yellow-700 border-yellow-200";case"advanced":return"bg-red-100 text-red-700 border-red-200";default:return"bg-gray-100 text-gray-700 border-gray-200"}},j2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,role:"alert",className:We("relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",e),...n}));j2.displayName="Alert";const S2=w.forwardRef(({className:e,...n},r)=>o.jsx("h5",{ref:r,className:We("mb-1 font-medium leading-none tracking-tight",e),...n}));S2.displayName="AlertTitle";const _2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,className:We("text-sm [&_p]:leading-relaxed",e),...n}));_2.displayName="AlertDescription";function E2({children:e,copyable:n=!1}){const[r,a]=w.useState(!1),l=()=>{navigator.clipboard.writeText(e),a(!0),setTimeout(()=>a(!1),2e3)};return o.jsxs("div",{className:"relative",children:[o.jsx("pre",{className:"bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono",children:o.jsx("code",{children:e})}),n&&o.jsx(Le,{variant:"ghost",size:"sm",className:"absolute top-2 right-2 h-6 w-6 p-0",onClick:l,children:r?o.jsx(jo,{className:"h-3 w-3"}):o.jsx(uo,{className:"h-3 w-3"})})]})}function iu({number:e,title:n,description:r,code:a,action:l,copyable:c=!1}){return o.jsxs("div",{className:"flex gap-4",children:[o.jsx("div",{className:"flex-shrink-0",children:o.jsx("div",{className:"flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold",children:e})}),o.jsxs("div",{className:"flex-1 space-y-2",children:[o.jsx("h4",{className:"font-semibold",children:n}),r&&o.jsx("p",{className:"text-sm text-muted-foreground",children:r}),a&&o.jsx(E2,{copyable:c,children:a}),l&&o.jsx("div",{children:l})]})]})}function iD({sample:e,open:n,onOpenChange:r}){const a=e.requiredEnvVars&&e.requiredEnvVars.length>0,l=a?0:-1;return o.jsx(Ir,{open:n,onOpenChange:r,children:o.jsxs(Lr,{className:"max-w-3xl",children:[o.jsxs($r,{className:"px-6 pt-6 pb-2",children:[o.jsxs(Pr,{children:["Setup: ",e.name]}),o.jsxs(OR,{children:["Follow these steps to run this sample ",e.type," locally"]})]}),o.jsx("div",{className:"px-6 pb-6",children:o.jsx(Wn,{className:"h-[500px]",children:o.jsxs("div",{className:"space-y-6 pr-4",children:[o.jsx(iu,{number:1,title:"Download the sample file",action:o.jsx(Le,{asChild:!0,size:"sm",children:o.jsxs("a",{href:e.url,download:`${e.id}.py`,target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Pu,{className:"h-4 w-4 mr-2"}),"Download ",e.id,".py"]})})}),o.jsx(iu,{number:2,title:"Create a project folder",description:"Create a dedicated folder for this sample and move the downloaded file there:",code:`mkdir -p ~/my-agents/${e.id} + --query properties.configuration.ingress.fqdn`})]})]})]}),o.jsxs("div",{className:"bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3",children:[o.jsx("h4",{className:"text-sm font-semibold mb-2",children:"Learn More"}),o.jsx("p",{className:"text-xs text-muted-foreground mb-3",children:"Explore Azure Container Apps documentation for advanced features like scaling, monitoring, and CI/CD integration."}),o.jsx(Le,{size:"sm",variant:"outline",className:"w-full",asChild:!0,children:o.jsxs("a",{href:"https://learn.microsoft.com/azure/container-apps/",target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Hu,{className:"h-3 w-3 mr-1"}),"View Azure Container Apps Documentation"]})})]})]})]})]})})})]})})}function tD({className:e,...n}){return o.jsx("div",{"data-slot":"card",className:We("bg-card text-card-foreground flex flex-col gap-6 rounded border py-6 shadow-sm",e),...n})}function nD({className:e,...n}){return o.jsx("div",{"data-slot":"card-header",className:We("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",e),...n})}function N2({className:e,...n}){return o.jsx("div",{"data-slot":"card-title",className:We("leading-none font-semibold",e),...n})}function sD({className:e,...n}){return o.jsx("div",{"data-slot":"card-description",className:We("text-muted-foreground text-sm",e),...n})}function rD({className:e,...n}){return o.jsx("div",{"data-slot":"card-content",className:We("px-6",e),...n})}function oD({className:e,...n}){return o.jsx("div",{"data-slot":"card-footer",className:We("flex items-center px-6 [.border-t]:pt-6",e),...n})}const Cr=[{id:"foundry-weather-agent",name:"Azure AI Weather Agent",description:"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/foundry_agent/agent.py",tags:["azure-ai","foundry","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure AI Agent integration","Azure CLI authentication","Mock weather tools"],requiredEnvVars:[{name:"AZURE_AI_PROJECT_ENDPOINT",description:"Azure AI Foundry project endpoint URL",required:!0,example:"https://your-project.api.azureml.ms"},{name:"FOUNDRY_MODEL_DEPLOYMENT_NAME",description:"Name of the deployed model in Azure AI Foundry",required:!0,example:"gpt-4o"}]},{id:"weather-agent-azure",name:"Azure OpenAI Weather Agent",description:"Weather agent using Azure OpenAI with API key authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/weather_agent_azure/agent.py",tags:["azure","openai","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure OpenAI integration","API key authentication","Function calling","Mock weather tools"],requiredEnvVars:[{name:"AZURE_OPENAI_API_KEY",description:"Azure OpenAI API key",required:!0},{name:"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME",description:"Name of the deployed model in Azure OpenAI",required:!0,example:"gpt-4o"},{name:"AZURE_OPENAI_ENDPOINT",description:"Azure OpenAI endpoint URL",required:!0,example:"https://your-resource.openai.azure.com"}]},{id:"spam-workflow",name:"Spam Detection Workflow",description:"5-step workflow demonstrating email spam detection with branching logic",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/spam_workflow/workflow.py",tags:["workflow","branching","multi-step"],author:"Microsoft",difficulty:"beginner",features:["Sequential execution","Conditional branching","Mock spam detection"]},{id:"fanout-workflow",name:"Complex Fan-In/Fan-Out Workflow",description:"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/fanout_workflow/workflow.py",tags:["workflow","fan-out","fan-in","parallel"],author:"Microsoft",difficulty:"advanced",features:["Fan-out pattern","Parallel execution","Complex state management","Multi-stage processing"]}];Cr.filter(e=>e.type==="agent"),Cr.filter(e=>e.type==="workflow"),Cr.filter(e=>e.difficulty==="beginner"),Cr.filter(e=>e.difficulty==="intermediate"),Cr.filter(e=>e.difficulty==="advanced");const aD=e=>{switch(e){case"beginner":return"bg-green-100 text-green-700 border-green-200";case"intermediate":return"bg-yellow-100 text-yellow-700 border-yellow-200";case"advanced":return"bg-red-100 text-red-700 border-red-200";default:return"bg-gray-100 text-gray-700 border-gray-200"}},j2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,role:"alert",className:We("relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",e),...n}));j2.displayName="Alert";const S2=w.forwardRef(({className:e,...n},r)=>o.jsx("h5",{ref:r,className:We("mb-1 font-medium leading-none tracking-tight",e),...n}));S2.displayName="AlertTitle";const _2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,className:We("text-sm [&_p]:leading-relaxed",e),...n}));_2.displayName="AlertDescription";function E2({children:e,copyable:n=!1}){const[r,a]=w.useState(!1),l=()=>{navigator.clipboard.writeText(e),a(!0),setTimeout(()=>a(!1),2e3)};return o.jsxs("div",{className:"relative",children:[o.jsx("pre",{className:"bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono",children:o.jsx("code",{children:e})}),n&&o.jsx(Le,{variant:"ghost",size:"sm",className:"absolute top-2 right-2 h-6 w-6 p-0",onClick:l,children:r?o.jsx(jo,{className:"h-3 w-3"}):o.jsx(uo,{className:"h-3 w-3"})})]})}function iu({number:e,title:n,description:r,code:a,action:l,copyable:c=!1}){return o.jsxs("div",{className:"flex gap-4",children:[o.jsx("div",{className:"flex-shrink-0",children:o.jsx("div",{className:"flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold",children:e})}),o.jsxs("div",{className:"flex-1 space-y-2",children:[o.jsx("h4",{className:"font-semibold",children:n}),r&&o.jsx("p",{className:"text-sm text-muted-foreground",children:r}),a&&o.jsx(E2,{copyable:c,children:a}),l&&o.jsx("div",{children:l})]})]})}function iD({sample:e,open:n,onOpenChange:r}){const a=e.requiredEnvVars&&e.requiredEnvVars.length>0,l=a?0:-1;return o.jsx(Ir,{open:n,onOpenChange:r,children:o.jsxs(Lr,{className:"max-w-3xl",children:[o.jsxs($r,{className:"px-6 pt-6 pb-2",children:[o.jsxs(Pr,{children:["Setup: ",e.name]}),o.jsxs(OR,{children:["Follow these steps to run this sample ",e.type," locally"]})]}),o.jsx("div",{className:"px-6 pb-6",children:o.jsx(Wn,{className:"h-[500px]",children:o.jsxs("div",{className:"space-y-6 pr-4",children:[o.jsx(iu,{number:1,title:"Download the sample file",action:o.jsx(Le,{asChild:!0,size:"sm",children:o.jsxs("a",{href:e.url,download:`${e.id}.py`,target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Pu,{className:"h-4 w-4 mr-2"}),"Download ",e.id,".py"]})})}),o.jsx(iu,{number:2,title:"Create a project folder",description:"Create a dedicated folder for this sample and move the downloaded file there:",code:`mkdir -p ~/my-agents/${e.id} mv ~/Downloads/${e.id}.py ~/my-agents/${e.id}/`,copyable:!0}),a&&o.jsx(iu,{number:3,title:"Set up environment variables",description:"Create a .env file in the project folder with these required variables:",code:e.requiredEnvVars.map(c=>`${c.name}=${c.example||"your-value-here"} # ${c.description}`).join(` diff --git a/python/packages/devui/dev.md b/python/packages/devui/dev.md index 838e3536fc..5a4166112d 100644 --- a/python/packages/devui/dev.md +++ b/python/packages/devui/dev.md @@ -45,7 +45,7 @@ AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="your-deployment-name" **Option A: In-Memory Mode (Recommended for quick testing)** ```bash -cd samples/getting_started/devui +cd samples/02-agents/devui python in_memory_mode.py ``` @@ -54,7 +54,7 @@ This runs a simple example with predefined agents and opens your browser automat **Option B: Directory-Based Discovery** ```bash -cd samples/getting_started/devui +cd samples/02-agents/devui devui ``` diff --git a/python/packages/devui/frontend/src/components/features/workflow/run-workflow-button.tsx b/python/packages/devui/frontend/src/components/features/workflow/run-workflow-button.tsx index d4d1bc4887..9cddab5212 100644 --- a/python/packages/devui/frontend/src/components/features/workflow/run-workflow-button.tsx +++ b/python/packages/devui/frontend/src/components/features/workflow/run-workflow-button.tsx @@ -76,7 +76,7 @@ export function RunWorkflowButton({ // Analyze input requirements const inputAnalysis = useMemo(() => { - // Check if this is a ChatMessage schema (for AgentExecutor workflows) + // Check if this is a Message schema (for AgentExecutor workflows) const isChatMessage = isChatMessageSchema(inputSchema); if (!inputSchema) diff --git a/python/packages/devui/frontend/src/components/features/workflow/schema-form-renderer.tsx b/python/packages/devui/frontend/src/components/features/workflow/schema-form-renderer.tsx index 9abf6a1074..b37761ba5d 100644 --- a/python/packages/devui/frontend/src/components/features/workflow/schema-form-renderer.tsx +++ b/python/packages/devui/frontend/src/components/features/workflow/schema-form-renderer.tsx @@ -115,7 +115,7 @@ export function getFieldColumnSpan( } // ============================================================================ -// ChatMessage Pattern Detection (exported for reuse) +// Message Pattern Detection (exported for reuse) // ============================================================================ export function detectChatMessagePattern( @@ -436,7 +436,7 @@ export function SchemaFormRenderer({ (name) => !hideFields.includes(name) ); - // Detect ChatMessage pattern + // Detect Message pattern const isChatMessageLike = detectChatMessagePattern(schema, requiredFields); // Separate required and optional fields @@ -449,7 +449,7 @@ export function SchemaFormRenderer({ (name) => !requiredFields.includes(name) ); - // For ChatMessage: prioritize text/message/content + // For Message: prioritize text/message/content const sortedOptionalFields = isChatMessageLike ? [...optionalFieldNames].sort((a, b) => { const priority = (name: string) => diff --git a/python/packages/devui/frontend/src/components/features/workflow/workflow-input-form.tsx b/python/packages/devui/frontend/src/components/features/workflow/workflow-input-form.tsx index 367e1d5deb..bcb0a940e9 100644 --- a/python/packages/devui/frontend/src/components/features/workflow/workflow-input-form.tsx +++ b/python/packages/devui/frontend/src/components/features/workflow/workflow-input-form.tsx @@ -48,7 +48,7 @@ export function WorkflowInputForm({ const requiredFields = inputSchema.required || []; const isSimpleInput = inputSchema.type === "string" && !inputSchema.enum; - // Detect ChatMessage-like pattern for auto-filling role + // Detect Message-like pattern for auto-filling role const isChatMessageLike = detectChatMessagePattern(inputSchema, requiredFields); // Validation: check if required fields are filled @@ -82,7 +82,7 @@ export function WorkflowInputForm({ } }); - // Auto-fill role="user" for ChatMessage-like inputs + // Auto-fill role="user" for Message-like inputs if (isChatMessageLike && !initialData["role"]) { initialData["role"] = "user"; } diff --git a/python/packages/devui/frontend/src/components/layout/debug-panel.tsx b/python/packages/devui/frontend/src/components/layout/debug-panel.tsx index 828e03666f..19797ac74b 100644 --- a/python/packages/devui/frontend/src/components/layout/debug-panel.tsx +++ b/python/packages/devui/frontend/src/components/layout/debug-panel.tsx @@ -1340,7 +1340,7 @@ function TraceTreeNode({ node, depth = 0 }: { node: TraceNode; depth?: number }) {/* Operation badge */} - {operationName.replace("ChatAgent.", "").replace("invoke_agent ", "")} + {operationName.replace("Agent.", "").replace("invoke_agent ", "")} {/* Duration */} diff --git a/python/packages/devui/frontend/src/data/gallery/sample-entities.ts b/python/packages/devui/frontend/src/data/gallery/sample-entities.ts index 1878d21dae..64211ddfcb 100644 --- a/python/packages/devui/frontend/src/data/gallery/sample-entities.ts +++ b/python/packages/devui/frontend/src/data/gallery/sample-entities.ts @@ -30,7 +30,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [ description: "Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication", type: "agent", - url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/foundry_agent/agent.py", + url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/foundry_agent/agent.py", tags: ["azure-ai", "foundry", "tools"], author: "Microsoft", difficulty: "beginner", @@ -61,7 +61,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [ description: "Weather agent using Azure OpenAI with API key authentication", type: "agent", - url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/weather_agent_azure/agent.py", + url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/weather_agent_azure/agent.py", tags: ["azure", "openai", "tools"], author: "Microsoft", difficulty: "beginner", @@ -99,7 +99,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [ description: "5-step workflow demonstrating email spam detection with branching logic", type: "workflow", - url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/spam_workflow/workflow.py", + url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/spam_workflow/workflow.py", tags: ["workflow", "branching", "multi-step"], author: "Microsoft", difficulty: "beginner", @@ -117,7 +117,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [ description: "Advanced data processing workflow with parallel validation, transformation, and quality assurance stages", type: "workflow", - url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/fanout_workflow/workflow.py", + url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/fanout_workflow/workflow.py", tags: ["workflow", "fan-out", "fan-in", "parallel"], author: "Microsoft", difficulty: "advanced", diff --git a/python/packages/devui/frontend/src/types/agent-framework.ts b/python/packages/devui/frontend/src/types/agent-framework.ts index 5e26580d5f..5a63b8d914 100644 --- a/python/packages/devui/frontend/src/types/agent-framework.ts +++ b/python/packages/devui/frontend/src/types/agent-framework.ts @@ -223,7 +223,7 @@ export interface AgentResponseUpdate { // Agent run response (final) export interface AgentResponse { - messages: ChatMessage[]; + messages: Message[]; response_id?: string; created_at?: CreatedAtT; usage_details?: UsageDetails; @@ -232,7 +232,7 @@ export interface AgentResponse { } // Chat message -export interface ChatMessage { +export interface Message { contents: Content[]; role?: Role; author_name?: string; diff --git a/python/packages/devui/frontend/src/types/index.ts b/python/packages/devui/frontend/src/types/index.ts index 7d6e9a8f73..dc79cc43d4 100644 --- a/python/packages/devui/frontend/src/types/index.ts +++ b/python/packages/devui/frontend/src/types/index.ts @@ -185,7 +185,7 @@ export interface MetaResponse { } // Chat message types matching Agent Framework -export interface ChatMessage { +export interface Message { id: string; role: "user" | "assistant" | "system" | "tool"; contents: import("./agent-framework").Content[]; @@ -212,7 +212,7 @@ export interface AppState { } export interface ChatState { - messages: ChatMessage[]; + messages: Message[]; isStreaming: boolean; // streamEvents removed - use OpenAI events directly instead } diff --git a/python/packages/devui/frontend/src/utils/workflow-utils.ts b/python/packages/devui/frontend/src/utils/workflow-utils.ts index adda06e2d2..6d8c45c019 100644 --- a/python/packages/devui/frontend/src/utils/workflow-utils.ts +++ b/python/packages/devui/frontend/src/utils/workflow-utils.ts @@ -15,8 +15,8 @@ import type { Workflow } from "@/types/workflow"; import { getTypedWorkflow } from "@/types/workflow"; /** - * Detects if a JSON schema represents a ChatMessage input type. - * ChatMessage schemas typically have: + * Detects if a JSON schema represents a Message input type. + * Message schemas typically have: * - type: "object" * - properties with "text" (required string) and "role" (optional string) * @@ -24,7 +24,7 @@ import { getTypedWorkflow } from "@/types/workflow"; * component for workflows that start with an AgentExecutor. * * @param schema - The JSON schema to check - * @returns true if the schema represents a ChatMessage-like input + * @returns true if the schema represents a Message-like input */ export function isChatMessageSchema(schema: JSONSchemaProperty | undefined): boolean { if (!schema) return false; @@ -37,13 +37,13 @@ export function isChatMessageSchema(schema: JSONSchemaProperty | undefined): boo const props = schema.properties; - // ChatMessage has "text" property (the main content) + // Message has "text" property (the main content) const hasText = "text" in props && props.text?.type === "string"; - // ChatMessage has "role" property (user, assistant, system) + // Message has "role" property (user, assistant, system) const hasRole = "role" in props && props.role?.type === "string"; - // If it has both text and role, it's likely a ChatMessage + // If it has both text and role, it's likely a Message if (hasText && hasRole) { return true; } diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index 6dbdd27f3c..bd6bf2f724 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://github.com/microsoft/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "fastapi>=0.104.0", "uvicorn[standard]>=0.24.0", "python-dotenv>=1.0.0", diff --git a/python/packages/devui/samples/README.md b/python/packages/devui/samples/README.md index 6ed374bcb5..d4ad6f6b83 100644 --- a/python/packages/devui/samples/README.md +++ b/python/packages/devui/samples/README.md @@ -7,7 +7,7 @@ All DevUI samples are now located at: ``` -python/samples/getting_started/devui/ +python/samples/02-agents/devui/ ``` ## Available Samples @@ -22,17 +22,17 @@ python/samples/getting_started/devui/ ## Quick Start ```bash -cd ../../samples/getting_started/devui +cd ../../samples/02-agents/devui python in_memory_mode.py ``` Or for directory discovery: ```bash -cd ../../samples/getting_started/devui +cd ../../samples/02-agents/devui devui ``` ## Learn More -See the [DevUI samples README](../../../samples/getting_started/devui/README.md) for detailed documentation. +See the [DevUI samples README](../../../samples/02-agents/devui/README.md) for detailed documentation. diff --git a/python/packages/devui/tests/devui/capture_messages.py b/python/packages/devui/tests/devui/capture_messages.py index 8026303c20..820fedde57 100644 --- a/python/packages/devui/tests/devui/capture_messages.py +++ b/python/packages/devui/tests/devui/capture_messages.py @@ -28,8 +28,8 @@ def start_server() -> tuple[str, Any]: """Start server with samples directory.""" # Get samples directory - updated path after samples were moved current_dir = Path(__file__).parent - # Samples are now in python/samples/getting_started/devui - samples_dir = current_dir.parent.parent.parent / "samples" / "getting_started" / "devui" + # Samples are now in python/samples/02-agents/devui + samples_dir = current_dir.parent.parent.parent / "samples" / "02-agents" / "devui" if not samples_dir.exists(): raise RuntimeError(f"Samples directory not found: {samples_dir}") diff --git a/python/packages/devui/tests/devui/conftest.py b/python/packages/devui/tests/devui/conftest.py index b229b0e9e6..3ff5f499a7 100644 --- a/python/packages/devui/tests/devui/conftest.py +++ b/python/packages/devui/tests/devui/conftest.py @@ -17,19 +17,19 @@ from typing import Any, Generic import pytest import pytest_asyncio from agent_framework import ( + Agent, AgentResponse, AgentResponseUpdate, - AgentThread, + AgentSession, BaseAgent, BaseChatClient, - ChatAgent, - ChatMessage, ChatResponse, ChatResponseUpdate, Content, + Message, ResponseStream, ) -from agent_framework._clients import TOptions_co +from agent_framework._clients import OptionsCoT from agent_framework._workflows._agent_executor import AgentExecutorResponse from agent_framework._workflows._events import ( WorkflowErrorDetails, @@ -67,17 +67,17 @@ class MockChatClient: async def get_response( self, - messages: str | ChatMessage | list[str] | list[ChatMessage], + messages: str | Message | list[str] | list[Message], **kwargs: Any, ) -> ChatResponse: self.call_count += 1 if self.responses: return self.responses.pop(0) - return ChatResponse(messages=ChatMessage("assistant", ["test response"])) + return ChatResponse(messages=Message("assistant", ["test response"])) async def get_streaming_response( self, - messages: str | ChatMessage | list[str] | list[ChatMessage], + messages: str | Message | list[str] | list[Message], **kwargs: Any, ) -> AsyncIterable[ChatResponseUpdate]: self.call_count += 1 @@ -88,7 +88,7 @@ class MockChatClient: yield ChatResponseUpdate(contents=[Content.from_text(text="test streaming response")], role="assistant") -class MockBaseChatClient(BaseChatClient[TOptions_co], Generic[TOptions_co]): +class MockBaseChatClient(BaseChatClient[OptionsCoT], Generic[OptionsCoT]): """Full BaseChatClient mock with middleware support. Use this when testing features that require the full BaseChatClient interface. @@ -101,13 +101,13 @@ class MockBaseChatClient(BaseChatClient[TOptions_co], Generic[TOptions_co]): self.run_responses: list[ChatResponse] = [] self.streaming_responses: list[list[ChatResponseUpdate]] = [] self.call_count: int = 0 - self.received_messages: list[list[ChatMessage]] = [] + self.received_messages: list[list[Message]] = [] @override def _inner_get_response( self, *, - messages: Sequence[ChatMessage], + messages: Sequence[Message], stream: bool, options: Mapping[str, Any], **kwargs: Any, @@ -120,11 +120,11 @@ class MockBaseChatClient(BaseChatClient[TOptions_co], Generic[TOptions_co]): self.received_messages.append(list(messages)) if self.run_responses: return self.run_responses.pop(0) - return ChatResponse(messages=ChatMessage("assistant", ["Mock response from ChatAgent"])) + return ChatResponse(messages=Message("assistant", ["Mock response from Agent"])) return _get() - async def _stream_impl(self, messages: Sequence[ChatMessage]) -> AsyncIterable[ChatResponseUpdate]: + async def _stream_impl(self, messages: Sequence[Message]) -> AsyncIterable[ChatResponseUpdate]: self.call_count += 1 self.received_messages.append(list(messages)) if self.streaming_responses: @@ -135,7 +135,7 @@ class MockBaseChatClient(BaseChatClient[TOptions_co], Generic[TOptions_co]): yield ChatResponseUpdate(contents=[Content.from_text(text="Mock ")], role="assistant") yield ChatResponseUpdate(contents=[Content.from_text(text="streaming ")], role="assistant") yield ChatResponseUpdate(contents=[Content.from_text(text="response ")], role="assistant") - yield ChatResponseUpdate(contents=[Content.from_text(text="from ChatAgent")], role="assistant") + yield ChatResponseUpdate(contents=[Content.from_text(text="from Agent")], role="assistant") # ============================================================================= @@ -159,32 +159,32 @@ class MockAgent(BaseAgent): def run( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: self.call_count += 1 if stream: - return self._run_stream(messages=messages, thread=thread, **kwargs) - return self._run(messages=messages, thread=thread, **kwargs) + return self._run_stream(messages=messages, session=session, **kwargs) + return self._run(messages=messages, session=session, **kwargs) async def _run( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> AgentResponse: self.call_count += 1 - return AgentResponse(messages=[ChatMessage("assistant", [Content.from_text(text=self.response_text)])]) + return AgentResponse(messages=[Message("assistant", [Content.from_text(text=self.response_text)])]) def _run_stream( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: self.call_count += 1 @@ -205,31 +205,31 @@ class MockToolCallingAgent(BaseAgent): def run( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: self.call_count += 1 if stream: - return self._run_stream(messages=messages, thread=thread, **kwargs) - return self._run(messages=messages, thread=thread, **kwargs) + return self._run_stream(messages=messages, session=session, **kwargs) + return self._run(messages=messages, session=session, **kwargs) async def _run( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> AgentResponse: - return AgentResponse(messages=[ChatMessage("assistant", ["done"])]) + return AgentResponse(messages=[Message("assistant", ["done"])]) def _run_stream( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: async def _iter() -> AsyncIterable[AgentResponseUpdate]: @@ -275,7 +275,7 @@ class MockToolCallingAgent(BaseAgent): def _create_agent_run_response(text: str = "Test response") -> AgentResponse: """Create an AgentResponse with the given text.""" - return AgentResponse(messages=[ChatMessage("assistant", [Content.from_text(text=text)])]) + return AgentResponse(messages=[Message("assistant", [Content.from_text(text=text)])]) def _create_agent_executor_response( @@ -289,8 +289,8 @@ def _create_agent_executor_response( executor_id=executor_id, agent_response=agent_response, full_conversation=[ - ChatMessage("user", [Content.from_text(text="User input")]), - ChatMessage("assistant", [Content.from_text(text=response_text)]), + Message("user", [Content.from_text(text="User input")]), + Message("assistant", [Content.from_text(text=response_text)]), ], ) @@ -318,7 +318,7 @@ def create_executor_completed_event( This creates the exact data structure that caused the serialization bug: WorkflowEvent.data contains AgentExecutorResponse which contains - AgentResponse and ChatMessage objects (SerializationMixin, not Pydantic). + AgentResponse and Message objects (SerializationMixin, not Pydantic). """ data = _create_agent_executor_response(executor_id) if with_agent_response else {"simple": "dict"} return WorkflowEvent.executor_completed(executor_id=executor_id, data=data) @@ -390,7 +390,7 @@ def executor_completed_event() -> WorkflowEvent[Any]: This creates the exact data structure that caused the serialization bug: executor_completed event (type='executor_completed').data contains AgentExecutorResponse which contains - AgentResponse and ChatMessage objects (SerializationMixin, not Pydantic). + AgentResponse and Message objects (SerializationMixin, not Pydantic). """ data = _create_agent_executor_response("test_executor") return WorkflowEvent.executor_completed(executor_id="test_executor", data=data) @@ -413,8 +413,8 @@ def executor_failed_event() -> WorkflowEvent[WorkflowErrorDetails]: def test_entities_dir() -> str: """Use the samples directory which has proper entity structure.""" current_dir = Path(__file__).parent - # Navigate to python/samples/getting_started/devui - samples_dir = current_dir.parent.parent.parent.parent / "samples" / "getting_started" / "devui" + # Navigate to python/samples/02-agents/devui + samples_dir = current_dir.parent.parent.parent.parent / "samples" / "02-agents" / "devui" return str(samples_dir.resolve()) @@ -425,10 +425,10 @@ def test_entities_dir() -> str: @pytest_asyncio.fixture async def executor_with_real_agent() -> tuple[AgentFrameworkExecutor, str, MockBaseChatClient]: - """Create an executor with a REAL ChatAgent using mock chat client. + """Create an executor with a REAL Agent using mock chat client. This tests the full execution pipeline: - - Real ChatAgent class + - Real Agent class - Real message handling and normalization - Real middleware pipeline - Only the LLM call is mocked @@ -440,12 +440,12 @@ async def executor_with_real_agent() -> tuple[AgentFrameworkExecutor, str, MockB mapper = MessageMapper() executor = AgentFrameworkExecutor(discovery, mapper) - # Create a REAL ChatAgent with mock client - agent = ChatAgent( + # Create a REAL Agent with mock client + agent = Agent( id="test_chat_agent", name="Test Chat Agent", - description="A real ChatAgent for testing execution flow", - chat_client=mock_client, + description="A real Agent for testing execution flow", + client=mock_client, system_message="You are a helpful test assistant.", ) @@ -469,22 +469,22 @@ async def sequential_workflow() -> tuple[AgentFrameworkExecutor, str, MockBaseCh """ mock_client = MockBaseChatClient() mock_client.run_responses = [ - ChatResponse(messages=ChatMessage("assistant", ["Here's the draft content about the topic."])), - ChatResponse(messages=ChatMessage("assistant", ["Review: Content is clear and well-structured."])), + ChatResponse(messages=Message("assistant", ["Here's the draft content about the topic."])), + ChatResponse(messages=Message("assistant", ["Review: Content is clear and well-structured."])), ] - writer = ChatAgent( + writer = Agent( id="writer", name="Writer", description="Content writer agent", - chat_client=mock_client, + client=mock_client, system_message="You are a content writer. Create clear, engaging content.", ) - reviewer = ChatAgent( + reviewer = Agent( id="reviewer", name="Reviewer", description="Content reviewer agent", - chat_client=mock_client, + client=mock_client, system_message="You are a reviewer. Provide constructive feedback.", ) @@ -513,30 +513,30 @@ async def concurrent_workflow() -> tuple[AgentFrameworkExecutor, str, MockBaseCh """ mock_client = MockBaseChatClient() mock_client.run_responses = [ - ChatResponse(messages=ChatMessage("assistant", ["Research findings: Key data points identified."])), - ChatResponse(messages=ChatMessage("assistant", ["Analysis: Trends indicate positive growth."])), - ChatResponse(messages=ChatMessage("assistant", ["Summary: Overall outlook is favorable."])), + ChatResponse(messages=Message("assistant", ["Research findings: Key data points identified."])), + ChatResponse(messages=Message("assistant", ["Analysis: Trends indicate positive growth."])), + ChatResponse(messages=Message("assistant", ["Summary: Overall outlook is favorable."])), ] - researcher = ChatAgent( + researcher = Agent( id="researcher", name="Researcher", description="Research agent", - chat_client=mock_client, + client=mock_client, system_message="You are a researcher. Find key data and insights.", ) - analyst = ChatAgent( + analyst = Agent( id="analyst", name="Analyst", description="Analysis agent", - chat_client=mock_client, + client=mock_client, system_message="You are an analyst. Identify trends and patterns.", ) - summarizer = ChatAgent( + summarizer = Agent( id="summarizer", name="Summarizer", description="Summary agent", - chat_client=mock_client, + client=mock_client, system_message="You are a summarizer. Provide concise summaries.", ) diff --git a/python/packages/devui/tests/devui/test_checkpoints.py b/python/packages/devui/tests/devui/test_checkpoints.py index ffbbf93022..1e87187e41 100644 --- a/python/packages/devui/tests/devui/test_checkpoints.py +++ b/python/packages/devui/tests/devui/test_checkpoints.py @@ -104,17 +104,21 @@ class TestCheckpointConversationManager: from agent_framework._workflows._checkpoint import WorkflowCheckpoint checkpoint = WorkflowCheckpoint( - checkpoint_id=str(uuid.uuid4()), workflow_id=test_workflow.id, messages={}, state={"test": "data"} + checkpoint_id=str(uuid.uuid4()), + workflow_name=test_workflow.name, + graph_signature_hash=test_workflow.graph_signature_hash, + messages={}, + state={"test": "data"}, ) # Get checkpoint storage for this conversation and save storage = checkpoint_manager.get_checkpoint_storage(conversation_id) - checkpoint_id = await storage.save_checkpoint(checkpoint) + checkpoint_id = await storage.save(checkpoint) assert checkpoint_id == checkpoint.checkpoint_id # Verify checkpoint stored in THIS conversation only - checkpoints = await storage.list_checkpoints() + checkpoints = await storage.list_checkpoints(workflow_name=test_workflow.name) assert len(checkpoints) == 1 assert checkpoints[0].checkpoint_id == checkpoint.checkpoint_id @@ -140,20 +144,21 @@ class TestCheckpointConversationManager: checkpoint_a = WorkflowCheckpoint( checkpoint_id=str(uuid.uuid4()), - workflow_id=test_workflow.id, + workflow_name=test_workflow.name, + graph_signature_hash=test_workflow.graph_signature_hash, messages={}, state={"conversation": "A"}, ) storage_a = checkpoint_manager.get_checkpoint_storage(conv_a) - await storage_a.save_checkpoint(checkpoint_a) + await storage_a.save(checkpoint_a) # Verify conversation A has checkpoint - checkpoints_a = await storage_a.list_checkpoints() + checkpoints_a = await storage_a.list_checkpoints(workflow_name=test_workflow.name) assert len(checkpoints_a) == 1 # Verify conversation B has NO checkpoints (isolation) storage_b = checkpoint_manager.get_checkpoint_storage(conv_b) - checkpoints_b = await storage_b.list_checkpoints() + checkpoints_b = await storage_b.list_checkpoints(workflow_name=test_workflow.name) assert len(checkpoints_b) == 0 @pytest.mark.asyncio @@ -177,15 +182,16 @@ class TestCheckpointConversationManager: for i in range(3): checkpoint = WorkflowCheckpoint( checkpoint_id=str(uuid.uuid4()), - workflow_id=test_workflow.id, + workflow_name=test_workflow.name, + graph_signature_hash=test_workflow.graph_signature_hash, messages={}, state={"iteration": i}, ) - saved_id = await storage.save_checkpoint(checkpoint) + saved_id = await storage.save(checkpoint) checkpoint_ids.append(saved_id) # List checkpoints using the storage - checkpoints_list = await storage.list_checkpoints() + checkpoints_list = await storage.list_checkpoints(workflow_name=test_workflow.name) assert len(checkpoints_list) == 3 # Verify all checkpoint IDs are present @@ -213,11 +219,12 @@ class TestCheckpointConversationManager: for i in range(2): checkpoint = WorkflowCheckpoint( checkpoint_id=f"checkpoint_{i}", - workflow_id=test_workflow.id, + workflow_name=test_workflow.name, + graph_signature_hash=test_workflow.graph_signature_hash, messages={}, state={"iteration": i}, ) - saved_id = await storage.save_checkpoint(checkpoint) + saved_id = await storage.save(checkpoint) checkpoint_ids.append(saved_id) # List conversation items - should include checkpoints @@ -233,7 +240,7 @@ class TestCheckpointConversationManager: for item in checkpoint_items: assert item.get("type") == "checkpoint" assert item.get("checkpoint_id") in checkpoint_ids - assert item.get("workflow_id") == test_workflow.id + assert item.get("workflow_name") == test_workflow.name assert "timestamp" in item assert item.get("id").startswith("checkpoint_") # ID format: checkpoint_{checkpoint_id} @@ -255,21 +262,22 @@ class TestCheckpointConversationManager: original_checkpoint = WorkflowCheckpoint( checkpoint_id=str(uuid.uuid4()), - workflow_id=test_workflow.id, + workflow_name=test_workflow.name, + graph_signature_hash=test_workflow.graph_signature_hash, messages={}, state={"test_key": "test_value"}, ) # Save to this session storage = checkpoint_manager.get_checkpoint_storage(conversation_id) - await storage.save_checkpoint(original_checkpoint) + await storage.save(original_checkpoint) # Load checkpoint from this session - loaded_checkpoint = await storage.load_checkpoint(original_checkpoint.checkpoint_id) + loaded_checkpoint = await storage.load(original_checkpoint.checkpoint_id) assert loaded_checkpoint is not None assert loaded_checkpoint.checkpoint_id == original_checkpoint.checkpoint_id - assert loaded_checkpoint.workflow_id == original_checkpoint.workflow_id + assert loaded_checkpoint.workflow_name == original_checkpoint.workflow_name assert loaded_checkpoint.state == {"test_key": "test_value"} @@ -296,24 +304,28 @@ class TestCheckpointStorage: from agent_framework._workflows._checkpoint import WorkflowCheckpoint checkpoint = WorkflowCheckpoint( - checkpoint_id=str(uuid.uuid4()), workflow_id=test_workflow.id, messages={}, state={"test": "data"} + checkpoint_id=str(uuid.uuid4()), + workflow_name=test_workflow.name, + graph_signature_hash=test_workflow.graph_signature_hash, + messages={}, + state={"test": "data"}, ) - # Test save_checkpoint - checkpoint_id = await storage.save_checkpoint(checkpoint) + # Test save + checkpoint_id = await storage.save(checkpoint) assert checkpoint_id == checkpoint.checkpoint_id - # Test load_checkpoint - loaded = await storage.load_checkpoint(checkpoint_id) + # Test load + loaded = await storage.load(checkpoint_id) assert loaded is not None assert loaded.checkpoint_id == checkpoint_id # Test list_checkpoint_ids - ids = await storage.list_checkpoint_ids(workflow_id=test_workflow.id) + ids = await storage.list_checkpoint_ids(workflow_name=test_workflow.name) assert checkpoint_id in ids # Test list_checkpoints - checkpoints_list = await storage.list_checkpoints(workflow_id=test_workflow.id) + checkpoints_list = await storage.list_checkpoints(workflow_name=test_workflow.name) assert len(checkpoints_list) >= 1 assert any(cp.checkpoint_id == checkpoint_id for cp in checkpoints_list) @@ -346,12 +358,16 @@ class TestIntegration: from agent_framework._workflows._checkpoint import WorkflowCheckpoint checkpoint = WorkflowCheckpoint( - checkpoint_id=str(uuid.uuid4()), workflow_id=test_workflow.id, messages={}, state={"injected": True} + checkpoint_id=str(uuid.uuid4()), + workflow_name=test_workflow.name, + graph_signature_hash=test_workflow.graph_signature_hash, + messages={}, + state={"injected": True}, ) - await checkpoint_storage.save_checkpoint(checkpoint) + await checkpoint_storage.save(checkpoint) # Verify checkpoint is accessible via storage (in this session) - storage_checkpoints = await checkpoint_storage.list_checkpoints() + storage_checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=test_workflow.name) assert len(storage_checkpoints) > 0 assert storage_checkpoints[0].checkpoint_id == checkpoint.checkpoint_id @@ -377,20 +393,21 @@ class TestIntegration: checkpoint = WorkflowCheckpoint( checkpoint_id=str(uuid.uuid4()), - workflow_id=test_workflow.id, + workflow_name=test_workflow.name, + graph_signature_hash=test_workflow.graph_signature_hash, messages={}, state={"ready_to_resume": True}, ) - checkpoint_id = await checkpoint_storage.save_checkpoint(checkpoint) + checkpoint_id = await checkpoint_storage.save(checkpoint) # Verify checkpoint can be loaded for resume - loaded = await checkpoint_storage.load_checkpoint(checkpoint_id) + loaded = await checkpoint_storage.load(checkpoint_id) assert loaded is not None assert loaded.checkpoint_id == checkpoint_id assert loaded.state == {"ready_to_resume": True} # Verify checkpoint is accessible via storage (for UI to list checkpoints) - checkpoints = await checkpoint_storage.list_checkpoints() + checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=test_workflow.name) assert len(checkpoints) > 0 assert checkpoints[0].checkpoint_id == checkpoint_id @@ -420,7 +437,7 @@ class TestIntegration: test_workflow._runner.context._checkpoint_storage = checkpoint_storage # Verify no checkpoints initially - checkpoints_before = await checkpoint_storage.list_checkpoints() + checkpoints_before = await checkpoint_storage.list_checkpoints(workflow_name=test_workflow.name) assert len(checkpoints_before) == 0 # Run workflow until it reaches IDLE_WITH_PENDING_REQUESTS (after checkpoint is created) @@ -435,9 +452,9 @@ class TestIntegration: assert saw_request_event, "Test workflow should have emitted request_info event (type='request_info')" # Verify checkpoint was AUTOMATICALLY saved to our storage by the framework - checkpoints_after = await checkpoint_storage.list_checkpoints() + checkpoints_after = await checkpoint_storage.list_checkpoints(workflow_name=test_workflow.name) assert len(checkpoints_after) > 0, "Workflow should have auto-saved checkpoint at HIL pause" - # Verify checkpoint has correct workflow_id + # Verify checkpoint has correct workflow identity checkpoint = checkpoints_after[0] - assert checkpoint.workflow_id == test_workflow.id + assert checkpoint.workflow_name == test_workflow.name diff --git a/python/packages/devui/tests/devui/test_cleanup_hooks.py b/python/packages/devui/tests/devui/test_cleanup_hooks.py index f8bdf5c867..8d02bfaf27 100644 --- a/python/packages/devui/tests/devui/test_cleanup_hooks.py +++ b/python/packages/devui/tests/devui/test_cleanup_hooks.py @@ -7,7 +7,7 @@ import tempfile from pathlib import Path import pytest -from agent_framework import AgentResponse, ChatMessage, Content +from agent_framework import AgentResponse, Content, Message from agent_framework_devui import register_cleanup from agent_framework_devui._discovery import EntityDiscovery @@ -39,12 +39,12 @@ class MockAgent: async def _stream(): yield AgentResponse( - messages=[ChatMessage(role="assistant", contents=[Content.from_text(text="Test response")])], + messages=[Message(role="assistant", contents=[Content.from_text(text="Test response")])], ) return _stream() return AgentResponse( - messages=[ChatMessage(role="assistant", contents=[Content.from_text(text="Test response")])], + messages=[Message(role="assistant", contents=[Content.from_text(text="Test response")])], ) @@ -267,7 +267,7 @@ async def test_cleanup_with_file_based_discovery(): # Write agent module with cleanup registration agent_file = agent_dir / "__init__.py" agent_file.write_text(""" -from agent_framework import AgentResponse, ChatMessage, Role, Content +from agent_framework import AgentResponse, Message, Role, Content from agent_framework_devui import register_cleanup class MockCredential: @@ -289,12 +289,12 @@ class TestAgent: if stream: async def _stream(): yield AgentResponse( - messages=[ChatMessage(role="assistant", content=[Content.from_text(text="Test")])], + messages=[Message(role="assistant", content=[Content.from_text(text="Test")])], inner_messages=[], ) return _stream() return AgentResponse( - messages=[ChatMessage(role="assistant", content=[Content.from_text(text="Test")])], + messages=[Message(role="assistant", content=[Content.from_text(text="Test")])], inner_messages=[], ) diff --git a/python/packages/devui/tests/devui/test_conversations.py b/python/packages/devui/tests/devui/test_conversations.py index dbc2e4ddb2..a9e7ac6441 100644 --- a/python/packages/devui/tests/devui/test_conversations.py +++ b/python/packages/devui/tests/devui/test_conversations.py @@ -83,29 +83,29 @@ async def test_delete_conversation(): @pytest.mark.asyncio -async def test_get_thread(): - """Test getting underlying AgentThread.""" +async def test_get_session(): + """Test getting AgentSession for execution.""" store = InMemoryConversationStore() # Create conversation conversation = store.create_conversation(metadata={"agent_id": "test_agent"}) - # Get thread - thread = store.get_thread(conversation.id) + # Get session + session = store.get_session(conversation.id) - assert thread is not None - # AgentThread should have message_store - assert hasattr(thread, "message_store") + assert session is not None + # AgentSession should have session_id + assert hasattr(session, "session_id") @pytest.mark.asyncio -async def test_get_thread_not_found(): - """Test getting thread for non-existent conversation.""" +async def test_get_session_not_found(): + """Test getting session for non-existent conversation.""" store = InMemoryConversationStore() - thread = store.get_thread("conv_nonexistent") + session = store.get_session("conv_nonexistent") - assert thread is None + assert session is None @pytest.mark.asyncio @@ -199,25 +199,17 @@ async def test_list_items_pagination(): @pytest.mark.asyncio async def test_list_items_converts_function_calls(): """Test that list_items properly converts function calls to ResponseFunctionToolCallItem.""" - from agent_framework import ChatMessage, ChatMessageStore + from agent_framework import Message store = InMemoryConversationStore() # Create conversation conversation = store.create_conversation(metadata={"agent_id": "test_agent"}) - # Get the underlying thread and set up message store - thread = store.get_thread(conversation.id) - assert thread is not None - - # Initialize message store if not present - if thread.message_store is None: - thread.message_store = ChatMessageStore() - # Simulate messages from agent execution with function calls messages = [ - ChatMessage(role="user", contents=[{"type": "text", "text": "What's the weather in SF?"}]), - ChatMessage( + Message(role="user", contents=[{"type": "text", "text": "What's the weather in SF?"}]), + Message( role="assistant", contents=[ { @@ -228,7 +220,7 @@ async def test_list_items_converts_function_calls(): } ], ), - ChatMessage( + Message( role="tool", contents=[ { @@ -238,11 +230,11 @@ async def test_list_items_converts_function_calls(): } ], ), - ChatMessage(role="assistant", contents=[{"type": "text", "text": "The weather is sunny, 65°F"}]), + Message(role="assistant", contents=[{"type": "text", "text": "The weather is sunny, 65°F"}]), ] - # Add messages to thread - await thread.on_new_messages(messages) + # Add messages to internal storage + store._conversations[conversation.id]["messages"].extend(messages) # List conversation items items, has_more = await store.list_items(conversation.id) @@ -284,23 +276,16 @@ async def test_list_items_converts_function_calls(): @pytest.mark.asyncio async def test_list_items_handles_images_and_files(): """Test that list_items properly converts data content (images/files) to OpenAI types.""" - from agent_framework import ChatMessage, ChatMessageStore + from agent_framework import Message store = InMemoryConversationStore() # Create conversation conversation = store.create_conversation(metadata={"agent_id": "test_agent"}) - # Get the underlying thread - thread = store.get_thread(conversation.id) - assert thread is not None - - if thread.message_store is None: - thread.message_store = ChatMessageStore() - # Simulate message with image and file messages = [ - ChatMessage( + Message( role="user", contents=[ {"type": "text", "text": "Check this image and PDF"}, @@ -310,7 +295,8 @@ async def test_list_items_handles_images_and_files(): ), ] - await thread.on_new_messages(messages) + # Add messages to internal storage + store._conversations[conversation.id]["messages"].extend(messages) # List items items, has_more = await store.list_items(conversation.id) diff --git a/python/packages/devui/tests/devui/test_discovery.py b/python/packages/devui/tests/devui/test_discovery.py index c5e92b4645..4a4efaadab 100644 --- a/python/packages/devui/tests/devui/test_discovery.py +++ b/python/packages/devui/tests/devui/test_discovery.py @@ -74,24 +74,24 @@ async def test_discovery_accepts_agents_with_only_run(): init_file = agent_dir / "__init__.py" init_file.write_text(""" -from agent_framework import AgentResponse, AgentThread, ChatMessage, Role, Content +from agent_framework import AgentResponse, AgentSession, Message, Role, Content class NonStreamingAgent: id = "non_streaming" name = "Non-Streaming Agent" description = "Agent with run() method" - async def run(self, messages=None, *, thread=None, **kwargs): + async def run(self, messages=None, *, session=None, **kwargs): return AgentResponse( - messages=[ChatMessage( + messages=[Message( role="assistant", contents=[Content.from_text(text="response")] )], response_id="test" ) - def get_new_thread(self, **kwargs): - return AgentThread() + def create_session(self, **kwargs): + return AgentSession() agent = NonStreamingAgent() """) @@ -188,19 +188,19 @@ workflow = WorkflowBuilder(start_executor=executor).build() agent_dir = temp_path / "my_agent" agent_dir.mkdir() (agent_dir / "agent.py").write_text(""" -from agent_framework import AgentResponse, AgentThread, ChatMessage, Role, TextContent +from agent_framework import AgentResponse, AgentSession, Message, Role, TextContent class TestAgent: name = "Test Agent" - async def run(self, messages=None, *, thread=None, **kwargs): + async def run(self, messages=None, *, session=None, **kwargs): return AgentResponse( - messages=[ChatMessage(role="assistant", contents=[Content.from_text(text="test")])], + messages=[Message(role="assistant", contents=[Content.from_text(text="test")])], response_id="test" ) - def get_new_thread(self, **kwargs): - return AgentThread() + def create_session(self, **kwargs): + return AgentSession() agent = TestAgent() """) @@ -320,7 +320,7 @@ class WeatherAgent: name = "Weather Agent" description = "Gets weather information" - def run(self, input_str, *, stream: bool = False, thread=None, **kwargs): + def run(self, input_str, *, stream: bool = False, session=None, **kwargs): return f"Weather in {input_str}" """) diff --git a/python/packages/devui/tests/devui/test_execution.py b/python/packages/devui/tests/devui/test_execution.py index 3dd417cbf6..a7ac622c75 100644 --- a/python/packages/devui/tests/devui/test_execution.py +++ b/python/packages/devui/tests/devui/test_execution.py @@ -4,7 +4,7 @@ Tests include: - Entity discovery and info retrieval -- Agent execution (sync and streaming) using real ChatAgent with mock LLM +- Agent execution (sync and streaming) using real Agent with mock LLM - Workflow execution using real WorkflowBuilder with FunctionExecutor - Edge cases like non-streaming agents """ @@ -15,7 +15,7 @@ from pathlib import Path from typing import Any import pytest -from agent_framework import AgentExecutor, ChatAgent, FunctionExecutor, WorkflowBuilder +from agent_framework import Agent, AgentExecutor, FunctionExecutor, WorkflowBuilder # Import mock classes from conftest for direct use in some tests from conftest import MockBaseChatClient @@ -77,15 +77,15 @@ async def test_executor_get_entity_info(executor): # ============================================================================= -# Agent Execution Tests (using real ChatAgent with mock LLM) +# Agent Execution Tests (using real Agent with mock LLM) # ============================================================================= async def test_agent_sync_execution(executor_with_real_agent): - """Test synchronous agent execution with REAL ChatAgent (mock LLM). + """Test synchronous agent execution with REAL Agent (mock LLM). This tests the full execution pipeline without needing an API key: - - Real ChatAgent class with middleware + - Real Agent class with middleware - Real message normalization - Mock chat client for LLM calls """ @@ -130,7 +130,7 @@ async def test_agent_sync_execution_respects_model_field(executor_with_real_agen async def test_chat_client_receives_correct_messages(executor_with_real_agent): """Verify the mock chat client receives properly formatted messages. - This tests that the REAL ChatAgent properly: + This tests that the REAL Agent properly: - Normalizes input messages - Formats messages for the chat client """ @@ -297,18 +297,18 @@ async def test_full_pipeline_workflow_events_are_json_serializable(): This is particularly important for workflows with AgentExecutor because: - AgentExecutor produces executor_completed event (type='executor_completed') with AgentExecutorResponse - - AgentExecutorResponse contains AgentResponse and ChatMessage objects + - AgentExecutorResponse contains AgentResponse and Message objects - These are SerializationMixin objects, not Pydantic, which caused the original bug This test ensures the ENTIRE streaming pipeline works end-to-end. """ # Create a workflow with AgentExecutor (the problematic case) mock_client = MockBaseChatClient() - agent = ChatAgent( + agent = Agent( id="serialization_test_agent", name="Serialization Test Agent", description="Agent for testing serialization", - chat_client=mock_client, + client=mock_client, system_message="You are a test assistant.", ) @@ -466,15 +466,15 @@ async def test_executor_parse_raw_string_for_string_workflow(): @pytest.mark.asyncio async def test_executor_parse_converts_to_chat_message_for_sequential_workflow(sequential_workflow): - """Sequential workflows convert string input to ChatMessage.""" - from agent_framework import ChatMessage + """Sequential workflows convert string input to Message.""" + from agent_framework import Message executor, _entity_id, _mock_client, workflow = sequential_workflow - # Sequential workflows expect ChatMessage, so raw string becomes ChatMessage + # Sequential workflows expect Message, so raw string becomes Message parsed = executor._parse_raw_workflow_input(workflow, "hello") - assert isinstance(parsed, ChatMessage) + assert isinstance(parsed, Message) assert parsed.text == "hello" @@ -538,7 +538,7 @@ def test_extract_workflow_hil_responses_handles_stringified_json(): async def test_executor_handles_streaming_agent(): """Test executor handles agents with run(stream=True) method.""" - from agent_framework import AgentResponse, AgentResponseUpdate, AgentThread, ChatMessage, Content + from agent_framework import AgentResponse, AgentResponseUpdate, AgentSession, Content, Message class StreamingAgent: """Agent with run() method supporting stream parameter.""" @@ -547,7 +547,7 @@ async def test_executor_handles_streaming_agent(): name = "Streaming Test Agent" description = "Test agent with run(stream=True)" - def run(self, messages=None, *, stream=False, thread=None, **kwargs): + def run(self, messages=None, *, stream=False, session=None, **kwargs): if stream: # Return an async generator for streaming return self._stream_impl(messages) @@ -556,7 +556,7 @@ async def test_executor_handles_streaming_agent(): async def _run_impl(self, messages): return AgentResponse( - messages=[ChatMessage(role="assistant", contents=[Content.from_text(text=f"Processed: {messages}")])], + messages=[Message(role="assistant", contents=[Content.from_text(text=f"Processed: {messages}")])], response_id="test_123", ) @@ -566,8 +566,8 @@ async def test_executor_handles_streaming_agent(): role="assistant", ) - def get_new_thread(self, **kwargs): - return AgentThread() + def create_session(self, **kwargs): + return AgentSession() # Create executor and register agent discovery = EntityDiscovery(None) @@ -754,7 +754,7 @@ class StreamingAgent: name = "Streaming Test Agent" description = "Test agent for streaming" - async def run(self, input_str, *, stream: bool = False, thread=None, **kwargs): + async def run(self, input_str, *, stream: bool = False, session=None, **kwargs): if stream: async def _stream(): for i, word in enumerate(f"Processing {input_str}".split()): diff --git a/python/packages/devui/tests/devui/test_mapper.py b/python/packages/devui/tests/devui/test_mapper.py index 3609cd774b..bab2130a99 100644 --- a/python/packages/devui/tests/devui/test_mapper.py +++ b/python/packages/devui/tests/devui/test_mapper.py @@ -304,7 +304,7 @@ async def test_executor_completed_event_with_agent_response( This is a REGRESSION TEST for the serialization bug where WorkflowEvent.data contained AgentExecutorResponse with nested - AgentResponse and ChatMessage objects (SerializationMixin) that + AgentResponse and Message objects (SerializationMixin) that Pydantic couldn't serialize. """ # Create event with realistic nested data - the exact structure that caused the bug @@ -579,13 +579,13 @@ async def test_workflow_output_event(mapper: MessageMapper, test_request: AgentF async def test_workflow_output_event_with_list_data(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None: """Test output event (type='output') with list data (common for sequential/concurrent workflows).""" - from agent_framework import ChatMessage + from agent_framework import Message from agent_framework._workflows._events import WorkflowEvent - # Sequential/Concurrent workflows often output list[ChatMessage] + # Sequential/Concurrent workflows often output list[Message] messages = [ - ChatMessage(role="user", contents=[Content.from_text(text="Hello")]), - ChatMessage(role="assistant", contents=[Content.from_text(text="World")]), + Message(role="user", contents=[Content.from_text(text="Hello")]), + Message(role="assistant", contents=[Content.from_text(text="World")]), ] event = WorkflowEvent.output(executor_id="complete", data=messages) events = await mapper.convert_event(event, test_request) diff --git a/python/packages/devui/tests/devui/test_multimodal_workflow.py b/python/packages/devui/tests/devui/test_multimodal_workflow.py index 1124c9afce..7af7f3f308 100644 --- a/python/packages/devui/tests/devui/test_multimodal_workflow.py +++ b/python/packages/devui/tests/devui/test_multimodal_workflow.py @@ -48,8 +48,8 @@ class TestMultimodalWorkflowInput: assert executor._is_openai_multimodal_format([{"foo": "bar"}]) is False # no type field def test_convert_openai_input_to_chat_message_with_image(self): - """Test that OpenAI format with image is converted to ChatMessage with DataContent.""" - from agent_framework import ChatMessage + """Test that OpenAI format with image is converted to Message with DataContent.""" + from agent_framework import Message discovery = MagicMock(spec=EntityDiscovery) mapper = MagicMock(spec=MessageMapper) @@ -67,11 +67,11 @@ class TestMultimodalWorkflowInput: } ] - # Convert to ChatMessage + # Convert to Message result = executor._convert_input_to_chat_message(openai_input) - # Verify result is ChatMessage - assert isinstance(result, ChatMessage), f"Expected ChatMessage, got {type(result)}" + # Verify result is Message + assert isinstance(result, Message), f"Expected Message, got {type(result)}" assert result.role == "user" # Verify contents @@ -89,7 +89,7 @@ class TestMultimodalWorkflowInput: async def test_parse_workflow_input_handles_json_string_with_multimodal(self): """Test that _parse_workflow_input correctly handles JSON string with multimodal content.""" - from agent_framework import ChatMessage + from agent_framework import Message discovery = MagicMock(spec=EntityDiscovery) mapper = MagicMock(spec=MessageMapper) @@ -114,8 +114,8 @@ class TestMultimodalWorkflowInput: # Parse the input result = await executor._parse_workflow_input(mock_workflow, json_string_input) - # Verify result is ChatMessage with multimodal content - assert isinstance(result, ChatMessage), f"Expected ChatMessage, got {type(result)}" + # Verify result is Message with multimodal content + assert isinstance(result, Message), f"Expected Message, got {type(result)}" assert len(result.contents) == 2 # Verify text content @@ -129,7 +129,7 @@ class TestMultimodalWorkflowInput: async def test_parse_workflow_input_still_handles_simple_dict(self): """Test that simple dict input still works (backward compatibility).""" - from agent_framework import ChatMessage + from agent_framework import Message discovery = MagicMock(spec=EntityDiscovery) mapper = MagicMock(spec=MessageMapper) @@ -139,14 +139,14 @@ class TestMultimodalWorkflowInput: simple_input = {"text": "Hello world", "role": "user"} json_string_input = json.dumps(simple_input) - # Mock workflow with ChatMessage input type + # Mock workflow with Message input type mock_workflow = MagicMock() mock_executor = MagicMock() - mock_executor.input_types = [ChatMessage] + mock_executor.input_types = [Message] mock_workflow.get_start_executor.return_value = mock_executor # Parse the input result = await executor._parse_workflow_input(mock_workflow, json_string_input) - # Result should be ChatMessage (from _parse_structured_workflow_input) - assert isinstance(result, ChatMessage), f"Expected ChatMessage, got {type(result)}" + # Result should be Message (from _parse_structured_workflow_input) + assert isinstance(result, Message), f"Expected Message, got {type(result)}" diff --git a/python/packages/devui/tests/devui/test_openai_sdk_integration.py b/python/packages/devui/tests/devui/test_openai_sdk_integration.py index 6957369215..0de4075690 100644 --- a/python/packages/devui/tests/devui/test_openai_sdk_integration.py +++ b/python/packages/devui/tests/devui/test_openai_sdk_integration.py @@ -28,7 +28,7 @@ def devui_server() -> Generator[str, None, None]: """ # Get samples directory current_dir = Path(__file__).parent - samples_dir = current_dir.parent.parent.parent / "samples" / "getting_started" / "devui" + samples_dir = current_dir.parent.parent.parent / "samples" / "02-agents" / "devui" if not samples_dir.exists(): pytest.skip(f"Samples directory not found: {samples_dir}") diff --git a/python/packages/devui/tests/devui/test_schema_generation.py b/python/packages/devui/tests/devui/test_schema_generation.py index ddc8b401a6..a5e6c47ba6 100644 --- a/python/packages/devui/tests/devui/test_schema_generation.py +++ b/python/packages/devui/tests/devui/test_schema_generation.py @@ -67,16 +67,16 @@ def test_dataclass_schema_generation(): def test_chat_message_schema_generation(): - """Test schema generation for ChatMessage (SerializationMixin).""" + """Test schema generation for Message (SerializationMixin).""" try: - from agent_framework import ChatMessage + from agent_framework import Message - schema = generate_input_schema(ChatMessage) + schema = generate_input_schema(Message) assert schema is not None assert isinstance(schema, dict) except ImportError: - pytest.skip("ChatMessage not available - agent_framework not installed") + pytest.skip("Message not available - agent_framework not installed") def test_pydantic_model_schema_generation(): diff --git a/python/packages/devui/tests/devui/test_server.py b/python/packages/devui/tests/devui/test_server.py index 1489142914..8fbc127946 100644 --- a/python/packages/devui/tests/devui/test_server.py +++ b/python/packages/devui/tests/devui/test_server.py @@ -142,7 +142,7 @@ async def test_credential_cleanup() -> None: """Test that async credentials are properly closed during server cleanup.""" from unittest.mock import AsyncMock, Mock - from agent_framework import ChatAgent + from agent_framework import Agent # Create mock credential with async close mock_credential = AsyncMock() @@ -155,7 +155,7 @@ async def test_credential_cleanup() -> None: mock_client.function_invocation_configuration = None # Create agent with mock client - agent = ChatAgent(name="TestAgent", chat_client=mock_client, instructions="Test agent") + agent = Agent(name="TestAgent", client=mock_client, instructions="Test agent") # Create DevUI server with agent server = DevServer() @@ -175,7 +175,7 @@ async def test_credential_cleanup_error_handling() -> None: """Test that credential cleanup errors are handled gracefully.""" from unittest.mock import AsyncMock, Mock - from agent_framework import ChatAgent + from agent_framework import Agent # Create mock credential that raises error on close mock_credential = AsyncMock() @@ -188,7 +188,7 @@ async def test_credential_cleanup_error_handling() -> None: mock_client.function_invocation_configuration = None # Create agent with mock client - agent = ChatAgent(name="TestAgent", chat_client=mock_client, instructions="Test agent") + agent = Agent(name="TestAgent", client=mock_client, instructions="Test agent") # Create DevUI server with agent server = DevServer() @@ -207,7 +207,7 @@ async def test_multiple_credential_attributes() -> None: """Test that we check all common credential attribute names.""" from unittest.mock import AsyncMock, Mock - from agent_framework import ChatAgent + from agent_framework import Agent # Create mock credentials mock_cred1 = Mock() @@ -223,7 +223,7 @@ async def test_multiple_credential_attributes() -> None: mock_client.function_invocation_configuration = None # Create agent with mock client - agent = ChatAgent(name="TestAgent", chat_client=mock_client, instructions="Test agent") + agent = Agent(name="TestAgent", client=mock_client, instructions="Test agent") # Create DevUI server with agent server = DevServer() @@ -379,26 +379,27 @@ async def test_checkpoint_api_endpoints(test_entities_dir): storage = executor.checkpoint_manager.get_checkpoint_storage(conv_id) checkpoint = WorkflowCheckpoint( checkpoint_id="test_checkpoint_1", - workflow_id="test_workflow", + workflow_name="test_workflow", + graph_signature_hash="test_graph_hash", state={"key": "value"}, iteration_count=1, ) - await storage.save_checkpoint(checkpoint) + await storage.save(checkpoint) # Test list checkpoints endpoint - checkpoints = await storage.list_checkpoints() + checkpoints = await storage.list_checkpoints(workflow_name="test_workflow") assert len(checkpoints) == 1 assert checkpoints[0].checkpoint_id == "test_checkpoint_1" - assert checkpoints[0].workflow_id == "test_workflow" + assert checkpoints[0].workflow_name == "test_workflow" # Test delete checkpoint endpoint - deleted = await storage.delete_checkpoint("test_checkpoint_1") + deleted = await storage.delete("test_checkpoint_1") assert deleted is True # Verify checkpoint was deleted - remaining = await storage.list_checkpoints() + remaining = await storage.list_checkpoints(workflow_name="test_workflow") assert len(remaining) == 0 # Test delete non-existent checkpoint - deleted = await storage.delete_checkpoint("nonexistent") + deleted = await storage.delete("nonexistent") assert deleted is False diff --git a/python/packages/durabletask/AGENTS.md b/python/packages/durabletask/AGENTS.md index 905f462212..6e185bcd98 100644 --- a/python/packages/durabletask/AGENTS.md +++ b/python/packages/durabletask/AGENTS.md @@ -18,7 +18,7 @@ Durable execution support for long-running agent workflows using Azure Durable F ### State Management - **`DurableAgentState`** - State container for durable agents -- **`DurableAgentThread`** - Thread management for durable agents +- **`DurableAgentSession`** - Session management for durable agents - **`DurableAIAgentOrchestrationContext`** - Orchestration context ### Callbacks diff --git a/python/packages/durabletask/agent_framework_durabletask/__init__.py b/python/packages/durabletask/agent_framework_durabletask/__init__.py index 84a1361d9a..a518b5ad23 100644 --- a/python/packages/durabletask/agent_framework_durabletask/__init__.py +++ b/python/packages/durabletask/agent_framework_durabletask/__init__.py @@ -45,7 +45,7 @@ from ._durable_agent_state import ( ) from ._entities import AgentEntity, AgentEntityStateProviderMixin from ._executors import DurableAgentExecutor -from ._models import AgentSessionId, DurableAgentThread, RunRequest +from ._models import AgentSessionId, DurableAgentSession, RunRequest from ._orchestration_context import DurableAIAgentOrchestrationContext from ._response_utils import ensure_response_format, load_agent_response from ._shim import DurableAIAgent @@ -79,6 +79,7 @@ __all__ = [ "DurableAIAgentOrchestrationContext", "DurableAIAgentWorker", "DurableAgentExecutor", + "DurableAgentSession", "DurableAgentState", "DurableAgentStateContent", "DurableAgentStateData", @@ -99,7 +100,6 @@ __all__ = [ "DurableAgentStateUriContent", "DurableAgentStateUsage", "DurableAgentStateUsageContent", - "DurableAgentThread", "DurableStateFields", "RunRequest", "__version__", diff --git a/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py b/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py index c6e6eaad08..4fd59df051 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py +++ b/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py @@ -37,8 +37,8 @@ from typing import Any, ClassVar, cast from agent_framework import ( AgentResponse, - ChatMessage, Content, + Message, UsageDetails, get_logger, ) @@ -803,11 +803,11 @@ class DurableAgentStateMessage: ) @staticmethod - def from_chat_message(chat_message: ChatMessage) -> DurableAgentStateMessage: + def from_chat_message(chat_message: Message) -> DurableAgentStateMessage: """Converts an Agent Framework chat message to a durable state message. Args: - chat_message: ChatMessage object with role, contents, and metadata to convert + chat_message: Message object with role, contents, and metadata to convert Returns: DurableAgentStateMessage with converted content items and metadata @@ -824,15 +824,15 @@ class DurableAgentStateMessage: ) def to_chat_message(self) -> Any: - """Converts this DurableAgentStateMessage back to an agent framework ChatMessage. + """Converts this DurableAgentStateMessage back to an agent framework Message. Returns: - ChatMessage object with role, contents, and metadata converted back to agent framework types + Message object with role, contents, and metadata converted back to agent framework types """ # Convert DurableAgentStateContent objects back to agent_framework content objects ai_contents = [c.to_ai_content() for c in self.contents] - # Build kwargs for ChatMessage + # Build kwargs for Message kwargs: dict[str, Any] = { "role": self.role, "contents": ai_contents, @@ -844,7 +844,7 @@ class DurableAgentStateMessage: if self.extension_data is not None: kwargs["additional_properties"] = self.extension_data - return ChatMessage(**kwargs) + return Message(**kwargs) class DurableAgentStateDataContent(DurableAgentStateContent): diff --git a/python/packages/durabletask/agent_framework_durabletask/_entities.py b/python/packages/durabletask/agent_framework_durabletask/_entities.py index c39359dc72..186561e3f4 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_entities.py +++ b/python/packages/durabletask/agent_framework_durabletask/_entities.py @@ -11,8 +11,8 @@ from typing import Any, cast from agent_framework import ( AgentResponse, AgentResponseUpdate, - ChatMessage, Content, + Message, ResponseStream, SupportsAgentRun, get_logger, @@ -150,7 +150,7 @@ class AgentEntity: self.state.data.conversation_history.append(state_request) try: - chat_messages: list[ChatMessage] = [ + chat_messages: list[Message] = [ m.to_chat_message() for entry in self.state.data.conversation_history if not self._is_error_response(entry) @@ -175,7 +175,7 @@ class AgentEntity: except Exception as exc: logger.exception("[AgentEntity.run] Agent execution failed.") - error_message = ChatMessage( + error_message = Message( role="assistant", contents=[Content.from_error(message=str(exc), error_code=type(exc).__name__)] ) error_response = AgentResponse( diff --git a/python/packages/durabletask/agent_framework_durabletask/_executors.py b/python/packages/durabletask/agent_framework_durabletask/_executors.py index 226d9dff6c..7adb79875a 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_executors.py +++ b/python/packages/durabletask/agent_framework_durabletask/_executors.py @@ -16,7 +16,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timezone from typing import Any, Generic, TypeVar -from agent_framework import AgentResponse, AgentThread, ChatMessage, Content, get_logger +from agent_framework import AgentResponse, AgentSession, Content, Message, get_logger from durabletask.client import TaskHubGrpcClient from durabletask.entities import EntityInstanceId from durabletask.task import CompletableTask, CompositeTask, OrchestrationContext, Task @@ -24,7 +24,7 @@ from pydantic import BaseModel from ._constants import DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS from ._durable_agent_state import DurableAgentState -from ._models import AgentSessionId, DurableAgentThread, RunRequest +from ._models import AgentSessionId, DurableAgentSession, RunRequest from ._response_utils import ensure_response_format, load_agent_response logger = get_logger("agent_framework.durabletask.executors") @@ -114,7 +114,7 @@ class DurableAgentExecutor(ABC, Generic[TaskT]): self, agent_name: str, run_request: RunRequest, - thread: AgentThread | None = None, + session: AgentSession | None = None, ) -> TaskT: """Execute the durable agent. @@ -123,20 +123,20 @@ class DurableAgentExecutor(ABC, Generic[TaskT]): """ raise NotImplementedError - def get_new_thread(self, agent_name: str, **kwargs: Any) -> DurableAgentThread: - """Create a new DurableAgentThread with random session ID.""" + def get_new_session(self, agent_name: str, **kwargs: Any) -> DurableAgentSession: + """Create a new DurableAgentSession with random session ID.""" session_id = self._create_session_id(agent_name) - return DurableAgentThread.from_session_id(session_id, **kwargs) + return DurableAgentSession.from_session_id(session_id, **kwargs) def _create_session_id( self, agent_name: str, - thread: AgentThread | None = None, + session: AgentSession | None = None, ) -> AgentSessionId: """Create the AgentSessionId for the execution.""" - if isinstance(thread, DurableAgentThread) and thread.session_id is not None: - return thread.session_id - # Create new session ID - either no thread provided or it's a regular AgentThread + if isinstance(session, DurableAgentSession) and session.durable_session_id is not None: + return session.durable_session_id + # Create new session ID - either no session provided or it's a regular AgentSession key = self.generate_unique_id() return AgentSessionId(name=agent_name, key=key) @@ -179,7 +179,7 @@ class DurableAgentExecutor(ABC, Generic[TaskT]): Returns: AgentResponse: Acceptance response with correlation ID """ - acceptance_message = ChatMessage( + acceptance_message = Message( role="system", contents=[ Content.from_text( @@ -217,7 +217,7 @@ class ClientAgentExecutor(DurableAgentExecutor[AgentResponse]): self, agent_name: str, run_request: RunRequest, - thread: AgentThread | None = None, + session: AgentSession | None = None, ) -> AgentResponse: """Execute the agent via the durabletask client. @@ -231,14 +231,14 @@ class ClientAgentExecutor(DurableAgentExecutor[AgentResponse]): Args: agent_name: Name of the agent to execute run_request: The run request containing message and optional response format - thread: Optional conversation thread (creates new if not provided) + session: Optional conversation session (creates new if not provided) Returns: AgentResponse: The agent's response after execution completes, or an immediate acknowledgement if wait_for_response is False """ # Signal the entity with the request - entity_id = self._signal_agent_entity(agent_name, run_request, thread) + entity_id = self._signal_agent_entity(agent_name, run_request, session) # If fire-and-forget mode, return immediately without polling if not run_request.wait_for_response: @@ -258,20 +258,20 @@ class ClientAgentExecutor(DurableAgentExecutor[AgentResponse]): self, agent_name: str, run_request: RunRequest, - thread: AgentThread | None, + session: AgentSession | None, ) -> EntityInstanceId: """Signal the agent entity with a run request. Args: agent_name: Name of the agent to execute run_request: The run request containing message and optional response format - thread: Optional conversation thread + session: Optional conversation session Returns: entity_id """ # Get or create session ID - session_id = self._create_session_id(agent_name, thread) + session_id = self._create_session_id(agent_name, session) # Create the entity ID entity_id = EntityInstanceId( @@ -360,7 +360,7 @@ class ClientAgentExecutor(DurableAgentExecutor[AgentResponse]): "[ClientAgentExecutor] Error converting response for correlation: %s", correlation_id, ) - error_message = ChatMessage( + error_message = Message( role="system", contents=[ Content.from_error( @@ -375,7 +375,7 @@ class ClientAgentExecutor(DurableAgentExecutor[AgentResponse]): self.max_poll_retries, correlation_id, ) - error_message = ChatMessage( + error_message = Message( role="system", contents=[ Content.from_error( @@ -460,7 +460,7 @@ class OrchestrationAgentExecutor(DurableAgentExecutor[DurableAgentTask]): self, agent_name: str, run_request: RunRequest, - thread: AgentThread | None = None, + session: AgentSession | None = None, ) -> DurableAgentTask: """Execute the agent via orchestration context. @@ -470,13 +470,13 @@ class OrchestrationAgentExecutor(DurableAgentExecutor[DurableAgentTask]): Args: agent_name: Name of the agent to execute run_request: The run request containing message and optional response format - thread: Optional conversation thread (creates new if not provided) + session: Optional conversation session (creates new if not provided) Returns: DurableAgentTask: A task wrapping the entity call that yields AgentResponse """ # Resolve session - session_id = self._create_session_id(agent_name, thread) + session_id = self._create_session_id(agent_name, session) # Create the entity ID entity_id = EntityInstanceId( diff --git a/python/packages/durabletask/agent_framework_durabletask/_models.py b/python/packages/durabletask/agent_framework_durabletask/_models.py index 3d20828fc7..1c5484afbf 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_models.py +++ b/python/packages/durabletask/agent_framework_durabletask/_models.py @@ -10,13 +10,12 @@ from __future__ import annotations import inspect import json import uuid -from collections.abc import MutableMapping from dataclasses import dataclass, field from datetime import datetime, timezone from importlib import import_module from typing import TYPE_CHECKING, Any, cast -from agent_framework import AgentThread +from agent_framework import AgentSession from ._constants import REQUEST_RESPONSE_FORMAT_TEXT @@ -274,65 +273,57 @@ class AgentSessionId: raise ValueError(f"Invalid agent session ID format: {session_id_string}") -class DurableAgentThread(AgentThread): - """Durable agent thread that tracks the owning :class:`AgentSessionId`.""" +class DurableAgentSession(AgentSession): + """Durable agent session that tracks the owning :class:`AgentSessionId`.""" _SERIALIZED_SESSION_ID_KEY = "durable_session_id" def __init__( self, *, - session_id: AgentSessionId | None = None, + durable_session_id: AgentSessionId | None = None, + session_id: str | None = None, + service_session_id: str | None = None, **kwargs: Any, ) -> None: - super().__init__(**kwargs) - self._session_id: AgentSessionId | None = session_id + super().__init__(session_id=session_id, service_session_id=service_session_id, **kwargs) + self._session_id_value: AgentSessionId | None = durable_session_id @property - def session_id(self) -> AgentSessionId | None: - return self._session_id + def durable_session_id(self) -> AgentSessionId | None: + return self._session_id_value - @session_id.setter - def session_id(self, value: AgentSessionId | None) -> None: - self._session_id = value + @durable_session_id.setter + def durable_session_id(self, value: AgentSessionId | None) -> None: + self._session_id_value = value @classmethod def from_session_id( cls, session_id: AgentSessionId, **kwargs: Any, - ) -> DurableAgentThread: - return cls(session_id=session_id, **kwargs) + ) -> DurableAgentSession: + return cls(durable_session_id=session_id, **kwargs) - async def serialize(self, **kwargs: Any) -> dict[str, Any]: - state = await super().serialize(**kwargs) - if self._session_id is not None: - state[self._SERIALIZED_SESSION_ID_KEY] = str(self._session_id) + def to_dict(self) -> dict[str, Any]: + state = super().to_dict() + if self._session_id_value is not None: + state[self._SERIALIZED_SESSION_ID_KEY] = str(self._session_id_value) return state @classmethod - async def deserialize( - cls, - serialized_thread_state: MutableMapping[str, Any], - *, - message_store: Any = None, - **kwargs: Any, - ) -> DurableAgentThread: - state_payload = dict(serialized_thread_state) + def from_dict(cls, data: dict[str, Any]) -> DurableAgentSession: + state_payload = dict(data) session_id_value = state_payload.pop(cls._SERIALIZED_SESSION_ID_KEY, None) - thread = await super().deserialize( - state_payload, - message_store=message_store, - **kwargs, + session = super().from_dict(state_payload) + # We need to create a DurableAgentSession from the base AgentSession + durable_session = cls( + session_id=session.session_id, + service_session_id=session.service_session_id, ) - if not isinstance(thread, DurableAgentThread): - raise TypeError("Deserialized thread is not a DurableAgentThread instance") - - if session_id_value is None: - return thread - - if not isinstance(session_id_value, str): - raise ValueError("durable_session_id must be a string when present in serialized state") - - thread.session_id = AgentSessionId.parse(session_id_value) - return thread + durable_session.state.update(session.state) + if session_id_value is not None: + if not isinstance(session_id_value, str): + raise ValueError("durable_session_id must be a string when present in serialized state") + durable_session._session_id_value = AgentSessionId.parse(session_id_value) + return durable_session diff --git a/python/packages/durabletask/agent_framework_durabletask/_shim.py b/python/packages/durabletask/agent_framework_durabletask/_shim.py index 00f606ffe4..1f6165133c 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_shim.py +++ b/python/packages/durabletask/agent_framework_durabletask/_shim.py @@ -12,10 +12,10 @@ from __future__ import annotations from abc import ABC, abstractmethod from typing import Any, Generic, Literal, TypeVar -from agent_framework import AgentThread, ChatMessage, SupportsAgentRun +from agent_framework import AgentSession, Message, SupportsAgentRun from ._executors import DurableAgentExecutor -from ._models import DurableAgentThread +from ._models import DurableAgentSession # TypeVar for the task type returned by executors # Covariant because TaskT only appears in return positions (output) @@ -86,10 +86,10 @@ class DurableAIAgent(SupportsAgentRun, Generic[TaskT]): def run( # type: ignore[override] self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, stream: Literal[False] = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, options: dict[str, Any] | None = None, ) -> TaskT: """Execute the agent via the injected provider. @@ -98,7 +98,7 @@ class DurableAIAgent(SupportsAgentRun, Generic[TaskT]): messages: The message(s) to send to the agent stream: Whether to use streaming for the response (must be False) DurableAgents do not support streaming mode. - thread: Optional agent thread for conversation context + session: Optional agent session for conversation context options: Optional options dictionary. Supported keys include ``response_format``, ``enable_tool_calls``, and ``wait_for_response``. Additional keys are forwarded to the agent execution. @@ -129,14 +129,21 @@ class DurableAIAgent(SupportsAgentRun, Generic[TaskT]): return self._executor.run_durable_agent( agent_name=self.name, run_request=run_request, - thread=thread, + session=session, ) - def get_new_thread(self, **kwargs: Any) -> DurableAgentThread: - """Create a new agent thread via the provider.""" - return self._executor.get_new_thread(self.name, **kwargs) + def create_session(self, **kwargs: Any) -> DurableAgentSession: + """Create a new agent session via the provider.""" + return self._executor.get_new_session(self.name, **kwargs) - def _normalize_messages(self, messages: str | ChatMessage | list[str] | list[ChatMessage] | None) -> str: + def get_session(self, **kwargs: Any) -> AgentSession: + """Retrieve an existing session via the provider. + + For durable agents, sessions do not use `service_session_id` so this is not used. + """ + return self._executor.get_new_session(self.name, **kwargs) + + def _normalize_messages(self, messages: str | Message | list[str] | list[Message] | None) -> str: """Convert supported message inputs to a single string. Args: @@ -149,7 +156,7 @@ class DurableAIAgent(SupportsAgentRun, Generic[TaskT]): return "" if isinstance(messages, str): return messages - if isinstance(messages, ChatMessage): + if isinstance(messages, Message): return messages.text or "" if isinstance(messages, list): if not messages: @@ -157,6 +164,6 @@ class DurableAIAgent(SupportsAgentRun, Generic[TaskT]): first_item = messages[0] if isinstance(first_item, str): return "\n".join(messages) # type: ignore[arg-type] - # List of ChatMessage + # List of Message return "\n".join([msg.text or "" for msg in messages]) # type: ignore[union-attr] return "" diff --git a/python/packages/durabletask/agent_framework_durabletask/_worker.py b/python/packages/durabletask/agent_framework_durabletask/_worker.py index ce6dc9d70e..636dadff2a 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_worker.py +++ b/python/packages/durabletask/agent_framework_durabletask/_worker.py @@ -29,7 +29,7 @@ class DurableAIAgentWorker: Example: ```python from durabletask import TaskHubGrpcWorker - from agent_framework import ChatAgent + from agent_framework import Agent from agent_framework.azure import DurableAIAgentWorker # Create the underlying worker @@ -39,7 +39,7 @@ class DurableAIAgentWorker: agent_worker = DurableAIAgentWorker(worker) # Register agents - my_agent = ChatAgent(chat_client=client, name="assistant") + my_agent = Agent(client=client, name="assistant") agent_worker.add_agent(my_agent) # Start the worker diff --git a/python/packages/durabletask/pyproject.toml b/python/packages/durabletask/pyproject.toml index ea989cfd24..13b00a2441 100644 --- a/python/packages/durabletask/pyproject.toml +++ b/python/packages/durabletask/pyproject.toml @@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "durabletask>=1.3.0", "durabletask-azuremanaged>=1.3.0", "python-dateutil>=2.8.0", diff --git a/python/packages/durabletask/tests/integration_tests/conftest.py b/python/packages/durabletask/tests/integration_tests/conftest.py index e6b26e33a1..014128cf37 100644 --- a/python/packages/durabletask/tests/integration_tests/conftest.py +++ b/python/packages/durabletask/tests/integration_tests/conftest.py @@ -382,7 +382,7 @@ def worker_process( pytest.fail("Test class must have @pytest.mark.sample() marker") sample_name: str = cast(str, sample_marker.args[0]) # type: ignore[union-attr] - sample_path: Path = Path(__file__).parents[4] / "samples" / "getting_started" / "durabletask" / sample_name + sample_path: Path = Path(__file__).parents[4] / "samples" / "04-hosting" / "durabletask" / sample_name worker_file: Path = sample_path / "worker.py" if not worker_file.exists(): diff --git a/python/packages/durabletask/tests/integration_tests/test_01_dt_single_agent.py b/python/packages/durabletask/tests/integration_tests/test_01_dt_single_agent.py index b87e078345..43795f9ef1 100644 --- a/python/packages/durabletask/tests/integration_tests/test_01_dt_single_agent.py +++ b/python/packages/durabletask/tests/integration_tests/test_01_dt_single_agent.py @@ -39,9 +39,9 @@ class TestSingleAgent: def test_single_interaction(self): """Test a single interaction with the agent.""" agent = self.agent_client.get_agent("Joker") - thread = agent.get_new_thread() + session = agent.create_session() - response = agent.run("Tell me a short joke about programming.", thread=thread) + response = agent.run("Tell me a short joke about programming.", session=session) assert response is not None assert response.text is not None @@ -50,33 +50,33 @@ class TestSingleAgent: def test_conversation_continuity(self): """Test that conversation context is maintained across turns.""" agent = self.agent_client.get_agent("Joker") - thread = agent.get_new_thread() + session = agent.create_session() # First turn: Ask for a joke about a specific topic - response1 = agent.run("Tell me a joke about cats.", thread=thread) + response1 = agent.run("Tell me a joke about cats.", session=session) assert response1 is not None assert len(response1.text) > 0 # Second turn: Ask a follow-up that requires context - response2 = agent.run("Can you make it funnier?", thread=thread) + response2 = agent.run("Can you make it funnier?", session=session) assert response2 is not None assert len(response2.text) > 0 # The agent should understand "it" refers to the previous joke - def test_multiple_threads(self): - """Test that different threads maintain separate contexts.""" + def test_multiple_sessions(self): + """Test that different sessions maintain separate contexts.""" agent = self.agent_client.get_agent("Joker") - # Create two separate threads - thread1 = agent.get_new_thread() - thread2 = agent.get_new_thread() + # Create two separate sessions + session1 = agent.create_session() + session2 = agent.create_session() - assert thread1.session_id != thread2.session_id + assert session1.durable_session_id != session2.durable_session_id - # Send different messages to each thread - response1 = agent.run("Tell me a joke about dogs.", thread=thread1) - response2 = agent.run("Tell me a joke about birds.", thread=thread2) + # Send different messages to each session + response1 = agent.run("Tell me a joke about dogs.", session=session1) + response2 = agent.run("Tell me a joke about birds.", session=session2) assert response1 is not None assert response2 is not None diff --git a/python/packages/durabletask/tests/integration_tests/test_02_dt_multi_agent.py b/python/packages/durabletask/tests/integration_tests/test_02_dt_multi_agent.py index 02bcd3029a..9d7d8588ac 100644 --- a/python/packages/durabletask/tests/integration_tests/test_02_dt_multi_agent.py +++ b/python/packages/durabletask/tests/integration_tests/test_02_dt_multi_agent.py @@ -47,9 +47,9 @@ class TestMultiAgent: def test_weather_agent_with_tool(self): """Test weather agent with weather tool execution.""" agent = self.agent_client.get_agent(WEATHER_AGENT_NAME) - thread = agent.get_new_thread() + session = agent.create_session() - response = agent.run("What's the weather in Seattle?", thread=thread) + response = agent.run("What's the weather in Seattle?", session=session) assert response is not None assert response.text is not None @@ -66,9 +66,9 @@ class TestMultiAgent: def test_math_agent_with_tool(self): """Test math agent with calculation tool execution.""" agent = self.agent_client.get_agent(MATH_AGENT_NAME) - thread = agent.get_new_thread() + session = agent.create_session() - response = agent.run("Calculate a 20% tip on a $50 bill.", thread=thread) + response = agent.run("Calculate a 20% tip on a $50 bill.", session=session) assert response is not None assert response.text is not None @@ -85,11 +85,11 @@ class TestMultiAgent: def test_multiple_calls_to_same_agent(self): """Test multiple sequential calls to the same agent.""" agent = self.agent_client.get_agent(WEATHER_AGENT_NAME) - thread = agent.get_new_thread() + session = agent.create_session() # Multiple weather queries - response1 = agent.run("What's the weather in Chicago?", thread=thread) - response2 = agent.run("And what about Los Angeles?", thread=thread) + response1 = agent.run("What's the weather in Chicago?", session=session) + response2 = agent.run("And what about Los Angeles?", session=session) assert response1 is not None assert response2 is not None diff --git a/python/packages/durabletask/tests/integration_tests/test_03_dt_single_agent_streaming.py b/python/packages/durabletask/tests/integration_tests/test_03_dt_single_agent_streaming.py index 2d05280431..41e8bf15bb 100644 --- a/python/packages/durabletask/tests/integration_tests/test_03_dt_single_agent_streaming.py +++ b/python/packages/durabletask/tests/integration_tests/test_03_dt_single_agent_streaming.py @@ -27,7 +27,7 @@ import pytest import redis.asyncio as aioredis # Add sample directory to path to import RedisStreamResponseHandler -SAMPLE_DIR = Path(__file__).parents[4] / "samples" / "getting_started" / "durabletask" / "03_single_agent_streaming" +SAMPLE_DIR = Path(__file__).parents[4] / "samples" / "04-hosting" / "durabletask" / "03_single_agent_streaming" sys.path.insert(0, str(SAMPLE_DIR)) from redis_stream_response_handler import RedisStreamResponseHandler # type: ignore[reportMissingImports] # noqa: E402 @@ -70,7 +70,7 @@ class TestSampleReliableStreaming: async def _stream_from_redis( self, - thread_id: str, + session_key: str, cursor: str | None = None, timeout: float = 30.0, ) -> tuple[str, bool, str]: @@ -78,7 +78,7 @@ class TestSampleReliableStreaming: Stream responses from Redis using the sample's RedisStreamResponseHandler. Args: - thread_id: The conversation/thread ID to stream from + session_key: The conversation/thread ID to stream from cursor: Optional cursor to resume from timeout: Maximum time to wait for stream completion @@ -92,7 +92,7 @@ class TestSampleReliableStreaming: async with await self._get_stream_handler() as stream_handler: # type: ignore[reportUnknownMemberType] try: - async for chunk in stream_handler.read_stream(thread_id, cursor): # type: ignore[reportUnknownMemberType] + async for chunk in stream_handler.read_stream(session_key, cursor): # type: ignore[reportUnknownMemberType] if time.time() - start_time > timeout: break @@ -124,15 +124,15 @@ class TestSampleReliableStreaming: assert travel_planner is not None assert travel_planner.name == "TravelPlanner" - # Create a new thread - thread = travel_planner.get_new_thread() - assert thread.session_id is not None - assert thread.session_id.key is not None - thread_id = str(thread.session_id.key) + # Create a new session + session = travel_planner.create_session() + assert session.durable_session_id is not None + assert session.durable_session_id.key is not None + session_key = str(session.durable_session_id.key) # Start agent run with wait_for_response=False for non-blocking execution travel_planner.run( - "Plan a 1-day trip to Seattle in 1 sentence", thread=thread, options={"wait_for_response": False} + "Plan a 1-day trip to Seattle in 1 sentence", session=session, options={"wait_for_response": False} ) # Poll Redis stream with retries to handle race conditions @@ -146,7 +146,7 @@ class TestSampleReliableStreaming: while retry_count < max_retries and not is_complete: text, is_complete, last_cursor = asyncio.run( - self._stream_from_redis(thread_id, cursor=cursor, timeout=10.0) + self._stream_from_redis(session_key, cursor=cursor, timeout=10.0) ) accumulated_text += text cursor = last_cursor # Resume from last position on next read @@ -166,7 +166,7 @@ class TestSampleReliableStreaming: # Verify we got content assert len(accumulated_text) > 0, ( - f"Expected text content but got empty string for thread_id: {thread_id} after {retry_count} retries" + f"Expected text content but got empty string for session_key: {session_key} after {retry_count} retries" ) assert "seattle" in accumulated_text.lower(), f"Expected 'seattle' in response but got: {accumulated_text}" assert is_complete, "Expected stream to be complete" @@ -175,13 +175,13 @@ class TestSampleReliableStreaming: """Test streaming with cursor-based resumption.""" # Get the TravelPlanner agent travel_planner = self.agent_client.get_agent("TravelPlanner") - thread = travel_planner.get_new_thread() - assert thread.session_id is not None - assert thread.session_id.key is not None - thread_id = str(thread.session_id.key) + session = travel_planner.create_session() + assert session.durable_session_id is not None + assert session.durable_session_id.key is not None + session_key = str(session.durable_session_id.key) # Start agent run - travel_planner.run("What's the weather like?", thread=thread, options={"wait_for_response": False}) + travel_planner.run("What's the weather like?", session=session, options={"wait_for_response": False}) # Wait for agent to start writing time.sleep(3) @@ -194,7 +194,7 @@ class TestSampleReliableStreaming: chunk_count = 0 # Read just first 2 chunks - async for chunk in stream_handler.read_stream(thread_id): # type: ignore[reportUnknownMemberType] + async for chunk in stream_handler.read_stream(session_key): # type: ignore[reportUnknownMemberType] last_entry_id = chunk.entry_id # type: ignore[reportUnknownMemberType] if chunk.text: # type: ignore[reportUnknownMemberType] accumulated_text += chunk.text # type: ignore[reportUnknownMemberType] @@ -207,7 +207,7 @@ class TestSampleReliableStreaming: partial_text, cursor = asyncio.run(get_partial_stream()) # Resume from cursor - remaining_text, _, _ = asyncio.run(self._stream_from_redis(thread_id, cursor=cursor)) + remaining_text, _, _ = asyncio.run(self._stream_from_redis(session_key, cursor=cursor)) # Verify we got some initial content assert len(partial_text) > 0 diff --git a/python/packages/durabletask/tests/test_agent_session_id.py b/python/packages/durabletask/tests/test_agent_session_id.py index 5481e0109d..571212f145 100644 --- a/python/packages/durabletask/tests/test_agent_session_id.py +++ b/python/packages/durabletask/tests/test_agent_session_id.py @@ -1,11 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. -"""Unit tests for AgentSessionId and DurableAgentThread.""" +"""Unit tests for AgentSessionId and DurableAgentSession.""" import pytest -from agent_framework import AgentThread +from agent_framework import AgentSession -from agent_framework_durabletask._models import AgentSessionId, DurableAgentThread +from agent_framework_durabletask._models import AgentSessionId, DurableAgentSession class TestAgentSessionId: @@ -121,154 +121,162 @@ class TestAgentSessionId: assert "Invalid agent session ID format" in str(exc_info.value) -class TestDurableAgentThread: - """Test suite for DurableAgentThread.""" +class TestDurableAgentSession: + """Test suite for DurableAgentSession.""" - def test_init_with_session_id(self) -> None: - """Test DurableAgentThread initialization with session ID.""" + def test_init_with_durable_session_id(self) -> None: + """Test DurableAgentSession initialization with durable session ID.""" session_id = AgentSessionId(name="TestAgent", key="test-key") - thread = DurableAgentThread(session_id=session_id) + session = DurableAgentSession(durable_session_id=session_id) - assert thread.session_id is not None - assert thread.session_id == session_id + assert session.durable_session_id is not None + assert session.durable_session_id == session_id - def test_init_without_session_id(self) -> None: - """Test DurableAgentThread initialization without session ID.""" - thread = DurableAgentThread() + def test_init_without_durable_session_id(self) -> None: + """Test DurableAgentSession initialization without durable session ID.""" + session = DurableAgentSession() - assert thread.session_id is None + assert session.durable_session_id is None - def test_session_id_setter(self) -> None: - """Test setting a session ID to an existing thread.""" - thread = DurableAgentThread() - assert thread.session_id is None + def test_durable_session_id_setter(self) -> None: + """Test setting a durable session ID to an existing session.""" + session = DurableAgentSession() + assert session.durable_session_id is None session_id = AgentSessionId(name="TestAgent", key="test-key") - thread.session_id = session_id + session.durable_session_id = session_id - assert thread.session_id is not None - assert thread.session_id == session_id - assert thread.session_id.name == "TestAgent" + assert session.durable_session_id is not None + assert session.durable_session_id == session_id + assert session.durable_session_id.name == "TestAgent" def test_from_session_id(self) -> None: - """Test creating DurableAgentThread from session ID.""" + """Test creating DurableAgentSession from session ID.""" session_id = AgentSessionId(name="TestAgent", key="test-key") - thread = DurableAgentThread.from_session_id(session_id) + session = DurableAgentSession.from_session_id(session_id) - assert isinstance(thread, DurableAgentThread) - assert thread.session_id is not None - assert thread.session_id == session_id - assert thread.session_id.name == "TestAgent" - assert thread.session_id.key == "test-key" + assert isinstance(session, DurableAgentSession) + assert session.durable_session_id is not None + assert session.durable_session_id == session_id + assert session.durable_session_id.name == "TestAgent" + assert session.durable_session_id.key == "test-key" - def test_from_session_id_with_service_thread_id(self) -> None: - """Test creating DurableAgentThread with service thread ID.""" + def test_from_session_id_with_service_session_id(self) -> None: + """Test creating DurableAgentSession with service session ID.""" session_id = AgentSessionId(name="TestAgent", key="test-key") - thread = DurableAgentThread.from_session_id(session_id, service_thread_id="service-123") + session = DurableAgentSession.from_session_id(session_id, service_session_id="service-123") - assert thread.session_id is not None - assert thread.session_id == session_id - assert thread.service_thread_id == "service-123" + assert session.durable_session_id is not None + assert session.durable_session_id == session_id + assert session.service_session_id == "service-123" - async def test_serialize_with_session_id(self) -> None: - """Test serialization includes session ID.""" + def test_to_dict_with_durable_session_id(self) -> None: + """Test serialization includes durable session ID.""" session_id = AgentSessionId(name="TestAgent", key="test-key") - thread = DurableAgentThread(session_id=session_id) + session = DurableAgentSession(durable_session_id=session_id) - serialized = await thread.serialize() + serialized = session.to_dict() assert isinstance(serialized, dict) assert "durable_session_id" in serialized assert serialized["durable_session_id"] == "@TestAgent@test-key" - async def test_serialize_without_session_id(self) -> None: - """Test serialization without session ID.""" - thread = DurableAgentThread() + def test_to_dict_without_durable_session_id(self) -> None: + """Test serialization without durable session ID.""" + session = DurableAgentSession() - serialized = await thread.serialize() + serialized = session.to_dict() assert isinstance(serialized, dict) assert "durable_session_id" not in serialized - async def test_deserialize_with_session_id(self) -> None: - """Test deserialization restores session ID.""" + def test_from_dict_with_durable_session_id(self) -> None: + """Test deserialization restores durable session ID.""" serialized = { - "service_thread_id": "thread-123", + "type": "session", + "session_id": "session-123", + "service_session_id": "service-123", + "state": {}, "durable_session_id": "@TestAgent@test-key", } - thread = await DurableAgentThread.deserialize(serialized) + session = DurableAgentSession.from_dict(serialized) - assert isinstance(thread, DurableAgentThread) - assert thread.session_id is not None - assert thread.session_id.name == "TestAgent" - assert thread.session_id.key == "test-key" - assert thread.service_thread_id == "thread-123" + assert isinstance(session, DurableAgentSession) + assert session.durable_session_id is not None + assert session.durable_session_id.name == "TestAgent" + assert session.durable_session_id.key == "test-key" + assert session.service_session_id == "service-123" - async def test_deserialize_without_session_id(self) -> None: - """Test deserialization without session ID.""" + def test_from_dict_without_durable_session_id(self) -> None: + """Test deserialization without durable session ID.""" serialized = { - "service_thread_id": "thread-456", + "type": "session", + "session_id": "session-456", + "service_session_id": "service-456", + "state": {}, } - thread = await DurableAgentThread.deserialize(serialized) + session = DurableAgentSession.from_dict(serialized) - assert isinstance(thread, DurableAgentThread) - assert thread.session_id is None - assert thread.service_thread_id == "thread-456" + assert isinstance(session, DurableAgentSession) + assert session.durable_session_id is None + assert session.session_id == "session-456" - async def test_round_trip_serialization(self) -> None: - """Test round-trip serialization preserves session ID.""" + def test_round_trip_serialization(self) -> None: + """Test round-trip serialization preserves durable session ID.""" session_id = AgentSessionId(name="TestAgent", key="test-key-789") - original = DurableAgentThread(session_id=session_id) + original = DurableAgentSession(durable_session_id=session_id) - serialized = await original.serialize() - restored = await DurableAgentThread.deserialize(serialized) + serialized = original.to_dict() + restored = DurableAgentSession.from_dict(serialized) - assert isinstance(restored, DurableAgentThread) - assert restored.session_id is not None - assert restored.session_id.name == session_id.name - assert restored.session_id.key == session_id.key + assert isinstance(restored, DurableAgentSession) + assert restored.durable_session_id is not None + assert restored.durable_session_id.name == session_id.name + assert restored.durable_session_id.key == session_id.key - async def test_deserialize_invalid_session_id_type(self) -> None: - """Test deserialization with invalid session ID type raises error.""" + def test_from_dict_invalid_durable_session_id_type(self) -> None: + """Test deserialization with invalid durable session ID type raises error.""" serialized = { - "service_thread_id": "thread-123", + "type": "session", + "session_id": "session-123", + "state": {}, "durable_session_id": 12345, # Invalid type } with pytest.raises(ValueError, match="durable_session_id must be a string"): - await DurableAgentThread.deserialize(serialized) + DurableAgentSession.from_dict(serialized) -class TestAgentThreadCompatibility: - """Test suite for compatibility between AgentThread and DurableAgentThread.""" +class TestAgentSessionCompatibility: + """Test suite for compatibility between AgentSession and DurableAgentSession.""" - async def test_agent_thread_serialize(self) -> None: - """Test that base AgentThread can be serialized.""" - thread = AgentThread() + def test_agent_session_to_dict(self) -> None: + """Test that base AgentSession can be serialized.""" + session = AgentSession() - serialized = await thread.serialize() + serialized = session.to_dict() assert isinstance(serialized, dict) - assert "service_thread_id" in serialized + assert "session_id" in serialized - async def test_agent_thread_deserialize(self) -> None: - """Test that base AgentThread can be deserialized.""" - thread = AgentThread() - serialized = await thread.serialize() + def test_agent_session_from_dict(self) -> None: + """Test that base AgentSession can be deserialized.""" + session = AgentSession() + serialized = session.to_dict() - restored = await AgentThread.deserialize(serialized) + restored = AgentSession.from_dict(serialized) - assert isinstance(restored, AgentThread) - assert restored.service_thread_id == thread.service_thread_id + assert isinstance(restored, AgentSession) + assert restored.session_id == session.session_id - async def test_durable_thread_is_agent_thread(self) -> None: - """Test that DurableAgentThread is an AgentThread.""" - thread = DurableAgentThread() + def test_durable_session_is_agent_session(self) -> None: + """Test that DurableAgentSession is an AgentSession.""" + session = DurableAgentSession() - assert isinstance(thread, AgentThread) - assert isinstance(thread, DurableAgentThread) + assert isinstance(session, AgentSession) + assert isinstance(session, DurableAgentSession) class TestModelIntegration: @@ -281,19 +289,19 @@ class TestModelIntegration: assert session_id_str.startswith("@AgentEntity@") - async def test_thread_with_session_preserves_on_serialization(self) -> None: - """Test that thread with session ID preserves it through serialization.""" + def test_session_with_durable_id_preserves_on_serialization(self) -> None: + """Test that session with durable session ID preserves it through serialization.""" session_id = AgentSessionId(name="TestAgent", key="preserved-key") - thread = DurableAgentThread.from_session_id(session_id) + session = DurableAgentSession.from_session_id(session_id) # Serialize and deserialize - serialized = await thread.serialize() - restored = await DurableAgentThread.deserialize(serialized) + serialized = session.to_dict() + restored = DurableAgentSession.from_dict(serialized) - # Session ID should be preserved - assert restored.session_id is not None - assert restored.session_id.name == "TestAgent" - assert restored.session_id.key == "preserved-key" + # Durable session ID should be preserved + assert restored.durable_session_id is not None + assert restored.durable_session_id.name == "TestAgent" + assert restored.durable_session_id.key == "preserved-key" if __name__ == "__main__": diff --git a/python/packages/durabletask/tests/test_client.py b/python/packages/durabletask/tests/test_client.py index 7486352d17..0acdfb2f9c 100644 --- a/python/packages/durabletask/tests/test_client.py +++ b/python/packages/durabletask/tests/test_client.py @@ -11,7 +11,7 @@ from unittest.mock import Mock import pytest from agent_framework import SupportsAgentRun -from agent_framework_durabletask import DurableAgentThread, DurableAIAgentClient +from agent_framework_durabletask import DurableAgentSession, DurableAIAgentClient from agent_framework_durabletask._constants import DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS from agent_framework_durabletask._shim import DurableAIAgent @@ -80,22 +80,22 @@ class TestDurableAIAgentClientIntegration: assert hasattr(agent, "run") assert callable(agent.run) - def test_client_agent_can_create_threads(self, agent_client: DurableAIAgentClient) -> None: - """Verify agent from client can create DurableAgentThread instances.""" + def test_client_agent_can_create_sessions(self, agent_client: DurableAIAgentClient) -> None: + """Verify agent from client can create DurableAgentSession instances.""" agent = agent_client.get_agent("assistant") - thread = agent.get_new_thread() + session = agent.create_session() - assert isinstance(thread, DurableAgentThread) + assert isinstance(session, DurableAgentSession) - def test_client_agent_thread_with_parameters(self, agent_client: DurableAIAgentClient) -> None: - """Verify agent can create threads with custom parameters.""" + def test_client_agent_session_with_parameters(self, agent_client: DurableAIAgentClient) -> None: + """Verify agent can create sessions with custom parameters.""" agent = agent_client.get_agent("assistant") - thread = agent.get_new_thread(service_thread_id="client-session-123") + session = agent.create_session(service_session_id="client-session-123") - assert isinstance(thread, DurableAgentThread) - assert thread.service_thread_id == "client-session-123" + assert isinstance(session, DurableAgentSession) + assert session.service_session_id == "client-session-123" class TestDurableAIAgentClientPollingConfiguration: diff --git a/python/packages/durabletask/tests/test_durable_entities.py b/python/packages/durabletask/tests/test_durable_entities.py index e4516f1ce3..a11e9718ef 100644 --- a/python/packages/durabletask/tests/test_durable_entities.py +++ b/python/packages/durabletask/tests/test_durable_entities.py @@ -11,7 +11,7 @@ from typing import Any, TypeVar from unittest.mock import AsyncMock, Mock import pytest -from agent_framework import AgentResponse, AgentResponseUpdate, ChatMessage, Content, ResponseStream +from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message, ResponseStream from pydantic import BaseModel from agent_framework_durabletask import ( @@ -26,7 +26,7 @@ from agent_framework_durabletask import ( ) from agent_framework_durabletask._entities import DurableTaskEntityStateProvider -TState = TypeVar("TState") +StateT = TypeVar("StateT") class MockEntityContext: @@ -37,8 +37,8 @@ class MockEntityContext: def get_state( self, - intended_type: type[TState] | None = None, - default: TState | None = None, + intended_type: type[StateT] | None = None, + default: StateT | None = None, ) -> Any: del intended_type if self._state is None: @@ -71,7 +71,7 @@ def _make_entity(agent: Any, callback: Any = None, *, thread_id: str = "test-thr def _role_value(chat_message: DurableAgentStateMessage) -> str: - """Helper to extract the string role from a ChatMessage.""" + """Helper to extract the string role from a Message.""" role = getattr(chat_message, "role", None) role_value = getattr(role, "value", role) if role_value is None: @@ -81,7 +81,7 @@ def _role_value(chat_message: DurableAgentStateMessage) -> str: def _agent_response(text: str | None) -> AgentResponse: """Create an AgentResponse with a single assistant message.""" - message = ChatMessage(role="assistant", text=text) if text is not None else ChatMessage(role="assistant", text="") + message = Message(role="assistant", text=text) if text is not None else Message(role="assistant", text="") return AgentResponse(messages=[message], created_at="2024-01-01T00:00:00Z") diff --git a/python/packages/durabletask/tests/test_executors.py b/python/packages/durabletask/tests/test_executors.py index 802007541f..2333f08106 100644 --- a/python/packages/durabletask/tests/test_executors.py +++ b/python/packages/durabletask/tests/test_executors.py @@ -16,7 +16,7 @@ from durabletask.entities import EntityInstanceId from durabletask.task import Task from pydantic import BaseModel -from agent_framework_durabletask import DurableAgentThread +from agent_framework_durabletask import DurableAgentSession from agent_framework_durabletask._constants import DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS from agent_framework_durabletask._executors import ( ClientAgentExecutor, @@ -106,42 +106,42 @@ def configure_failed_entity_task(mock_entity_task: Mock) -> Any: return _configure -class TestExecutorThreadCreation: - """Test that executors properly create DurableAgentThread with parameters.""" +class TestExecutorSessionCreation: + """Test that executors properly create DurableAgentSession with parameters.""" - def test_client_executor_creates_durable_thread(self, mock_client: Mock) -> None: - """Verify ClientAgentExecutor creates DurableAgentThread instances.""" + def test_client_executor_creates_durable_session(self, mock_client: Mock) -> None: + """Verify ClientAgentExecutor creates DurableAgentSession instances.""" executor = ClientAgentExecutor(mock_client) - thread = executor.get_new_thread("test_agent") + session = executor.get_new_session("test_agent") - assert isinstance(thread, DurableAgentThread) + assert isinstance(session, DurableAgentSession) - def test_client_executor_forwards_kwargs_to_thread(self, mock_client: Mock) -> None: - """Verify ClientAgentExecutor forwards kwargs to DurableAgentThread creation.""" + def test_client_executor_forwards_kwargs_to_session(self, mock_client: Mock) -> None: + """Verify ClientAgentExecutor forwards kwargs to DurableAgentSession creation.""" executor = ClientAgentExecutor(mock_client) - thread = executor.get_new_thread("test_agent", service_thread_id="client-123") + session = executor.get_new_session("test_agent", service_session_id="client-123") - assert isinstance(thread, DurableAgentThread) - assert thread.service_thread_id == "client-123" + assert isinstance(session, DurableAgentSession) + assert session.service_session_id == "client-123" - def test_orchestration_executor_creates_durable_thread( + def test_orchestration_executor_creates_durable_session( self, orchestration_executor: OrchestrationAgentExecutor ) -> None: - """Verify OrchestrationAgentExecutor creates DurableAgentThread instances.""" - thread = orchestration_executor.get_new_thread("test_agent") + """Verify OrchestrationAgentExecutor creates DurableAgentSession instances.""" + session = orchestration_executor.get_new_session("test_agent") - assert isinstance(thread, DurableAgentThread) + assert isinstance(session, DurableAgentSession) - def test_orchestration_executor_forwards_kwargs_to_thread( + def test_orchestration_executor_forwards_kwargs_to_session( self, orchestration_executor: OrchestrationAgentExecutor ) -> None: - """Verify OrchestrationAgentExecutor forwards kwargs to DurableAgentThread creation.""" - thread = orchestration_executor.get_new_thread("test_agent", service_thread_id="orch-456") + """Verify OrchestrationAgentExecutor forwards kwargs to DurableAgentSession creation.""" + session = orchestration_executor.get_new_session("test_agent", service_session_id="orch-456") - assert isinstance(thread, DurableAgentThread) - assert thread.service_thread_id == "orch-456" + assert isinstance(session, DurableAgentSession) + assert session.service_session_id == "orch-456" class TestClientAgentExecutorRun: @@ -189,19 +189,29 @@ class TestClientAgentExecutorPollingConfiguration: # Verify get_entity was called 2 times (max_poll_retries) assert mock_client.get_entity.call_count == 2 - def test_executor_respects_custom_poll_interval(self, mock_client: Mock, sample_run_request: RunRequest) -> None: + def test_executor_respects_custom_poll_interval( + self, + mock_client: Mock, + sample_run_request: RunRequest, + monkeypatch: pytest.MonkeyPatch, + ) -> None: """Verify executor respects custom poll_interval_seconds during polling.""" # Create executor with very short interval executor = ClientAgentExecutor(mock_client, max_poll_retries=3, poll_interval_seconds=0.01) - # Measure time taken - start = time.time() - result = executor.run_durable_agent("test_agent", sample_run_request) - elapsed = time.time() - start + sleep_calls: list[float] = [] - # Should take roughly 3 * 0.01 = 0.03 seconds (plus overhead) - # Be generous with timing to avoid flakiness - assert elapsed < 0.2 # Should be quick with 0.01 interval + def fake_sleep(seconds: float) -> None: + sleep_calls.append(seconds) + + # Use deterministic assertions instead of wall-clock timing to avoid CI flakiness. + monkeypatch.setattr("agent_framework_durabletask._executors.time.sleep", fake_sleep) + + result = executor.run_durable_agent("test_agent", sample_run_request) + + assert len(sleep_calls) == 3 + assert sleep_calls == pytest.approx([0.01, 0.01, 0.01]) + assert mock_client.get_entity.call_count == 3 assert isinstance(result, AgentResponse) @@ -353,18 +363,18 @@ class TestOrchestrationAgentExecutorRun: # Verify request dict assert request_dict_arg == sample_run_request.to_dict() - def test_orchestration_executor_uses_thread_session_id( + def test_orchestration_executor_uses_session_durable_id( self, mock_orchestration_context: Mock, orchestration_executor: OrchestrationAgentExecutor, sample_run_request: RunRequest, ) -> None: - """Verify executor uses thread's session ID when provided.""" - # Create thread with specific session ID + """Verify executor uses session's durable session ID when provided.""" + # Create session with specific durable session ID session_id = AgentSessionId(name="test_agent", key="specific-key-123") - thread = DurableAgentThread.from_session_id(session_id) + session = DurableAgentSession.from_session_id(session_id) - result = orchestration_executor.run_durable_agent("test_agent", sample_run_request, thread=thread) + result = orchestration_executor.run_durable_agent("test_agent", sample_run_request, session=session) # Verify call_entity was called with the specific key call_args = mock_orchestration_context.call_entity.call_args diff --git a/python/packages/durabletask/tests/test_orchestration_context.py b/python/packages/durabletask/tests/test_orchestration_context.py index 073f0e1642..033c274c88 100644 --- a/python/packages/durabletask/tests/test_orchestration_context.py +++ b/python/packages/durabletask/tests/test_orchestration_context.py @@ -11,7 +11,7 @@ from unittest.mock import Mock import pytest from agent_framework import SupportsAgentRun -from agent_framework_durabletask import DurableAgentThread +from agent_framework_durabletask import DurableAgentSession from agent_framework_durabletask._orchestration_context import DurableAIAgentOrchestrationContext from agent_framework_durabletask._shim import DurableAIAgent @@ -74,24 +74,24 @@ class TestDurableAIAgentOrchestrationContextIntegration: assert hasattr(agent, "run") assert callable(agent.run) - def test_orchestration_agent_can_create_threads(self, agent_context: DurableAIAgentOrchestrationContext) -> None: - """Verify agent from context can create DurableAgentThread instances.""" + def test_orchestration_agent_can_create_sessions(self, agent_context: DurableAIAgentOrchestrationContext) -> None: + """Verify agent from context can create DurableAgentSession instances.""" agent = agent_context.get_agent("assistant") - thread = agent.get_new_thread() + session = agent.create_session() - assert isinstance(thread, DurableAgentThread) + assert isinstance(session, DurableAgentSession) - def test_orchestration_agent_thread_with_parameters( + def test_orchestration_agent_session_with_parameters( self, agent_context: DurableAIAgentOrchestrationContext ) -> None: - """Verify agent can create threads with custom parameters.""" + """Verify agent can create sessions with custom parameters.""" agent = agent_context.get_agent("assistant") - thread = agent.get_new_thread(service_thread_id="orch-session-456") + session = agent.create_session(service_session_id="orch-session-456") - assert isinstance(thread, DurableAgentThread) - assert thread.service_thread_id == "orch-session-456" + assert isinstance(session, DurableAgentSession) + assert session.service_session_id == "orch-session-456" if __name__ == "__main__": diff --git a/python/packages/durabletask/tests/test_shim.py b/python/packages/durabletask/tests/test_shim.py index 6efb027628..423f587871 100644 --- a/python/packages/durabletask/tests/test_shim.py +++ b/python/packages/durabletask/tests/test_shim.py @@ -10,10 +10,10 @@ from typing import Any from unittest.mock import Mock import pytest -from agent_framework import ChatMessage, SupportsAgentRun +from agent_framework import Message, SupportsAgentRun from pydantic import BaseModel -from agent_framework_durabletask import DurableAgentThread +from agent_framework_durabletask import DurableAgentSession from agent_framework_durabletask._executors import DurableAgentExecutor from agent_framework_durabletask._models import RunRequest from agent_framework_durabletask._shim import DurableAgentProvider, DurableAIAgent @@ -30,7 +30,7 @@ def mock_executor() -> Mock: """Create a mock executor for testing.""" mock = Mock(spec=DurableAgentExecutor) mock.run_durable_agent = Mock(return_value=None) - mock.get_new_thread = Mock(return_value=DurableAgentThread()) + mock.get_new_session = Mock(return_value=DurableAgentSession()) # Mock get_run_request to create actual RunRequest objects def create_run_request( @@ -76,8 +76,8 @@ class TestDurableAIAgentMessageNormalization: assert kwargs["run_request"].message == "Hello, world!" def test_run_accepts_chat_message(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: - """Verify run accepts and normalizes ChatMessage objects.""" - chat_msg = ChatMessage(role="user", text="Test message") + """Verify run accepts and normalizes Message objects.""" + chat_msg = Message(role="user", text="Test message") test_agent.run(chat_msg) mock_executor.run_durable_agent.assert_called_once() @@ -93,10 +93,10 @@ class TestDurableAIAgentMessageNormalization: assert kwargs["run_request"].message == "First message\nSecond message" def test_run_accepts_list_of_chat_messages(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: - """Verify run accepts and joins list of ChatMessage objects.""" + """Verify run accepts and joins list of Message objects.""" messages = [ - ChatMessage(role="user", text="Message 1"), - ChatMessage(role="assistant", text="Message 2"), + Message(role="user", text="Message 1"), + Message(role="assistant", text="Message 2"), ] test_agent.run(messages) @@ -124,14 +124,14 @@ class TestDurableAIAgentMessageNormalization: class TestDurableAIAgentParameterFlow: """Test that parameters flow correctly through the shim to executor.""" - def test_run_forwards_thread_parameter(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: - """Verify run forwards thread parameter to executor.""" - thread = DurableAgentThread(service_thread_id="test-thread") - test_agent.run("message", thread=thread) + def test_run_forwards_session_parameter(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: + """Verify run forwards session parameter to executor.""" + session = DurableAgentSession(service_session_id="test-session") + test_agent.run("message", session=session) mock_executor.run_durable_agent.assert_called_once() _, kwargs = mock_executor.run_durable_agent.call_args - assert kwargs["thread"] == thread + assert kwargs["session"] == session def test_run_forwards_response_format(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: """Verify run forwards response_format parameter to executor.""" @@ -171,29 +171,29 @@ class TestDurableAISupportsAgentRunCompliance: assert agent.name == "my_agent" -class TestDurableAIAgentThreadManagement: - """Test thread creation and management.""" +class TestDurableAIAgentSessionManagement: + """Test session creation and management.""" - def test_get_new_thread_delegates_to_executor(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: - """Verify get_new_thread delegates to executor.""" - mock_thread = DurableAgentThread() - mock_executor.get_new_thread.return_value = mock_thread + def test_create_session_delegates_to_executor(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: + """Verify create_session delegates to executor.""" + mock_session = DurableAgentSession() + mock_executor.get_new_session.return_value = mock_session - thread = test_agent.get_new_thread() + session = test_agent.create_session() - mock_executor.get_new_thread.assert_called_once_with("test_agent") - assert thread == mock_thread + mock_executor.get_new_session.assert_called_once_with("test_agent") + assert session == mock_session - def test_get_new_thread_forwards_kwargs(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: - """Verify get_new_thread forwards kwargs to executor.""" - mock_thread = DurableAgentThread(service_thread_id="thread-123") - mock_executor.get_new_thread.return_value = mock_thread + def test_create_session_forwards_kwargs(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None: + """Verify create_session forwards kwargs to executor.""" + mock_session = DurableAgentSession(service_session_id="session-123") + mock_executor.get_new_session.return_value = mock_session - test_agent.get_new_thread(service_thread_id="thread-123") + test_agent.create_session(service_session_id="session-123") - mock_executor.get_new_thread.assert_called_once() - _, kwargs = mock_executor.get_new_thread.call_args - assert kwargs["service_thread_id"] == "thread-123" + mock_executor.get_new_session.assert_called_once() + _, kwargs = mock_executor.get_new_session.call_args + assert kwargs["service_session_id"] == "session-123" class TestDurableAgentProviderInterface: 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 0ee6ce4ab0..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 @@ -38,13 +38,13 @@ __all__ = [ "FoundryLocalSettings", ] -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None) +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) # region Foundry Local Chat Options TypedDict -class FoundryLocalChatOptions(ChatOptions[TResponseModel], Generic[TResponseModel], total=False): +class FoundryLocalChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): """Azure Foundry Local (local model deployment) chat options dict. Extends base ChatOptions for local model inference via Foundry Local. @@ -104,8 +104,8 @@ FOUNDRY_LOCAL_OPTION_TRANSLATIONS: dict[str, str] = { } """Maps ChatOptions keys to OpenAI API parameter names (for compatibility).""" -TFoundryLocalChatOptions = TypeVar( - "TFoundryLocalChatOptions", +FoundryLocalChatOptionsT = TypeVar( + "FoundryLocalChatOptionsT", bound=TypedDict, # type: ignore[valid-type] default="FoundryLocalChatOptions", covariant=True, @@ -115,33 +115,27 @@ TFoundryLocalChatOptions = 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( - ChatMiddlewareLayer[TFoundryLocalChatOptions], - FunctionInvocationLayer[TFoundryLocalChatOptions], - ChatTelemetryLayer[TFoundryLocalChatOptions], - RawOpenAIChatClient[TFoundryLocalChatOptions], - Generic[TFoundryLocalChatOptions], + ChatMiddlewareLayer[FoundryLocalChatOptionsT], + FunctionInvocationLayer[FoundryLocalChatOptionsT], + ChatTelemetryLayer[FoundryLocalChatOptionsT], + RawOpenAIChatClient[FoundryLocalChatOptionsT], + Generic[FoundryLocalChatOptionsT], ): """Foundry Local Chat completion class with middleware, telemetry, and function invocation support.""" @@ -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/pyproject.toml b/python/packages/foundry_local/pyproject.toml index 7e94a0691f..52ccc242c0 100644 --- a/python/packages/foundry_local/pyproject.toml +++ b/python/packages/foundry_local/pyproject.toml @@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "foundry-local-sdk>=0.5.1,<1", ] diff --git a/python/packages/foundry_local/samples/foundry_local_agent.py b/python/packages/foundry_local/samples/foundry_local_agent.py index 9e81d2b33d..bca1d469d9 100644 --- a/python/packages/foundry_local/samples/foundry_local_agent.py +++ b/python/packages/foundry_local/samples/foundry_local_agent.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Annotated from agent_framework_foundry_local import FoundryLocalClient if TYPE_CHECKING: - from agent_framework import ChatAgent + from agent_framework import Agent """ This sample demonstrates basic usage of the FoundryLocalClient. @@ -33,7 +33,7 @@ def get_weather( return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." -async def non_streaming_example(agent: ChatAgent) -> None: +async def non_streaming_example(agent: Agent) -> None: """Example of non-streaming response (get the complete result at once).""" print("=== Non-streaming Response Example ===") @@ -43,7 +43,7 @@ async def non_streaming_example(agent: ChatAgent) -> None: print(f"Agent: {result}\n") -async def streaming_example(agent: ChatAgent) -> None: +async def streaming_example(agent: Agent) -> None: """Example of streaming response (get results as they are generated).""" print("=== Streaming Response Example ===") 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 324c94630e..88b9fb4260 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -3,9 +3,9 @@ from unittest.mock import MagicMock, patch import pytest -from agent_framework import ChatClientProtocol -from agent_framework.exceptions import ServiceInitializationError -from pydantic import ValidationError +from agent_framework import SupportsChatGetResponse +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 @@ -55,7 +67,7 @@ def test_foundry_local_client_init(mock_foundry_local_manager: MagicMock) -> Non assert client.model_id == "test-model-id" assert client.manager is mock_foundry_local_manager - assert isinstance(client, ChatClientProtocol) + assert isinstance(client, SupportsChatGetResponse) def test_foundry_local_client_init_with_bootstrap_false(mock_foundry_local_manager: MagicMock) -> None: 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 46a92a6dc9..42de197014 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -13,17 +13,18 @@ from agent_framework import ( AgentMiddlewareTypes, AgentResponse, AgentResponseUpdate, - AgentThread, + AgentSession, BaseAgent, - ChatMessage, + BaseContextProvider, Content, - ContextProvider, + Message, ResponseStream, normalize_messages, ) -from agent_framework._tools import FunctionTool, ToolProtocol +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 @@ -91,15 +91,15 @@ class GitHubCopilotOptions(TypedDict, total=False): """ -TOptions = TypeVar( - "TOptions", +OptionsT = TypeVar( + "OptionsT", bound=TypedDict, # type: ignore[valid-type] default="GitHubCopilotOptions", covariant=True, ) -class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): +class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): """A GitHub Copilot Agent. This agent wraps the GitHub Copilot SDK to provide Copilot agentic capabilities @@ -149,14 +149,14 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): id: str | None = None, name: str | None = None, description: str | None = None, - context_provider: ContextProvider | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, middleware: Sequence[AgentMiddlewareTypes] | None = None, - tools: ToolProtocol + tools: FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions | None = None, + default_options: OptionsT | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -171,9 +171,9 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): id: ID of the GitHubCopilotAgent. name: Name of the GitHubCopilotAgent. description: Description of the GitHubCopilotAgent. - context_provider: Context Provider, to be used by the agent. + context_providers: Context Providers, to be used by the agent. middleware: Agent middleware used by the agent. - tools: Tools to use for the agent. Can be functions, ToolProtocol instances, + tools: Tools to use for the agent. Can be functions or tool definition dicts. These are converted to Copilot SDK tools internally. default_options: Default options for the agent. Can include cli_path, model, timeout, log_level, etc. @@ -187,7 +187,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): id=id, name=name, description=description, - context_provider=context_provider, + context_providers=context_providers, middleware=list(middleware) if middleware else None, ) @@ -207,17 +207,16 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): 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 @@ -225,7 +224,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): self._default_options = opts self._started = False - async def __aenter__(self) -> GitHubCopilotAgent[TOptions]: + async def __aenter__(self) -> GitHubCopilotAgent[OptionsT]: """Start the agent when entering async context.""" await self.start() return self @@ -249,10 +248,10 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): 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) @@ -278,32 +277,32 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): @overload def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[False] = False, - thread: AgentThread | None = None, - options: TOptions | None = None, + session: AgentSession | None = None, + options: OptionsT | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse]: ... @overload def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: Literal[True], - thread: AgentThread | None = None, - options: TOptions | None = None, + session: AgentSession | None = None, + options: OptionsT | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ... def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, - options: TOptions | None = None, + session: AgentSession | None = None, + options: OptionsT | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: """Get a response from the agent. @@ -317,7 +316,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): Keyword Args: stream: Whether to stream the response. Defaults to False. - thread: The conversation thread associated with the message(s). + session: The conversation session associated with the message(s). options: Runtime options (model, timeout, etc.). kwargs: Additional keyword arguments. @@ -334,39 +333,39 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): return AgentResponse.from_updates(updates) return ResponseStream( - self._stream_updates(messages=messages, thread=thread, options=options, **kwargs), + self._stream_updates(messages=messages, session=session, options=options, **kwargs), finalizer=_finalize, ) - return self._run_impl(messages=messages, thread=thread, options=options, **kwargs) + return self._run_impl(messages=messages, session=session, options=options, **kwargs) async def _run_impl( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, - thread: AgentThread | None = None, - options: TOptions | None = None, + session: AgentSession | None = None, + options: OptionsT | None = None, **kwargs: Any, ) -> AgentResponse: """Non-streaming implementation of run.""" if not self._started: await self.start() - if not thread: - thread = self.get_new_thread() + if not session: + session = self.create_session() 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) + copilot_session = await self._get_or_create_session(session, streaming=False, runtime_options=opts) input_messages = normalize_messages(messages) prompt = "\n".join([message.text for message in input_messages]) try: - response_event = await session.send_and_wait({"prompt": prompt}, timeout=timeout) + response_event = await copilot_session.send_and_wait({"prompt": prompt}, timeout=timeout) except Exception as ex: raise ServiceException(f"GitHub Copilot request failed: {ex}") from ex - response_messages: list[ChatMessage] = [] + response_messages: list[Message] = [] response_id: str | None = None # send_and_wait returns only the final ASSISTANT_MESSAGE event; @@ -376,7 +375,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): if response_event.data.content: response_messages.append( - ChatMessage( + Message( role="assistant", contents=[Content.from_text(response_event.data.content)], message_id=message_id, @@ -389,10 +388,10 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): async def _stream_updates( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, - thread: AgentThread | None = None, - options: TOptions | None = None, + session: AgentSession | None = None, + options: OptionsT | None = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseUpdate]: """Internal method to stream updates from GitHub Copilot. @@ -401,7 +400,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): messages: The message(s) to send to the agent. Keyword Args: - thread: The conversation thread associated with the message(s). + session: The conversation session associated with the message(s). options: Runtime options (model, timeout, etc.). kwargs: Additional keyword arguments. @@ -414,12 +413,12 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): if not self._started: await self.start() - if not thread: - thread = self.get_new_thread() + if not session: + session = self.create_session() opts: dict[str, Any] = dict(options) if options else {} - session = await self._get_or_create_session(thread, streaming=True, runtime_options=opts) + copilot_session = await self._get_or_create_session(session, streaming=True, runtime_options=opts) input_messages = normalize_messages(messages) prompt = "\n".join([message.text for message in input_messages]) @@ -442,10 +441,10 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): error_msg = event.data.message or "Unknown error" queue.put_nowait(ServiceException(f"GitHub Copilot session error: {error_msg}")) - unsubscribe = session.on(event_handler) + unsubscribe = copilot_session.on(event_handler) try: - await session.send({"prompt": prompt}) + await copilot_session.send({"prompt": prompt}) while (item := await queue.get()) is not None: if isinstance(item, Exception): @@ -479,7 +478,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): def _prepare_tools( self, - tools: list[ToolProtocol | MutableMapping[str, Any]], + tools: list[FunctionTool | MutableMapping[str, Any]], ) -> list[CopilotTool]: """Convert Agent Framework tools to Copilot SDK tools. @@ -492,18 +491,15 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): copilot_tools: list[CopilotTool] = [] for tool in tools: - if isinstance(tool, ToolProtocol): - match tool: - case FunctionTool(): - copilot_tools.append(self._tool_to_copilot_tool(tool)) # type: ignore - case _: - logger.debug(f"Unsupported tool type: {type(tool)}") + if isinstance(tool, FunctionTool): + copilot_tools.append(self._tool_to_copilot_tool(tool)) # type: ignore elif isinstance(tool, CopilotTool): copilot_tools.append(tool) + # Note: Other tool types (e.g., dict-based hosted tools) are skipped return copilot_tools - def _tool_to_copilot_tool(self, ai_func: FunctionTool[Any, Any]) -> CopilotTool: + def _tool_to_copilot_tool(self, ai_func: FunctionTool[Any]) -> CopilotTool: """Convert an FunctionTool to a Copilot SDK tool.""" async def handler(invocation: ToolInvocation) -> ToolResult: @@ -534,14 +530,14 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): async def _get_or_create_session( self, - thread: AgentThread, + agent_session: AgentSession, streaming: bool = False, runtime_options: dict[str, Any] | None = None, ) -> CopilotSession: - """Get an existing session or create a new one for the thread. + """Get an existing session or create a new one for the session. Args: - thread: The conversation thread. + agent_session: The conversation session. streaming: Whether to enable streaming for the session. runtime_options: Runtime options from run that take precedence. @@ -555,11 +551,11 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): raise ServiceException("GitHub Copilot client not initialized. Call start() first.") try: - if thread.service_thread_id: - return await self._resume_session(thread.service_thread_id, streaming) + if agent_session.service_session_id: + return await self._resume_session(agent_session.service_session_id, streaming) session = await self._create_session(streaming, runtime_options) - thread.service_thread_id = session.session_id + agent_session.service_session_id = session.session_id return session except Exception as ex: raise ServiceException(f"Failed to create GitHub Copilot session: {ex}") from ex @@ -581,7 +577,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): 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/pyproject.toml b/python/packages/github_copilot/pyproject.toml index 57e3c536f8..afda5dd17d 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "github-copilot-sdk>=0.1.0", ] 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 ed302b5bb6..c9bca2d0a6 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -10,9 +10,9 @@ import pytest from agent_framework import ( AgentResponse, AgentResponseUpdate, - AgentThread, - ChatMessage, + AgentSession, Content, + Message, ) from agent_framework.exceptions import ServiceException from copilot.generated.session_events import Data, SessionEvent, SessionEventType @@ -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.""" @@ -290,31 +290,31 @@ class TestGitHubCopilotAgentRun: mock_session: MagicMock, assistant_message_event: SessionEvent, ) -> None: - """Test run method with ChatMessage.""" + """Test run method with Message.""" mock_session.send_and_wait.return_value = assistant_message_event agent = GitHubCopilotAgent(client=mock_client) - chat_message = ChatMessage(role="user", contents=[Content.from_text("Hello")]) + chat_message = Message(role="user", contents=[Content.from_text("Hello")]) response = await agent.run(chat_message) assert isinstance(response, AgentResponse) assert len(response.messages) == 1 - async def test_run_with_thread( + async def test_run_with_session( self, mock_client: MagicMock, mock_session: MagicMock, assistant_message_event: SessionEvent, ) -> None: - """Test run method with existing thread.""" + """Test run method with existing session.""" mock_session.send_and_wait.return_value = assistant_message_event agent = GitHubCopilotAgent(client=mock_client) - thread = AgentThread() - response = await agent.run("Hello", thread=thread) + session = AgentSession() + response = await agent.run("Hello", session=session) assert isinstance(response, AgentResponse) - assert thread.service_thread_id == mock_session.session_id + assert session.service_session_id == mock_session.session_id async def test_run_with_runtime_options( self, @@ -392,13 +392,13 @@ class TestGitHubCopilotAgentRunStreaming: assert responses[0].role == "assistant" assert responses[0].contents[0].text == "Hello" - async def test_run_streaming_with_thread( + async def test_run_streaming_with_session( self, mock_client: MagicMock, mock_session: MagicMock, session_idle_event: SessionEvent, ) -> None: - """Test streaming with existing thread.""" + """Test streaming with existing session.""" def mock_on(handler: Any) -> Any: handler(session_idle_event) @@ -407,12 +407,12 @@ class TestGitHubCopilotAgentRunStreaming: mock_session.on = mock_on agent = GitHubCopilotAgent(client=mock_client) - thread = AgentThread() + session = AgentSession() - async for _ in agent.run("Hello", thread=thread, stream=True): + async for _ in agent.run("Hello", session=session, stream=True): pass - assert thread.service_thread_id == mock_session.session_id + assert session.service_session_id == mock_session.session_id async def test_run_streaming_error( self, @@ -461,20 +461,20 @@ class TestGitHubCopilotAgentRunStreaming: class TestGitHubCopilotAgentSessionManagement: """Test cases for session management.""" - async def test_session_resumed_for_same_thread( + async def test_session_resumed_for_same_session( self, mock_client: MagicMock, mock_session: MagicMock, assistant_message_event: SessionEvent, ) -> None: - """Test that subsequent calls on the same thread resume the session.""" + """Test that subsequent calls on the same session resume the session.""" mock_session.send_and_wait.return_value = assistant_message_event agent = GitHubCopilotAgent(client=mock_client) - thread = AgentThread() + session = AgentSession() - await agent.run("Hello", thread=thread) - await agent.run("World", thread=thread) + await agent.run("Hello", session=session) + await agent.run("World", session=session) mock_client.create_session.assert_called_once() mock_client.resume_session.assert_called_once_with(mock_session.session_id, unittest.mock.ANY) @@ -490,7 +490,7 @@ class TestGitHubCopilotAgentSessionManagement: ) await agent.start() - await agent._get_or_create_session(AgentThread()) # type: ignore + await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] @@ -508,7 +508,7 @@ class TestGitHubCopilotAgentSessionManagement: ) await agent.start() - await agent._get_or_create_session(AgentThread()) # type: ignore + await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] @@ -531,7 +531,7 @@ class TestGitHubCopilotAgentSessionManagement: "system_message": {"mode": "replace", "content": "Runtime instructions"} } await agent._get_or_create_session( # type: ignore - AgentThread(), + AgentSession(), runtime_options=runtime_options, ) @@ -549,25 +549,25 @@ class TestGitHubCopilotAgentSessionManagement: agent = GitHubCopilotAgent(client=mock_client) await agent.start() - await agent._get_or_create_session(AgentThread(), streaming=True) # type: ignore + await agent._get_or_create_session(AgentSession(), streaming=True) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] assert config["streaming"] is True - async def test_resume_session_with_existing_service_thread_id( + async def test_resume_session_with_existing_service_session_id( self, mock_client: MagicMock, mock_session: MagicMock, ) -> None: - """Test that session is resumed when thread has a service_thread_id.""" + """Test that session is resumed when session has a service_session_id.""" agent = GitHubCopilotAgent(client=mock_client) await agent.start() - thread = AgentThread() - thread.service_thread_id = "existing-session-id" + session = AgentSession() + session.service_session_id = "existing-session-id" - await agent._get_or_create_session(thread) # type: ignore + await agent._get_or_create_session(session) # type: ignore mock_client.create_session.assert_not_called() mock_client.resume_session.assert_called_once() @@ -596,10 +596,10 @@ class TestGitHubCopilotAgentSessionManagement: ) await agent.start() - thread = AgentThread() - thread.service_thread_id = "existing-session-id" + session = AgentSession() + session.service_session_id = "existing-session-id" - await agent._get_or_create_session(thread) # type: ignore + await agent._get_or_create_session(session) # type: ignore mock_client.resume_session.assert_called_once() call_args = mock_client.resume_session.call_args @@ -639,7 +639,7 @@ class TestGitHubCopilotAgentMCPServers: ) await agent.start() - await agent._get_or_create_session(AgentThread()) # type: ignore + await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] @@ -672,10 +672,10 @@ class TestGitHubCopilotAgentMCPServers: ) await agent.start() - thread = AgentThread() - thread.service_thread_id = "existing-session-id" + session = AgentSession() + session.service_session_id = "existing-session-id" - await agent._get_or_create_session(thread) # type: ignore + await agent._get_or_create_session(session) # type: ignore mock_client.resume_session.assert_called_once() call_args = mock_client.resume_session.call_args @@ -692,7 +692,7 @@ class TestGitHubCopilotAgentMCPServers: agent = GitHubCopilotAgent(client=mock_client) await agent.start() - await agent._get_or_create_session(AgentThread()) # type: ignore + await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] @@ -716,7 +716,7 @@ class TestGitHubCopilotAgentToolConversion: agent = GitHubCopilotAgent(client=mock_client, tools=[my_tool]) await agent.start() - await agent._get_or_create_session(AgentThread()) # type: ignore + await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] @@ -739,7 +739,7 @@ class TestGitHubCopilotAgentToolConversion: agent = GitHubCopilotAgent(client=mock_client, tools=[my_tool]) await agent.start() - await agent._get_or_create_session(AgentThread()) # type: ignore + await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] @@ -764,7 +764,7 @@ class TestGitHubCopilotAgentToolConversion: agent = GitHubCopilotAgent(client=mock_client, tools=[failing_tool]) await agent.start() - await agent._get_or_create_session(AgentThread()) # type: ignore + await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] @@ -867,7 +867,7 @@ class TestGitHubCopilotAgentErrorHandling: await agent.start() with pytest.raises(ServiceException, match="Failed to create GitHub Copilot session"): - await agent._get_or_create_session(AgentThread()) # type: ignore + await agent._get_or_create_session(AgentSession()) # type: ignore async def test_get_or_create_session_raises_when_client_not_initialized(self) -> None: """Test that _get_or_create_session raises ServiceException when client is not initialized.""" @@ -875,7 +875,7 @@ class TestGitHubCopilotAgentErrorHandling: # Don't call start() - client remains None with pytest.raises(ServiceException, match="GitHub Copilot client not initialized"): - await agent._get_or_create_session(AgentThread()) # type: ignore + await agent._get_or_create_session(AgentSession()) # type: ignore class TestGitHubCopilotAgentPermissions: @@ -919,7 +919,7 @@ class TestGitHubCopilotAgentPermissions: ) await agent.start() - await agent._get_or_create_session(AgentThread()) # type: ignore + await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] @@ -935,7 +935,7 @@ class TestGitHubCopilotAgentPermissions: agent = GitHubCopilotAgent(client=mock_client) await agent.start() - await agent._get_or_create_session(AgentThread()) # type: ignore + await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args config = call_args[0][0] diff --git a/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py b/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py index d031e7e69f..08619b84bc 100644 --- a/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py +++ b/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py @@ -186,7 +186,7 @@ def gaia_scorer(model_answer: str, ground_truth: str) -> bool: if is_float(ground_truth): # numeric exact match after normalization - return _normalize_number_str(model_answer) == float(ground_truth) + return abs(_normalize_number_str(model_answer) - float(ground_truth)) < 1e-6 if any(ch in ground_truth for ch in [",", ";"]): # list with per-element compare (number or string) gt_elems = _split_string(ground_truth) @@ -196,7 +196,7 @@ def gaia_scorer(model_answer: str, ground_truth: str) -> bool: comparisons = [] for ma, gt in zip(ma_elems, gt_elems, strict=False): if is_float(gt): - comparisons.append(_normalize_number_str(ma) == float(gt)) + comparisons.append(abs(_normalize_number_str(ma) - float(gt)) < 1e-6) else: comparisons.append(_normalize_str(ma, remove_punct=False) == _normalize_str(gt, remove_punct=False)) return all(comparisons) diff --git a/python/packages/lab/gaia/samples/azure_ai_agent.py b/python/packages/lab/gaia/samples/azure_ai_agent.py index 3f64e3a684..f83625b2c4 100644 --- a/python/packages/lab/gaia/samples/azure_ai_agent.py +++ b/python/packages/lab/gaia/samples/azure_ai_agent.py @@ -26,13 +26,13 @@ Example: from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from agent_framework import ChatAgent, HostedCodeInterpreterTool, HostedWebSearchTool +from agent_framework import Agent from agent_framework.azure import AzureAIAgentClient from azure.identity.aio import AzureCliCredential @asynccontextmanager -async def create_gaia_agent() -> AsyncIterator[ChatAgent]: +async def create_gaia_agent() -> AsyncIterator[Agent]: """Create an Azure AI agent configured for GAIA benchmark tasks. The agent is configured with: @@ -40,7 +40,7 @@ async def create_gaia_agent() -> AsyncIterator[ChatAgent]: - Code Interpreter tool for calculations and data analysis Yields: - ChatAgent: A configured agent ready to run GAIA tasks. + Agent: A configured agent ready to run GAIA tasks. Example: async with create_gaia_agent() as agent: @@ -54,11 +54,8 @@ async def create_gaia_agent() -> AsyncIterator[ChatAgent]: instructions="Solve tasks to your best ability. Use Bing Search to find " "information and Code Interpreter to perform calculations and data analysis.", tools=[ - HostedWebSearchTool( - name="Bing Grounding Search", - description="Search the web for current information using Bing", - ), - HostedCodeInterpreterTool(), + AzureAIAgentClient.get_web_search_tool(), + AzureAIAgentClient.get_code_interpreter_tool(), ], ) as agent, ): diff --git a/python/packages/lab/gaia/samples/openai_agent.py b/python/packages/lab/gaia/samples/openai_agent.py index 333c8d0931..a5709ecf2a 100644 --- a/python/packages/lab/gaia/samples/openai_agent.py +++ b/python/packages/lab/gaia/samples/openai_agent.py @@ -25,12 +25,12 @@ Example: from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from agent_framework import ChatAgent, HostedCodeInterpreterTool, HostedWebSearchTool +from agent_framework import Agent from agent_framework.openai import OpenAIResponsesClient @asynccontextmanager -async def create_gaia_agent() -> AsyncIterator[ChatAgent]: +async def create_gaia_agent() -> AsyncIterator[Agent]: """Create an OpenAI agent configured for GAIA benchmark tasks. Uses OpenAI Responses API for enhanced capabilities. @@ -40,25 +40,22 @@ async def create_gaia_agent() -> AsyncIterator[ChatAgent]: - Code Interpreter tool for calculations and data analysis Yields: - ChatAgent: A configured agent ready to run GAIA tasks. + Agent: A configured agent ready to run GAIA tasks. Example: async with create_gaia_agent() as agent: result = await agent.run("What is the capital of France?") print(result.text) """ - chat_client = OpenAIResponsesClient() + client = OpenAIResponsesClient() - async with chat_client.as_agent( + async with client.as_agent( name="GaiaAgent", instructions="Solve tasks to your best ability. Use Web Search to find " "information and Code Interpreter to perform calculations and data analysis.", tools=[ - HostedWebSearchTool( - name="Web Search", - description="Search the web for current information", - ), - HostedCodeInterpreterTool(), + OpenAIResponsesClient.get_web_search_tool(), + OpenAIResponsesClient.get_code_interpreter_tool(), ], ) as agent: yield agent diff --git a/python/packages/lab/lightning/README.md b/python/packages/lab/lightning/README.md index 05d3691d74..e9fd3ec91d 100644 --- a/python/packages/lab/lightning/README.md +++ b/python/packages/lab/lightning/README.md @@ -49,8 +49,8 @@ async def math_agent(task: TaskType, llm: LLM) -> float: """A function that solves a math problem and returns the evaluation score.""" async with ( MCPStdioTool(name="calculator", command="uvx", args=["mcp-server-calculator"]) as mcp_server, - ChatAgent( - chat_client=OpenAIChatClient( + Agent( + client=OpenAIChatClient( model_id=llm.model, api_key="your-api-key", base_url=llm.endpoint, diff --git a/python/packages/lab/lightning/samples/train_math_agent.py b/python/packages/lab/lightning/samples/train_math_agent.py index 0cb771e856..f702b5a631 100644 --- a/python/packages/lab/lightning/samples/train_math_agent.py +++ b/python/packages/lab/lightning/samples/train_math_agent.py @@ -20,7 +20,7 @@ import string from typing import TypedDict, cast import sympy # type: ignore[import-untyped,reportMissingImports] -from agent_framework import AgentResponse, ChatAgent, MCPStdioTool +from agent_framework import Agent, AgentResponse, MCPStdioTool from agent_framework.lab.lightning import AgentFrameworkTracer from agent_framework.openai import OpenAIChatClient from agentlightning import LLM, Dataset, Trainer, rollout @@ -166,8 +166,8 @@ async def math_agent(task: MathProblem, llm: LLM) -> float: # MCPStdioTool provides calculator functionality via MCP protocol async with ( MCPStdioTool(name="calculator", command="uvx", args=["mcp-server-calculator"]) as mcp_server, - ChatAgent( - chat_client=OpenAIChatClient( + Agent( + client=OpenAIChatClient( model_id=llm.model, # This is the model being trained api_key=os.getenv("OPENAI_API_KEY") or "dummy", # Can be dummy when connecting to training LLM base_url=llm.endpoint, # vLLM server endpoint provided by agent-lightning diff --git a/python/packages/lab/lightning/tests/test_lightning.py b/python/packages/lab/lightning/tests/test_lightning.py index c528bd8d78..76e6b98506 100644 --- a/python/packages/lab/lightning/tests/test_lightning.py +++ b/python/packages/lab/lightning/tests/test_lightning.py @@ -9,7 +9,7 @@ import pytest agentlightning = pytest.importorskip("agentlightning") -from agent_framework import AgentExecutor, AgentResponse, ChatAgent, WorkflowBuilder, Workflow +from agent_framework import AgentExecutor, AgentResponse, Agent, WorkflowBuilder, Workflow from agent_framework_lab_lightning import AgentFrameworkTracer from agent_framework.openai import OpenAIChatClient from agentlightning import TracerTraceToTriplet @@ -80,14 +80,14 @@ def workflow_two_agents(): ), ): # Create the two agents - analyzer_agent = ChatAgent( - chat_client=first_chat_client, + analyzer_agent = Agent( + client=first_chat_client, name="DataAnalyzer", instructions="You are a data analyst. Analyze the given data and provide insights.", ) - advisor_agent = ChatAgent( - chat_client=second_chat_client, + advisor_agent = Agent( + client=second_chat_client, name="InvestmentAdvisor", instructions="You are an investment advisor. Based on analysis results, provide recommendations.", ) diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index 9431560d59..7c7647aad5 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework" authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", ] [project.optional-dependencies] diff --git a/python/packages/lab/tau2/README.md b/python/packages/lab/tau2/README.md index a0b587ea3c..083fd05a9d 100644 --- a/python/packages/lab/tau2/README.md +++ b/python/packages/lab/tau2/README.md @@ -138,21 +138,21 @@ export OPENAI_BASE_URL="https://your-custom-endpoint.com/v1" ```python from agent_framework.lab.tau2 import TaskRunner -from agent_framework import ChatAgent +from agent_framework import Agent class CustomTaskRunner(TaskRunner): def assistant_agent(self, assistant_chat_client): # Override to customize the assistant agent - return ChatAgent( - chat_client=assistant_chat_client, + return Agent( + client=assistant_chat_client, instructions="Your custom system prompt here", # Add custom tools, temperature, etc. ) def user_simulator(self, user_chat_client, task): # Override to customize the user simulator - return ChatAgent( - chat_client=user_chat_client, + return Agent( + client=user_chat_client, instructions="Custom user simulator prompt", ) ``` diff --git a/python/packages/lab/tau2/agent_framework_lab_tau2/_message_utils.py b/python/packages/lab/tau2/agent_framework_lab_tau2/_message_utils.py index dccf6e2882..bd8d521e28 100644 --- a/python/packages/lab/tau2/agent_framework_lab_tau2/_message_utils.py +++ b/python/packages/lab/tau2/agent_framework_lab_tau2/_message_utils.py @@ -2,7 +2,7 @@ from typing import Any -from agent_framework._types import ChatMessage, Content +from agent_framework._types import Content, Message from loguru import logger @@ -11,7 +11,7 @@ def _get_role_value(role: Any) -> str: return role.value if hasattr(role, "value") else str(role) -def flip_messages(messages: list[ChatMessage]) -> list[ChatMessage]: +def flip_messages(messages: list[Message]) -> list[Message]: """Flip message roles between assistant and user for role-playing scenarios. Used in agent simulations where the assistant's messages become user inputs @@ -30,7 +30,7 @@ def flip_messages(messages: list[ChatMessage]) -> list[ChatMessage]: # Flip assistant to user contents = filter_out_function_calls(msg.contents) if contents: - flipped_msg = ChatMessage( + flipped_msg = Message( role="user", # The function calls will cause 400 when role is user contents=contents, @@ -40,7 +40,7 @@ def flip_messages(messages: list[ChatMessage]) -> list[ChatMessage]: flipped_messages.append(flipped_msg) elif role_value == "user": # Flip user to assistant - flipped_msg = ChatMessage( + flipped_msg = Message( role="assistant", contents=msg.contents, author_name=msg.author_name, message_id=msg.message_id ) flipped_messages.append(flipped_msg) @@ -53,7 +53,7 @@ def flip_messages(messages: list[ChatMessage]) -> list[ChatMessage]: return flipped_messages -def log_messages(messages: list[ChatMessage]) -> None: +def log_messages(messages: list[Message]) -> None: """Log messages with colored output based on role and content type. Provides visual debugging by color-coding different message roles and diff --git a/python/packages/lab/tau2/agent_framework_lab_tau2/_sliding_window.py b/python/packages/lab/tau2/agent_framework_lab_tau2/_sliding_window.py index 20a3a2fe27..1be7a7a318 100644 --- a/python/packages/lab/tau2/agent_framework_lab_tau2/_sliding_window.py +++ b/python/packages/lab/tau2/agent_framework_lab_tau2/_sliding_window.py @@ -1,63 +1,57 @@ # Copyright (c) Microsoft. All rights reserved. import json -from collections.abc import Sequence from typing import Any import tiktoken -from agent_framework import ChatMessage, ChatMessageStore +from agent_framework import InMemoryHistoryProvider, Message from loguru import logger -class SlidingWindowChatMessageStore(ChatMessageStore): - """A token-aware sliding window implementation of ChatMessageStore. +class SlidingWindowHistoryProvider(InMemoryHistoryProvider): + """A token-aware sliding window implementation of InMemoryHistoryProvider. - Maintains two message lists: complete history and truncated window. - Automatically removes oldest messages when token limit is exceeded. - Also removes leading tool messages to ensure valid conversation flow. + Stores all messages in session state but returns a truncated window from + ``get_messages`` that fits within ``max_tokens``. Automatically removes + oldest messages and leading tool messages to ensure valid conversation flow. """ def __init__( self, - messages: Sequence[ChatMessage] | None = None, + source_id: str = "memory", + *, max_tokens: int = 3800, system_message: str | None = None, tool_definitions: Any | None = None, ): - super().__init__(messages=messages) - self.truncated_messages = self.messages.copy() + super().__init__(source_id) self.max_tokens = max_tokens self.system_message = system_message # Included in token count self.tool_definitions = tool_definitions # An estimation based on a commonly used vocab table self.encoding = tiktoken.get_encoding("o200k_base") - async def add_messages(self, messages: Sequence[ChatMessage]) -> None: - await super().add_messages(messages) + async def get_messages( + self, session_id: str | None, *, state: dict[str, Any] | None = None, **kwargs: Any + ) -> list[Message]: + """Retrieve messages from session state, truncated to fit within max_tokens.""" + all_messages = await super().get_messages(session_id, state=state, **kwargs) + return self._truncate(list(all_messages)) - self.truncated_messages = self.messages.copy() - self.truncate_messages() - - async def list_messages(self) -> list[ChatMessage]: - """Get the current list of messages, which may be truncated.""" - return self.truncated_messages - - async def list_all_messages(self) -> list[ChatMessage]: - """Get all messages from the store including the truncated ones.""" - return self.messages - - def truncate_messages(self) -> None: - while len(self.truncated_messages) > 0 and self.get_token_count() > self.max_tokens: + def _truncate(self, messages: list[Message]) -> list[Message]: + """Truncate messages to fit within max_tokens and remove leading tool messages.""" + while len(messages) > 0 and self._get_token_count(messages) > self.max_tokens: logger.warning("Messages exceed max tokens. Truncating oldest message.") - self.truncated_messages.pop(0) + messages.pop(0) # Remove leading tool messages - while len(self.truncated_messages) > 0: - if self.truncated_messages[0].role != "tool": + while len(messages) > 0: + if messages[0].role != "tool": break logger.warning("Removing leading tool message because tool result cannot be the first message.") - self.truncated_messages.pop(0) + messages.pop(0) + return messages - def get_token_count(self) -> int: + def _get_token_count(self, messages: list[Message]) -> int: """Estimate token count for a list of messages using tiktoken. Returns: @@ -70,7 +64,7 @@ class SlidingWindowChatMessageStore(ChatMessageStore): total_tokens += len(self.encoding.encode(self.system_message)) total_tokens += 4 # Extra tokens for system message formatting - for msg in self.truncated_messages: + for msg in messages: # Add 4 tokens per message for role, formatting, etc. total_tokens += 4 @@ -87,7 +81,7 @@ class SlidingWindowChatMessageStore(ChatMessageStore): "name": content.name, "arguments": content.arguments, } - total_tokens += self.estimate_any_object_token_count(func_call_data) + total_tokens += self._estimate_any_object_token_count(func_call_data) elif content.type == "function_result": total_tokens += 4 # Serialize function result and count tokens @@ -95,19 +89,16 @@ class SlidingWindowChatMessageStore(ChatMessageStore): "call_id": content.call_id, "result": content.result, } - total_tokens += self.estimate_any_object_token_count(func_result_data) + total_tokens += self._estimate_any_object_token_count(func_result_data) else: # For other content types, serialize the whole content - total_tokens += self.estimate_any_object_token_count(content) + total_tokens += self._estimate_any_object_token_count(content) else: # Content without type, treat as text - total_tokens += self.estimate_any_object_token_count(content) + total_tokens += self._estimate_any_object_token_count(content) elif hasattr(msg, "text") and msg.text: # Simple text message - total_tokens += self.estimate_any_object_token_count(msg.text) - else: - # Skip it - pass + total_tokens += self._estimate_any_object_token_count(msg.text) if total_tokens > self.max_tokens / 2: logger.opt(colors=True).warning( @@ -122,7 +113,7 @@ class SlidingWindowChatMessageStore(ChatMessageStore): return total_tokens - def estimate_any_object_token_count(self, obj: Any) -> int: + def _estimate_any_object_token_count(self, obj: Any) -> int: try: serialized = json.dumps(obj) except Exception: diff --git a/python/packages/lab/tau2/agent_framework_lab_tau2/_tau2_utils.py b/python/packages/lab/tau2/agent_framework_lab_tau2/_tau2_utils.py index 647dd8884a..42d03393f8 100644 --- a/python/packages/lab/tau2/agent_framework_lab_tau2/_tau2_utils.py +++ b/python/packages/lab/tau2/agent_framework_lab_tau2/_tau2_utils.py @@ -7,17 +7,19 @@ from typing import Any import numpy as np from agent_framework._tools import FunctionTool -from agent_framework._types import ChatMessage +from agent_framework._types import Message from loguru import logger from pydantic import BaseModel from tau2.data_model.message import ( # type: ignore[import-untyped] AssistantMessage, - Message, SystemMessage, ToolCall, ToolMessage, UserMessage, ) +from tau2.data_model.message import ( + Message as Tau2Message, +) from tau2.data_model.tasks import EnvFunctionCall, InitializationData # type: ignore[import-untyped] from tau2.environment.environment import Environment # type: ignore[import-untyped] from tau2.environment.tool import Tool # type: ignore[import-untyped] @@ -25,7 +27,7 @@ from tau2.environment.tool import Tool # type: ignore[import-untyped] _original_set_state = Environment.set_state -def convert_tau2_tool_to_function_tool(tau2_tool: Tool) -> FunctionTool[Any, Any]: +def convert_tau2_tool_to_function_tool(tau2_tool: Tool) -> FunctionTool[Any]: """Convert a tau2 Tool to a FunctionTool for agent framework compatibility. Creates a wrapper that preserves the tool's interface while ensuring @@ -45,7 +47,7 @@ def convert_tau2_tool_to_function_tool(tau2_tool: Tool) -> FunctionTool[Any, Any ) -def convert_agent_framework_messages_to_tau2_messages(messages: list[ChatMessage]) -> list[Message]: +def convert_agent_framework_messages_to_tau2_messages(messages: list[Message]) -> list[Tau2Message]: """Convert agent framework ChatMessages to tau2 Message objects. Handles role mapping, text extraction, function calls, and function results. @@ -119,13 +121,13 @@ def patch_env_set_state() -> None: self: Any, initialization_data: InitializationData | None, initialization_actions: list[EnvFunctionCall] | None, - message_history: list[Message], + message_history: list[Tau2Message], ) -> None: if self.solo_mode and any(isinstance(message, UserMessage) for message in message_history): raise ValueError("User messages are not allowed in solo mode") def get_actions_from_messages( - messages: list[Message], + messages: list[Tau2Message], ) -> list[tuple[ToolCall, ToolMessage]]: """Get the actions from the messages.""" messages = deepcopy(messages)[::-1] diff --git a/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py b/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py index 326aaf0748..9437bbc08a 100644 --- a/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py +++ b/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py @@ -3,17 +3,17 @@ from __future__ import annotations import uuid -from typing import cast +from typing import Any from agent_framework import ( + Agent, AgentExecutor, AgentExecutorRequest, AgentExecutorResponse, AgentResponse, - ChatAgent, - ChatClientProtocol, - ChatMessage, FunctionExecutor, + Message, + SupportsChatGetResponse, Workflow, WorkflowBuilder, WorkflowContext, @@ -32,7 +32,7 @@ from tau2.user.user_simulator import ( # type: ignore[import-untyped] from tau2.utils.utils import get_now # type: ignore[import-untyped] from ._message_utils import flip_messages, log_messages -from ._sliding_window import SlidingWindowChatMessageStore +from ._sliding_window import SlidingWindowHistoryProvider from ._tau2_utils import convert_agent_framework_messages_to_tau2_messages, convert_tau2_tool_to_function_tool __all__ = ["ASSISTANT_AGENT_ID", "ORCHESTRATOR_ID", "USER_SIMULATOR_ID", "TaskRunner"] @@ -67,10 +67,10 @@ class TaskRunner: # State tracking step_count: int - full_conversation: list[ChatMessage] + full_conversation: list[Message] termination_reason: TerminationReason | None full_reward_info: RewardInfo | None - _final_user_message: list[ChatMessage] | None + _final_user_message: list[Message] | None _assistant_executor: AgentExecutor | None _user_executor: AgentExecutor | None @@ -159,7 +159,7 @@ class TaskRunner: """Check if user wants to stop the conversation.""" return STOP in text or TRANSFER in text or OUT_OF_SCOPE in text - def assistant_agent(self, assistant_chat_client: ChatClientProtocol) -> ChatAgent: + def assistant_agent(self, assistant_chat_client: SupportsChatGetResponse) -> Agent: """Create an assistant agent. Users can override this method to provide a custom assistant agent. @@ -196,19 +196,21 @@ class TaskRunner: # - Access to all domain tools (booking, cancellation, etc.) # - Sliding window memory to handle long conversations within token limits # - Temperature-controlled response generation - return ChatAgent( - chat_client=assistant_chat_client, + return Agent( + client=assistant_chat_client, instructions=assistant_system_prompt, tools=tools, temperature=self.assistant_sampling_temperature, - chat_message_store_factory=lambda: SlidingWindowChatMessageStore( - system_message=assistant_system_prompt, - tool_definitions=[tool.openai_schema for tool in tools], - max_tokens=self.assistant_window_size, - ), + context_providers=[ + SlidingWindowHistoryProvider( + system_message=assistant_system_prompt, + tool_definitions=[tool.openai_schema for tool in tools], + max_tokens=self.assistant_window_size, + ) + ], ) - def user_simulator(self, user_simuator_chat_client: ChatClientProtocol, task: Task) -> ChatAgent: + def user_simulator(self, user_simuator_chat_client: SupportsChatGetResponse, task: Task) -> Agent: """Create a user simulator agent. Users can override this method to provide a custom user simulator agent. @@ -230,8 +232,8 @@ class TaskRunner: {task.user_scenario.instructions} """ - return ChatAgent( - chat_client=user_simuator_chat_client, + return Agent( + client=user_simuator_chat_client, instructions=user_sim_system_prompt, temperature=0.0, # No sliding window for user simulator to maintain full conversation context @@ -268,7 +270,7 @@ class TaskRunner: target_id=USER_SIMULATOR_ID if is_from_agent else ASSISTANT_AGENT_ID, ) - def build_conversation_workflow(self, assistant_agent: ChatAgent, user_simulator_agent: ChatAgent) -> Workflow: + def build_conversation_workflow(self, assistant_agent: Agent, user_simulator_agent: Agent) -> Workflow: """Build the conversation workflow. Users can override this method to provide a custom conversation workflow. @@ -304,9 +306,9 @@ class TaskRunner: async def run( self, task: Task, - assistant_chat_client: ChatClientProtocol, - user_simulator_chat_client: ChatClientProtocol, - ) -> list[ChatMessage]: + assistant_chat_client: SupportsChatGetResponse, + user_simulator_chat_client: SupportsChatGetResponse, + ) -> list[Message]: """Run a tau2 task using workflow-based agent orchestration. This method orchestrates a complex multi-agent simulation: @@ -323,7 +325,7 @@ class TaskRunner: user_simulator_chat_client: LLM client for the user simulator Returns: - Complete conversation history as ChatMessage list for evaluation + Complete conversation history as Message list for evaluation """ logger.info(f"Starting workflow agent for task {task.id}: {task.description.purpose}") # type: ignore[unused-ignore] logger.info(f"Assistant chat client: {assistant_chat_client}") @@ -340,11 +342,11 @@ class TaskRunner: # Matches tau2's expected conversation start pattern logger.info(f"Starting workflow with hardcoded greeting: '{DEFAULT_FIRST_AGENT_MESSAGE}'") - first_message = ChatMessage(role="assistant", text=DEFAULT_FIRST_AGENT_MESSAGE) + first_message = Message(role="assistant", text=DEFAULT_FIRST_AGENT_MESSAGE) initial_greeting = AgentExecutorResponse( executor_id=ASSISTANT_AGENT_ID, agent_response=AgentResponse(messages=[first_message]), - full_conversation=[ChatMessage(role="assistant", text=DEFAULT_FIRST_AGENT_MESSAGE)], + full_conversation=[Message(role="assistant", text=DEFAULT_FIRST_AGENT_MESSAGE)], ) # STEP 4: Execute the workflow and collect results @@ -354,11 +356,11 @@ class TaskRunner: # STEP 5: Ensemble the conversation history needed for evaluation. # It's coming from three parts: # 1. The initial greeting - # 2. The assistant's message store (not just the truncated window) + # 2. The assistant's session state (full history, not just the truncated window) # 3. The final user message (if any) - assistant_executor = cast(AgentExecutor, self._assistant_executor) - message_store = cast(SlidingWindowChatMessageStore, assistant_executor._agent_thread.message_store) - full_conversation = [first_message] + await message_store.list_all_messages() + session_state: dict[str, Any] = self._assistant_executor._session.state # type: ignore + all_messages: list[Message] = list(session_state.get("memory", {}).get("messages", [])) # type: ignore + full_conversation = [first_message, *all_messages] if self._final_user_message is not None: full_conversation.extend(self._final_user_message) @@ -371,7 +373,7 @@ class TaskRunner: return full_conversation def evaluate( - self, task_input: Task, conversation: list[ChatMessage], termination_reason: TerminationReason | None + self, task_input: Task, conversation: list[Message], termination_reason: TerminationReason | None ) -> float: """Evaluate agent performance using tau2's comprehensive evaluation system. diff --git a/python/packages/lab/tau2/tests/test_message_utils.py b/python/packages/lab/tau2/tests/test_message_utils.py index 7bee8bc9be..8908140f94 100644 --- a/python/packages/lab/tau2/tests/test_message_utils.py +++ b/python/packages/lab/tau2/tests/test_message_utils.py @@ -2,14 +2,14 @@ from unittest.mock import patch -from agent_framework._types import ChatMessage, Content +from agent_framework._types import Content, Message from agent_framework_lab_tau2._message_utils import flip_messages, log_messages def test_flip_messages_user_to_assistant(): """Test flipping user message to assistant.""" messages = [ - ChatMessage( + Message( role="user", contents=[Content.from_text(text="Hello assistant")], author_name="User1", @@ -29,7 +29,7 @@ def test_flip_messages_user_to_assistant(): def test_flip_messages_assistant_to_user(): """Test flipping assistant message to user.""" messages = [ - ChatMessage( + Message( role="assistant", contents=[Content.from_text(text="Hello user")], author_name="Assistant1", @@ -51,7 +51,7 @@ def test_flip_messages_assistant_with_function_calls_filtered(): function_call = Content.from_function_call(call_id="call_123", name="test_function", arguments={"param": "value"}) messages = [ - ChatMessage( + Message( role="assistant", contents=[ Content.from_text(text="I'll call a function"), @@ -78,7 +78,7 @@ def test_flip_messages_assistant_with_only_function_calls_skipped(): function_call = Content.from_function_call(call_id="call_456", name="another_function", arguments={"key": "value"}) messages = [ - ChatMessage(role="assistant", contents=[function_call], message_id="msg_004") # Only function call, no text + Message(role="assistant", contents=[function_call], message_id="msg_004") # Only function call, no text ] flipped = flip_messages(messages) @@ -91,7 +91,7 @@ def test_flip_messages_tool_messages_skipped(): """Test that tool messages are skipped.""" function_result = Content.from_function_result(call_id="call_789", result={"success": True}) - messages = [ChatMessage(role="tool", contents=[function_result])] + messages = [Message(role="tool", contents=[function_result])] flipped = flip_messages(messages) @@ -101,9 +101,7 @@ def test_flip_messages_tool_messages_skipped(): def test_flip_messages_system_messages_preserved(): """Test that system messages are preserved as-is.""" - messages = [ - ChatMessage(role="system", contents=[Content.from_text(text="System instruction")], message_id="sys_001") - ] + messages = [Message(role="system", contents=[Content.from_text(text="System instruction")], message_id="sys_001")] flipped = flip_messages(messages) @@ -120,11 +118,11 @@ def test_flip_messages_mixed_conversation(): function_result = Content.from_function_result(call_id="call_mixed", result="function result") messages = [ - ChatMessage(role="system", contents=[Content.from_text(text="System prompt")]), - ChatMessage(role="user", contents=[Content.from_text(text="User question")]), - ChatMessage(role="assistant", contents=[Content.from_text(text="Assistant response"), function_call]), - ChatMessage(role="tool", contents=[function_result]), - ChatMessage(role="assistant", contents=[Content.from_text(text="Final response")]), + Message(role="system", contents=[Content.from_text(text="System prompt")]), + Message(role="user", contents=[Content.from_text(text="User question")]), + Message(role="assistant", contents=[Content.from_text(text="Assistant response"), function_call]), + Message(role="tool", contents=[function_result]), + Message(role="assistant", contents=[Content.from_text(text="Final response")]), ] flipped = flip_messages(messages) @@ -159,7 +157,7 @@ def test_flip_messages_empty_list(): def test_flip_messages_preserves_metadata(): """Test that message metadata is preserved during flipping.""" messages = [ - ChatMessage( + Message( role="user", contents=[Content.from_text(text="Test message")], author_name="TestUser", @@ -178,8 +176,8 @@ def test_flip_messages_preserves_metadata(): def test_log_messages_text_content(mock_logger): """Test logging messages with text content.""" messages = [ - ChatMessage(role="user", contents=[Content.from_text(text="Hello")]), - ChatMessage(role="assistant", contents=[Content.from_text(text="Hi there!")]), + Message(role="user", contents=[Content.from_text(text="Hello")]), + Message(role="assistant", contents=[Content.from_text(text="Hi there!")]), ] log_messages(messages) @@ -193,7 +191,7 @@ def test_log_messages_function_call(mock_logger): """Test logging messages with function calls.""" function_call = Content.from_function_call(call_id="call_log", name="log_function", arguments={"param": "value"}) - messages = [ChatMessage(role="assistant", contents=[function_call])] + messages = [Message(role="assistant", contents=[function_call])] log_messages(messages) @@ -209,7 +207,7 @@ def test_log_messages_function_result(mock_logger): """Test logging messages with function results.""" function_result = Content.from_function_result(call_id="call_result", result="success") - messages = [ChatMessage(role="tool", contents=[function_result])] + messages = [Message(role="tool", contents=[function_result])] log_messages(messages) @@ -223,10 +221,10 @@ def test_log_messages_function_result(mock_logger): def test_log_messages_different_roles(mock_logger): """Test logging messages with different roles get different colors.""" messages = [ - ChatMessage(role="system", contents=[Content.from_text(text="System")]), - ChatMessage(role="user", contents=[Content.from_text(text="User")]), - ChatMessage(role="assistant", contents=[Content.from_text(text="Assistant")]), - ChatMessage(role="tool", contents=[Content.from_text(text="Tool")]), + Message(role="system", contents=[Content.from_text(text="System")]), + Message(role="user", contents=[Content.from_text(text="User")]), + Message(role="assistant", contents=[Content.from_text(text="Assistant")]), + Message(role="tool", contents=[Content.from_text(text="Tool")]), ] log_messages(messages) @@ -250,7 +248,7 @@ def test_log_messages_different_roles(mock_logger): @patch("agent_framework_lab_tau2._message_utils.logger") def test_log_messages_escapes_html(mock_logger): """Test that HTML-like characters are properly escaped in log output.""" - messages = [ChatMessage(role="user", contents=[Content.from_text(text="Message with content")])] + messages = [Message(role="user", contents=[Content.from_text(text="Message with content")])] log_messages(messages) @@ -266,7 +264,7 @@ def test_log_messages_mixed_content_types(mock_logger): function_call = Content.from_function_call(call_id="mixed_call", name="mixed_function", arguments={"key": "value"}) messages = [ - ChatMessage( + Message( role="assistant", contents=[Content.from_text(text="I'll call a function"), function_call, Content.from_text(text="Done!")], ) diff --git a/python/packages/lab/tau2/tests/test_sliding_window.py b/python/packages/lab/tau2/tests/test_sliding_window.py index 706bbf75c9..833474eb13 100644 --- a/python/packages/lab/tau2/tests/test_sliding_window.py +++ b/python/packages/lab/tau2/tests/test_sliding_window.py @@ -1,243 +1,181 @@ # Copyright (c) Microsoft. All rights reserved. -"""Tests for sliding window message list.""" +"""Tests for sliding window history provider.""" from unittest.mock import patch -from agent_framework._types import ChatMessage, Content -from agent_framework_lab_tau2._sliding_window import SlidingWindowChatMessageStore +from agent_framework._types import Content, Message +from agent_framework_lab_tau2._sliding_window import SlidingWindowHistoryProvider -def test_initialization_empty(): - """Test initializing with no messages.""" - sliding_window = SlidingWindowChatMessageStore(max_tokens=1000) - - assert sliding_window.max_tokens == 1000 - assert sliding_window.system_message is None - assert sliding_window.tool_definitions is None - assert len(sliding_window.messages) == 0 - assert len(sliding_window.truncated_messages) == 0 +def _make_state(provider: SlidingWindowHistoryProvider, messages: list[Message] | None = None) -> dict: + """Helper to create a session state dict with messages pre-loaded.""" + state: dict = {} + if messages: + state[provider.source_id] = {"messages": list(messages)} + return state -def test_initialization_with_parameters(): - """Test initializing with system message and tool definitions.""" - system_msg = "You are a helpful assistant" - tool_defs = [{"name": "test_tool", "description": "A test tool"}] - - sliding_window = SlidingWindowChatMessageStore( - max_tokens=2000, system_message=system_msg, tool_definitions=tool_defs +def test_initialization(): + """Test initializing with parameters.""" + provider = SlidingWindowHistoryProvider( + max_tokens=2000, + system_message="You are a helpful assistant", + tool_definitions=[{"name": "test_tool"}], ) - assert sliding_window.max_tokens == 2000 - assert sliding_window.system_message == system_msg - assert sliding_window.tool_definitions == tool_defs + assert provider.max_tokens == 2000 + assert provider.system_message == "You are a helpful assistant" + assert provider.tool_definitions == [{"name": "test_tool"}] + assert provider.source_id == "memory" -def test_initialization_with_messages(): - """Test initializing with existing messages.""" - messages = [ - ChatMessage(role="user", contents=[Content.from_text(text="Hello")]), - ChatMessage(role="assistant", contents=[Content.from_text(text="Hi there!")]), +async def test_get_messages_empty(): + """Test getting messages from empty state.""" + provider = SlidingWindowHistoryProvider(max_tokens=1000) + messages = await provider.get_messages(None, state={}) + assert messages == [] + + +async def test_get_messages_simple(): + """Test getting messages without truncation.""" + provider = SlidingWindowHistoryProvider(max_tokens=10000) + msgs = [ + Message(role="user", contents=[Content.from_text(text="What's the weather?")]), + Message(role="assistant", contents=[Content.from_text(text="I can help with that.")]), ] + state = _make_state(provider, msgs) - sliding_window = SlidingWindowChatMessageStore(messages=messages, max_tokens=1000) - - assert len(sliding_window.messages) == 2 - assert len(sliding_window.truncated_messages) == 2 + result = await provider.get_messages(None, state=state) + assert len(result) == 2 + assert result[0].text == "What's the weather?" + assert result[1].text == "I can help with that." -async def test_add_messages_simple(): - """Test adding messages without truncation.""" - sliding_window = SlidingWindowChatMessageStore(max_tokens=10000) # Large limit +async def test_save_and_get_messages(): + """Test saving then getting messages with truncation.""" + provider = SlidingWindowHistoryProvider(max_tokens=50) + state: dict = {} - new_messages = [ - ChatMessage(role="user", contents=[Content.from_text(text="What's the weather?")]), - ChatMessage(role="assistant", contents=[Content.from_text(text="I can help with that.")]), + # Save many messages + msgs = [ + Message(role="user", contents=[Content.from_text(text=f"Message {i} with some content")]) for i in range(10) ] + await provider.save_messages(None, msgs, state=state) - await sliding_window.add_messages(new_messages) + # get_messages returns truncated + truncated = await provider.get_messages(None, state=state) + # Full history is in session state + all_msgs = state[provider.source_id]["messages"] - messages = await sliding_window.list_messages() - assert len(messages) == 2 - assert messages[0].text == "What's the weather?" - assert messages[1].text == "I can help with that." - - -async def test_list_all_messages_vs_list_messages(): - """Test difference between list_all_messages and list_messages.""" - sliding_window = SlidingWindowChatMessageStore(max_tokens=50) # Small limit to force truncation - - # Add many messages to trigger truncation - messages = [ - ChatMessage(role="user", contents=[Content.from_text(text=f"Message {i} with some content")]) for i in range(10) - ] - - await sliding_window.add_messages(messages) - - truncated_messages = await sliding_window.list_messages() - all_messages = await sliding_window.list_all_messages() - - # All messages should contain everything - assert len(all_messages) == 10 - - # Truncated messages should be fewer due to token limit - assert len(truncated_messages) < len(all_messages) + assert len(all_msgs) == 10 + assert len(truncated) < len(all_msgs) def test_get_token_count_basic(): """Test basic token counting.""" - sliding_window = SlidingWindowChatMessageStore(max_tokens=1000) - sliding_window.truncated_messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])] + provider = SlidingWindowHistoryProvider(max_tokens=1000) + messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - token_count = sliding_window.get_token_count() - - # Should be more than 0 (exact count depends on encoding) + token_count = provider._get_token_count(messages) assert token_count > 0 def test_get_token_count_with_system_message(): """Test token counting includes system message.""" - system_msg = "You are a helpful assistant" - sliding_window = SlidingWindowChatMessageStore(max_tokens=1000, system_message=system_msg) + provider = SlidingWindowHistoryProvider(max_tokens=1000, system_message="You are a helpful assistant") - # Without messages - token_count_empty = sliding_window.get_token_count() + count_empty = provider._get_token_count([]) + count_with_msg = provider._get_token_count([Message(role="user", contents=[Content.from_text(text="Hello")])]) - # Add a message - sliding_window.truncated_messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])] - token_count_with_message = sliding_window.get_token_count() - - # With message should be more tokens - assert token_count_with_message > token_count_empty - assert token_count_empty > 0 # System message contributes tokens + assert count_with_msg > count_empty + assert count_empty > 0 # System message contributes tokens def test_get_token_count_function_call(): """Test token counting with function calls.""" function_call = Content.from_function_call(call_id="call_123", name="test_function", arguments={"param": "value"}) + provider = SlidingWindowHistoryProvider(max_tokens=1000) - sliding_window = SlidingWindowChatMessageStore(max_tokens=1000) - sliding_window.truncated_messages = [ChatMessage(role="assistant", contents=[function_call])] - - token_count = sliding_window.get_token_count() + token_count = provider._get_token_count([Message(role="assistant", contents=[function_call])]) assert token_count > 0 def test_get_token_count_function_result(): """Test token counting with function results.""" function_result = Content.from_function_result(call_id="call_123", result={"success": True, "data": "result"}) + provider = SlidingWindowHistoryProvider(max_tokens=1000) - sliding_window = SlidingWindowChatMessageStore(max_tokens=1000) - sliding_window.truncated_messages = [ChatMessage(role="tool", contents=[function_result])] - - token_count = sliding_window.get_token_count() + token_count = provider._get_token_count([Message(role="tool", contents=[function_result])]) assert token_count > 0 @patch("agent_framework_lab_tau2._sliding_window.logger") -def test_truncate_messages_removes_old_messages(mock_logger): +def test_truncate_removes_old_messages(mock_logger): """Test that truncation removes old messages when token limit exceeded.""" - sliding_window = SlidingWindowChatMessageStore(max_tokens=20) # Very small limit + provider = SlidingWindowHistoryProvider(max_tokens=20) - # Create messages that will exceed the limit messages = [ - ChatMessage( + Message( role="user", contents=[Content.from_text(text="This is a very long message that should exceed the token limit")], ), - ChatMessage( + Message( role="assistant", contents=[ Content.from_text(text="This is another very long message that should also exceed the token limit") ], ), - ChatMessage(role="user", contents=[Content.from_text(text="Short msg")]), + Message(role="user", contents=[Content.from_text(text="Short msg")]), ] - sliding_window.truncated_messages = messages.copy() - sliding_window.truncate_messages() - - # Should have fewer messages after truncation - assert len(sliding_window.truncated_messages) < len(messages) - - # Should have logged warnings + result = provider._truncate(list(messages)) + assert len(result) < len(messages) assert mock_logger.warning.called @patch("agent_framework_lab_tau2._sliding_window.logger") -def test_truncate_messages_removes_leading_tool_messages(mock_logger): +def test_truncate_removes_leading_tool_messages(mock_logger): """Test that truncation removes leading tool messages.""" - sliding_window = SlidingWindowChatMessageStore(max_tokens=10000) # Large limit + provider = SlidingWindowHistoryProvider(max_tokens=10000) - # Create messages starting with tool message - tool_message = ChatMessage( - role="tool", contents=[Content.from_function_result(call_id="call_123", result="result")] - ) - user_message = ChatMessage(role="user", contents=[Content.from_text(text="Hello")]) + tool_message = Message(role="tool", contents=[Content.from_function_result(call_id="call_123", result="result")]) + user_message = Message(role="user", contents=[Content.from_text(text="Hello")]) - sliding_window.truncated_messages = [tool_message, user_message] - sliding_window.truncate_messages() - - # Tool message should be removed from the beginning - assert len(sliding_window.truncated_messages) == 1 - assert sliding_window.truncated_messages[0].role == "user" - - # Should have logged warning about removing tool message + result = provider._truncate([tool_message, user_message]) + assert len(result) == 1 + assert result[0].role == "user" mock_logger.warning.assert_called() -def test_estimate_any_object_token_count_dict(): - """Test token counting for dictionary objects.""" - sliding_window = SlidingWindowChatMessageStore(max_tokens=1000) +def test_estimate_any_object_token_count(): + """Test token counting for various object types.""" + provider = SlidingWindowHistoryProvider(max_tokens=1000) - test_dict = {"key": "value", "number": 42} - token_count = sliding_window.estimate_any_object_token_count(test_dict) + assert provider._estimate_any_object_token_count({"key": "value"}) > 0 + assert provider._estimate_any_object_token_count("test string") > 0 - assert token_count > 0 - - -def test_estimate_any_object_token_count_string(): - """Test token counting for string objects.""" - sliding_window = SlidingWindowChatMessageStore(max_tokens=1000) - - test_string = "This is a test string" - token_count = sliding_window.estimate_any_object_token_count(test_string) - - assert token_count > 0 - - -def test_estimate_any_object_token_count_non_serializable(): - """Test token counting for non-JSON-serializable objects.""" - sliding_window = SlidingWindowChatMessageStore(max_tokens=1000) - - # Create an object that can't be JSON serialized - class CustomObject: + # Non-serializable falls back to str() + class Custom: def __str__(self): - return "CustomObject instance" + return "Custom instance" - custom_obj = CustomObject() - token_count = sliding_window.estimate_any_object_token_count(custom_obj) - - # Should fall back to string representation - assert token_count > 0 + assert provider._estimate_any_object_token_count(Custom()) > 0 async def test_real_world_scenario(): """Test a realistic conversation scenario.""" - sliding_window = SlidingWindowChatMessageStore( - max_tokens=30, - system_message="You are a helpful assistant", # Moderate limit - ) + provider = SlidingWindowHistoryProvider(max_tokens=30, system_message="You are a helpful assistant") + state: dict = {} - # Simulate a conversation conversation = [ - ChatMessage(role="user", contents=[Content.from_text(text="Hello, how are you?")]), - ChatMessage( + Message(role="user", contents=[Content.from_text(text="Hello, how are you?")]), + Message( role="assistant", contents=[Content.from_text(text="I'm doing well, thank you! How can I help you today?")], ), - ChatMessage(role="user", contents=[Content.from_text(text="Can you tell me about the weather?")]), - ChatMessage( + Message(role="user", contents=[Content.from_text(text="Can you tell me about the weather?")]), + Message( role="assistant", contents=[ Content.from_text( @@ -246,8 +184,8 @@ async def test_real_world_scenario(): ) ], ), - ChatMessage(role="user", contents=[Content.from_text(text="What about telling me a joke instead?")]), - ChatMessage( + Message(role="user", contents=[Content.from_text(text="What about telling me a joke instead?")]), + Message( role="assistant", contents=[ Content.from_text(text="Sure! Why don't scientists trust atoms? Because they make up everything!") @@ -255,18 +193,13 @@ async def test_real_world_scenario(): ), ] - await sliding_window.add_messages(conversation) + await provider.save_messages(None, conversation, state=state) - current_messages = await sliding_window.list_messages() - all_messages = await sliding_window.list_all_messages() + truncated = await provider.get_messages(None, state=state) + all_msgs = state[provider.source_id]["messages"] - # All messages should be preserved - assert len(all_messages) == 6 + assert len(all_msgs) == 6 + assert len(truncated) <= 6 - # Current messages might be truncated - assert len(current_messages) <= 6 - - # Token count should be within or close to limit - token_count = sliding_window.get_token_count() - # Allow some margin since truncation happens when exceeded - assert token_count <= sliding_window.max_tokens * 1.1 + token_count = provider._get_token_count(truncated) + assert token_count <= provider.max_tokens * 1.1 diff --git a/python/packages/lab/tau2/tests/test_tau2_utils.py b/python/packages/lab/tau2/tests/test_tau2_utils.py index dff8a56e5c..f463c13ec8 100644 --- a/python/packages/lab/tau2/tests/test_tau2_utils.py +++ b/python/packages/lab/tau2/tests/test_tau2_utils.py @@ -6,7 +6,7 @@ import urllib.request from pathlib import Path import pytest -from agent_framework import ChatMessage, Content, FunctionTool +from agent_framework import Content, FunctionTool, Message from agent_framework_lab_tau2._tau2_utils import ( convert_agent_framework_messages_to_tau2_messages, convert_tau2_tool_to_function_tool, @@ -91,7 +91,7 @@ def test_convert_tau2_tool_to_function_tool_multiple_tools(tau2_airline_environm def test_convert_agent_framework_messages_to_tau2_messages_system(): """Test converting system message.""" - messages = [ChatMessage(role="system", contents=[Content.from_text(text="System instruction")])] + messages = [Message(role="system", contents=[Content.from_text(text="System instruction")])] tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages) @@ -103,7 +103,7 @@ def test_convert_agent_framework_messages_to_tau2_messages_system(): def test_convert_agent_framework_messages_to_tau2_messages_user(): """Test converting user message.""" - messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello assistant")])] + messages = [Message(role="user", contents=[Content.from_text(text="Hello assistant")])] tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages) @@ -116,7 +116,7 @@ def test_convert_agent_framework_messages_to_tau2_messages_user(): def test_convert_agent_framework_messages_to_tau2_messages_assistant(): """Test converting assistant message.""" - messages = [ChatMessage(role="assistant", contents=[Content.from_text(text="Hello user")])] + messages = [Message(role="assistant", contents=[Content.from_text(text="Hello user")])] tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages) @@ -131,7 +131,7 @@ def test_convert_agent_framework_messages_to_tau2_messages_with_function_call(): """Test converting message with function call.""" function_call = Content.from_function_call(call_id="call_123", name="test_function", arguments={"param": "value"}) - messages = [ChatMessage(role="assistant", contents=[Content.from_text(text="I'll call a function"), function_call])] + messages = [Message(role="assistant", contents=[Content.from_text(text="I'll call a function"), function_call])] tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages) @@ -153,7 +153,7 @@ def test_convert_agent_framework_messages_to_tau2_messages_with_function_result( """Test converting message with function result.""" function_result = Content.from_function_result(call_id="call_123", result={"success": True, "data": "result data"}) - messages = [ChatMessage(role="tool", contents=[function_result])] + messages = [Message(role="tool", contents=[function_result])] tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages) @@ -173,7 +173,7 @@ def test_convert_agent_framework_messages_to_tau2_messages_with_error(): call_id="call_456", result="Error occurred", exception=Exception("Test error") ) - messages = [ChatMessage(role="tool", contents=[function_result])] + messages = [Message(role="tool", contents=[function_result])] tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages) @@ -185,7 +185,7 @@ def test_convert_agent_framework_messages_to_tau2_messages_with_error(): def test_convert_agent_framework_messages_to_tau2_messages_multiple_text_contents(): """Test converting message with multiple text contents.""" messages = [ - ChatMessage(role="user", contents=[Content.from_text(text="First part"), Content.from_text(text="Second part")]) + Message(role="user", contents=[Content.from_text(text="First part"), Content.from_text(text="Second part")]) ] tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages) @@ -202,11 +202,11 @@ def test_convert_agent_framework_messages_to_tau2_messages_complex_scenario(): function_result = Content.from_function_result(call_id="call_789", result={"output": "tool result"}) messages = [ - ChatMessage(role="system", contents=[Content.from_text(text="System prompt")]), - ChatMessage(role="user", contents=[Content.from_text(text="User request")]), - ChatMessage(role="assistant", contents=[Content.from_text(text="I'll help you"), function_call]), - ChatMessage(role="tool", contents=[function_result]), - ChatMessage(role="assistant", contents=[Content.from_text(text="Based on the result...")]), + Message(role="system", contents=[Content.from_text(text="System prompt")]), + Message(role="user", contents=[Content.from_text(text="User request")]), + Message(role="assistant", contents=[Content.from_text(text="I'll help you"), function_call]), + Message(role="tool", contents=[function_result]), + Message(role="assistant", contents=[Content.from_text(text="Based on the result...")]), ] tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages) diff --git a/python/packages/mem0/AGENTS.md b/python/packages/mem0/AGENTS.md index 7c4ebaba2a..3a17e7b137 100644 --- a/python/packages/mem0/AGENTS.md +++ b/python/packages/mem0/AGENTS.md @@ -12,7 +12,7 @@ Integration with Mem0 for agent memory management. from agent_framework.mem0 import Mem0Provider provider = Mem0Provider(api_key="your-key") -agent = ChatAgent(..., context_provider=provider) +agent = Agent(..., context_provider=provider) ``` ## Import Path diff --git a/python/packages/mem0/README.md b/python/packages/mem0/README.md index a824c7db51..4d9cb64530 100644 --- a/python/packages/mem0/README.md +++ b/python/packages/mem0/README.md @@ -12,7 +12,7 @@ The Mem0 context provider enables persistent memory capabilities for your agents ### Basic Usage Example -See the [Mem0 basic example](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/context_providers/mem0/mem0_basic.py) which demonstrates: +See the [Mem0 basic example](../../samples/02-agents/context_providers/mem0/mem0_basic.py) which demonstrates: - Setting up an agent with Mem0 context provider - Teaching the agent user preferences @@ -27,5 +27,5 @@ Mem0's telemetry is **disabled by default** when using this package. If you want import os os.environ["MEM0_TELEMETRY"] = "true" -from agent_framework.mem0 import Mem0Provider +from agent_framework.mem0 import Mem0ContextProvider ``` diff --git a/python/packages/mem0/agent_framework_mem0/__init__.py b/python/packages/mem0/agent_framework_mem0/__init__.py index 7ff88aaa42..cb6f75f8a5 100644 --- a/python/packages/mem0/agent_framework_mem0/__init__.py +++ b/python/packages/mem0/agent_framework_mem0/__init__.py @@ -8,7 +8,7 @@ import os if os.environ.get("MEM0_TELEMETRY") is None: os.environ["MEM0_TELEMETRY"] = "false" -from ._provider import Mem0Provider +from ._context_provider import Mem0ContextProvider try: __version__ = importlib.metadata.version(__name__) @@ -16,6 +16,6 @@ except importlib.metadata.PackageNotFoundError: __version__ = "0.0.0" # Fallback for development mode __all__ = [ - "Mem0Provider", + "Mem0ContextProvider", "__version__", ] diff --git a/python/packages/mem0/agent_framework_mem0/_context_provider.py b/python/packages/mem0/agent_framework_mem0/_context_provider.py new file mode 100644 index 0000000000..ce3140d4ef --- /dev/null +++ b/python/packages/mem0/agent_framework_mem0/_context_provider.py @@ -0,0 +1,193 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""New-pattern Mem0 context provider using BaseContextProvider. + +This module provides ``Mem0ContextProvider``, built on the new +:class:`BaseContextProvider` hooks pattern. +""" + +from __future__ import annotations + +import sys +from contextlib import AbstractAsyncContextManager +from typing import TYPE_CHECKING, Any + +from agent_framework import Message +from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext +from agent_framework.exceptions import ServiceInitializationError +from mem0 import AsyncMemory, AsyncMemoryClient + +if sys.version_info >= (3, 11): + from typing import NotRequired, Self, TypedDict # pragma: no cover +else: + from typing_extensions import NotRequired, Self, TypedDict # pragma: no cover + +if TYPE_CHECKING: + from agent_framework._agents import SupportsAgentRun + + +class _MemorySearchResponse_v1_1(TypedDict): + results: list[dict[str, Any]] + relations: NotRequired[list[dict[str, Any]]] + + +_MemorySearchResponse_v2 = list[dict[str, Any]] + + +class Mem0ContextProvider(BaseContextProvider): + """Mem0 context provider using the new BaseContextProvider hooks pattern. + + Integrates Mem0 for persistent semantic memory, searching and storing + memories via the Mem0 API. + """ + + DEFAULT_CONTEXT_PROMPT = "## Memories\nConsider the following memories when answering user questions:" + + def __init__( + self, + source_id: str, + mem0_client: AsyncMemory | AsyncMemoryClient | None = None, + api_key: str | None = None, + application_id: str | None = None, + agent_id: str | None = None, + user_id: str | None = None, + *, + context_prompt: str | None = None, + ) -> None: + """Initialize the Mem0 context provider. + + Args: + source_id: Unique identifier for this provider instance. + mem0_client: A pre-created Mem0 MemoryClient or None to create a default client. + api_key: The API key for authenticating with the Mem0 API. + application_id: The application ID for scoping memories. + agent_id: The agent ID for scoping memories. + user_id: The user ID for scoping memories. + context_prompt: The prompt to prepend to retrieved memories. + """ + super().__init__(source_id) + should_close_client = False + if mem0_client is None: + mem0_client = AsyncMemoryClient(api_key=api_key) + should_close_client = True + + self.api_key = api_key + self.application_id = application_id + self.agent_id = agent_id + self.user_id = user_id + self.context_prompt = context_prompt or self.DEFAULT_CONTEXT_PROMPT + self.mem0_client = mem0_client + self._should_close_client = should_close_client + + async def __aenter__(self) -> Self: + """Async context manager entry.""" + if self.mem0_client and isinstance(self.mem0_client, AbstractAsyncContextManager): + await self.mem0_client.__aenter__() + return self + + async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: + """Async context manager exit.""" + if self._should_close_client and self.mem0_client and isinstance(self.mem0_client, AbstractAsyncContextManager): + await self.mem0_client.__aexit__(exc_type, exc_val, exc_tb) + + # -- Hooks pattern --------------------------------------------------------- + + async def before_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Search Mem0 for relevant memories and add to the session context.""" + self._validate_filters() + input_text = "\n".join(msg.text for msg in context.input_messages if msg and msg.text and msg.text.strip()) + if not input_text.strip(): + return + + filters = self._build_filters(session_id=context.session_id) + + # AsyncMemory (OSS) expects user_id/agent_id/run_id as direct kwargs + # AsyncMemoryClient (Platform) expects them in a filters dict + search_kwargs: dict[str, Any] = {"query": input_text} + if isinstance(self.mem0_client, AsyncMemory): + search_kwargs.update(filters) + else: + search_kwargs["filters"] = filters + + search_response: _MemorySearchResponse_v1_1 | _MemorySearchResponse_v2 = await self.mem0_client.search( # type: ignore[misc] + **search_kwargs, + ) + + if isinstance(search_response, list): + memories = search_response + elif isinstance(search_response, dict) and "results" in search_response: + memories = search_response["results"] + else: + memories = [search_response] + + line_separated_memories = "\n".join(memory.get("memory", "") for memory in memories) + if line_separated_memories: + context.extend_messages( + self.source_id, + [Message(role="user", text=f"{self.context_prompt}\n{line_separated_memories}")], + ) + + async def after_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Store request/response messages to Mem0 for future retrieval.""" + self._validate_filters() + + messages_to_store: list[Message] = list(context.input_messages) + if context.response and context.response.messages: + messages_to_store.extend(context.response.messages) + + def get_role_value(role: Any) -> str: + return role.value if hasattr(role, "value") else str(role) + + messages: list[dict[str, str]] = [ + {"role": get_role_value(message.role), "content": message.text} + for message in messages_to_store + if get_role_value(message.role) in {"user", "assistant", "system"} and message.text and message.text.strip() + ] + + if messages: + await self.mem0_client.add( # type: ignore[misc] + messages=messages, + user_id=self.user_id, + agent_id=self.agent_id, + run_id=context.session_id, + metadata={"application_id": self.application_id}, + ) + + # -- Internal methods ------------------------------------------------------ + + def _validate_filters(self) -> None: + """Validates that at least one filter is provided.""" + if not self.agent_id and not self.user_id and not self.application_id: + raise ServiceInitializationError( + "At least one of the filters: agent_id, user_id, or application_id is required." + ) + + def _build_filters(self, *, session_id: str | None = None) -> dict[str, Any]: + """Build search filters from initialization parameters.""" + filters: dict[str, Any] = {} + if self.user_id: + filters["user_id"] = self.user_id + if self.agent_id: + filters["agent_id"] = self.agent_id + if session_id: + filters["run_id"] = session_id + if self.application_id: + filters["app_id"] = self.application_id + return filters + + +__all__ = ["Mem0ContextProvider"] diff --git a/python/packages/mem0/agent_framework_mem0/_provider.py b/python/packages/mem0/agent_framework_mem0/_provider.py deleted file mode 100644 index 0dbad13134..0000000000 --- a/python/packages/mem0/agent_framework_mem0/_provider.py +++ /dev/null @@ -1,241 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import sys -from collections.abc import MutableSequence, Sequence -from contextlib import AbstractAsyncContextManager -from typing import Any - -from agent_framework import ChatMessage, Context, ContextProvider -from agent_framework.exceptions import ServiceInitializationError -from mem0 import AsyncMemory, AsyncMemoryClient - -if sys.version_info >= (3, 12): - from typing import override # type: ignore # pragma: no cover -else: - from typing_extensions import override # type: ignore[import] # pragma: no cover - -if sys.version_info >= (3, 11): - from typing import NotRequired, Self, TypedDict # pragma: no cover -else: - from typing_extensions import NotRequired, Self, TypedDict # pragma: no cover - - -# Type aliases for Mem0 search response formats (v1.1 and v2; v1 is deprecated, but matches the type definition for v2) -class MemorySearchResponse_v1_1(TypedDict): - results: list[dict[str, Any]] - relations: NotRequired[list[dict[str, Any]]] - - -MemorySearchResponse_v2 = list[dict[str, Any]] - - -class Mem0Provider(ContextProvider): - """Mem0 Context Provider. - - Note: - Mem0's telemetry is disabled by default when using this package. - To enable telemetry, set the environment variable ``MEM0_TELEMETRY=true`` before - importing this package. - """ - - def __init__( - self, - mem0_client: AsyncMemory | AsyncMemoryClient | None = None, - api_key: str | None = None, - application_id: str | None = None, - agent_id: str | None = None, - thread_id: str | None = None, - user_id: str | None = None, - scope_to_per_operation_thread_id: bool = False, - context_prompt: str = ContextProvider.DEFAULT_CONTEXT_PROMPT, - ) -> None: - """Initializes a new instance of the Mem0Provider class. - - Args: - mem0_client: A pre-created Mem0 MemoryClient or None to create a default client. - api_key: The API key for authenticating with the Mem0 API. If not - provided, it will attempt to use the MEM0_API_KEY environment variable. - application_id: The application ID for scoping memories or None. - agent_id: The agent ID for scoping memories or None. - thread_id: The thread ID for scoping memories or None. - user_id: The user ID for scoping memories or None. - scope_to_per_operation_thread_id: Whether to scope memories to per-operation thread ID. - context_prompt: The prompt to prepend to retrieved memories. - """ - should_close_client = False - if mem0_client is None: - mem0_client = AsyncMemoryClient(api_key=api_key) - should_close_client = True - - self.api_key = api_key - self.application_id = application_id - self.agent_id = agent_id - self.thread_id = thread_id - self.user_id = user_id - self.scope_to_per_operation_thread_id = scope_to_per_operation_thread_id - self.context_prompt = context_prompt - self.mem0_client = mem0_client - self._per_operation_thread_id: str | None = None - self._should_close_client = should_close_client - - async def __aenter__(self) -> Self: - """Async context manager entry.""" - if self.mem0_client and isinstance(self.mem0_client, AbstractAsyncContextManager): - await self.mem0_client.__aenter__() - return self - - async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: - """Async context manager exit.""" - if self._should_close_client and self.mem0_client and isinstance(self.mem0_client, AbstractAsyncContextManager): - await self.mem0_client.__aexit__(exc_type, exc_val, exc_tb) - - async def thread_created(self, thread_id: str | None = None) -> None: - """Called when a new thread is created. - - Args: - thread_id: The ID of the thread or None. - """ - self._validate_per_operation_thread_id(thread_id) - self._per_operation_thread_id = self._per_operation_thread_id or thread_id - - @override - async def invoked( - self, - request_messages: ChatMessage | Sequence[ChatMessage], - response_messages: ChatMessage | Sequence[ChatMessage] | None = None, - invoke_exception: Exception | None = None, - **kwargs: Any, - ) -> None: - self._validate_filters() - - request_messages_list = ( - [request_messages] if isinstance(request_messages, ChatMessage) else list(request_messages) - ) - response_messages_list = ( - [response_messages] - if isinstance(response_messages, ChatMessage) - else list(response_messages) - if response_messages - else [] - ) - messages_list = [*request_messages_list, *response_messages_list] - - # Extract role value - it may be a Role enum or a string - def get_role_value(role: Any) -> str: - return role.value if hasattr(role, "value") else str(role) - - messages: list[dict[str, str]] = [ - {"role": get_role_value(message.role), "content": message.text} - for message in messages_list - if get_role_value(message.role) in {"user", "assistant", "system"} and message.text and message.text.strip() - ] - - if messages: - await self.mem0_client.add( # type: ignore[misc] - messages=messages, - user_id=self.user_id, - agent_id=self.agent_id, - run_id=self._per_operation_thread_id if self.scope_to_per_operation_thread_id else self.thread_id, - metadata={"application_id": self.application_id}, - ) - - @override - async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context: - """Called before invoking the AI model to provide context. - - Args: - messages: List of new messages in the thread. - - Keyword Args: - **kwargs: not used at present. - - Returns: - Context: Context object containing instructions with memories. - """ - self._validate_filters() - messages_list = [messages] if isinstance(messages, ChatMessage) else list(messages) - input_text = "\n".join(msg.text for msg in messages_list if msg and msg.text and msg.text.strip()) - - # Validate input text is not empty before searching (possible for function approval responses) - if not input_text.strip(): - return Context(messages=None) - - # Build filters from init parameters - filters = self._build_filters() - - search_response: MemorySearchResponse_v1_1 | MemorySearchResponse_v2 = await self.mem0_client.search( # type: ignore[misc] - query=input_text, - filters=filters, - ) - - # Depending on the API version, the response schema varies slightly - if isinstance(search_response, list): - memories = search_response - elif isinstance(search_response, dict) and "results" in search_response: - memories = search_response["results"] - else: - # Fallback for unexpected schema - return response as text as-is - memories = [search_response] - - line_separated_memories = "\n".join(memory.get("memory", "") for memory in memories) - - return Context( - messages=[ChatMessage(role="user", text=f"{self.context_prompt}\n{line_separated_memories}")] - if line_separated_memories - else None - ) - - def _validate_filters(self) -> None: - """Validates that at least one filter is provided. - - Raises: - ServiceInitializationError: If no filters are provided. - """ - if not self.agent_id and not self.user_id and not self.application_id and not self.thread_id: - raise ServiceInitializationError( - "At least one of the filters: agent_id, user_id, application_id, or thread_id is required." - ) - - def _build_filters(self) -> dict[str, Any]: - """Build search filters from initialization parameters. - - Returns: - Filter dictionary for mem0 v2 search API containing initialization parameters. - In the v2 API, filters holds the user_id, agent_id, run_id (thread_id), and app_id - (application_id) which are required for scoping memory search operations. - """ - filters: dict[str, Any] = {} - - if self.user_id: - filters["user_id"] = self.user_id - if self.agent_id: - filters["agent_id"] = self.agent_id - if self.scope_to_per_operation_thread_id and self._per_operation_thread_id: - filters["run_id"] = self._per_operation_thread_id - elif self.thread_id: - filters["run_id"] = self.thread_id - if self.application_id: - filters["app_id"] = self.application_id - - return filters - - def _validate_per_operation_thread_id(self, thread_id: str | None) -> None: - """Validates that a new thread ID doesn't conflict with an existing one when scoped. - - Args: - thread_id: The new thread ID or None. - - Raises: - ValueError: If a new thread ID is provided when one already exists. - """ - if ( - self.scope_to_per_operation_thread_id - and thread_id - and self._per_operation_thread_id - and thread_id != self._per_operation_thread_id - ): - raise ValueError( - "Mem0Provider can only be used with one thread at a time when scope_to_per_operation_thread_id is True." - ) diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index 016ad77bd6..baf6d4bb3f 100644 --- a/python/packages/mem0/pyproject.toml +++ b/python/packages/mem0/pyproject.toml @@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "mem0ai>=1.0.0", ] diff --git a/python/packages/mem0/tests/test_mem0_context_provider.py b/python/packages/mem0/tests/test_mem0_context_provider.py index 432468fe3f..c13ac58dd4 100644 --- a/python/packages/mem0/tests/test_mem0_context_provider.py +++ b/python/packages/mem0/tests/test_mem0_context_provider.py @@ -1,20 +1,16 @@ # Copyright (c) Microsoft. All rights reserved. # pyright: reportPrivateUsage=false -import importlib -import os -import sys -from unittest.mock import AsyncMock +from __future__ import annotations + +from unittest.mock import AsyncMock, patch import pytest -from agent_framework import ChatMessage, Content, Context +from agent_framework import AgentResponse, Message +from agent_framework._sessions import AgentSession, SessionContext from agent_framework.exceptions import ServiceInitializationError -from agent_framework.mem0 import Mem0Provider - -def test_mem0_provider_import() -> None: - """Test that Mem0Provider can be imported.""" - assert Mem0Provider is not None +from agent_framework_mem0._context_provider import Mem0ContextProvider @pytest.fixture @@ -27,577 +23,385 @@ def mock_mem0_client() -> AsyncMock: mock_client.search = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock() - mock_client.async_client = AsyncMock() - mock_client.async_client.aclose = AsyncMock() return mock_client @pytest.fixture -def sample_messages() -> list[ChatMessage]: - """Create sample chat messages for testing.""" - return [ - ChatMessage(role="user", text="Hello, how are you?"), - ChatMessage(role="assistant", text="I'm doing well, thank you!"), - ChatMessage(role="system", text="You are a helpful assistant"), - ] +def mock_oss_mem0_client() -> AsyncMock: + """Create a mock Mem0 OSS AsyncMemory client.""" + from mem0 import AsyncMemory + + mock_client = AsyncMock(spec=AsyncMemory) + mock_client.add = AsyncMock() + mock_client.search = AsyncMock() + return mock_client -def test_init_with_all_ids(mock_mem0_client: AsyncMock) -> None: - """Test initialization with all IDs provided.""" - provider = Mem0Provider( - user_id="user123", - agent_id="agent123", - application_id="app123", - thread_id="thread123", - mem0_client=mock_mem0_client, - ) - assert provider.user_id == "user123" - assert provider.agent_id == "agent123" - assert provider.application_id == "app123" - assert provider.thread_id == "thread123" +# -- Initialization tests ------------------------------------------------------ -def test_init_without_filters_succeeds(mock_mem0_client: AsyncMock) -> None: - """Test that initialization succeeds even without filters (validation happens during invocation).""" - provider = Mem0Provider(mem0_client=mock_mem0_client) - assert provider.user_id is None - assert provider.agent_id is None - assert provider.application_id is None - assert provider.thread_id is None +class TestInit: + """Test Mem0ContextProvider initialization.""" - -def test_init_with_custom_context_prompt(mock_mem0_client: AsyncMock) -> None: - """Test initialization with custom context prompt.""" - custom_prompt = "## Custom Memories\nConsider these memories:" - provider = Mem0Provider(user_id="user123", context_prompt=custom_prompt, mem0_client=mock_mem0_client) - assert provider.context_prompt == custom_prompt - - -def test_init_with_scope_to_per_operation_thread_id(mock_mem0_client: AsyncMock) -> None: - """Test initialization with scope_to_per_operation_thread_id enabled.""" - provider = Mem0Provider( - user_id="user123", - scope_to_per_operation_thread_id=True, - mem0_client=mock_mem0_client, - ) - assert provider.scope_to_per_operation_thread_id is True - - -def test_init_with_provided_client_should_not_close(mock_mem0_client: AsyncMock) -> None: - """Test that provided client should not be closed by provider.""" - provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client) - assert provider._should_close_client is False - - -async def test_async_context_manager_entry(mock_mem0_client: AsyncMock) -> None: - """Test async context manager entry returns self.""" - provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client) - async with provider as ctx: - assert ctx is provider - - -async def test_async_context_manager_exit_does_not_close_provided_client(mock_mem0_client: AsyncMock) -> None: - """Test that async context manager does not close provided client.""" - provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client) - assert provider._should_close_client is False - - async with provider: - pass - - mock_mem0_client.__aexit__.assert_not_called() - - -class TestMem0ProviderThreadMethods: - """Test thread lifecycle methods.""" - - async def test_thread_created_sets_per_operation_thread_id(self, mock_mem0_client: AsyncMock) -> None: - """Test that thread_created sets per-operation thread ID.""" - provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client) - - await provider.thread_created("thread123") - - assert provider._per_operation_thread_id == "thread123" - - async def test_thread_created_with_existing_thread_id(self, mock_mem0_client: AsyncMock) -> None: - """Test thread_created when thread ID already exists.""" - provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client) - provider._per_operation_thread_id = "existing_thread" - - await provider.thread_created("thread123") - - # Should not overwrite existing thread ID - assert provider._per_operation_thread_id == "existing_thread" - - async def test_thread_created_validation_with_scope_enabled(self, mock_mem0_client: AsyncMock) -> None: - """Test thread_created validation when scope_to_per_operation_thread_id is enabled.""" - provider = Mem0Provider( - user_id="user123", - scope_to_per_operation_thread_id=True, + def test_init_with_all_params(self, mock_mem0_client: AsyncMock) -> None: + provider = Mem0ContextProvider( + source_id="mem0", mem0_client=mock_mem0_client, + api_key="key-123", + application_id="app1", + agent_id="agent1", + user_id="user1", + context_prompt="Custom prompt", ) - provider._per_operation_thread_id = "existing_thread" + assert provider.source_id == "mem0" + assert provider.api_key == "key-123" + assert provider.application_id == "app1" + assert provider.agent_id == "agent1" + assert provider.user_id == "user1" + assert provider.context_prompt == "Custom prompt" + assert provider.mem0_client is mock_mem0_client + assert provider._should_close_client is False - with pytest.raises(ValueError) as exc_info: - await provider.thread_created("different_thread") + def test_init_default_context_prompt(self, mock_mem0_client: AsyncMock) -> None: + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + assert provider.context_prompt == Mem0ContextProvider.DEFAULT_CONTEXT_PROMPT - assert "can only be used with one thread at a time" in str(exc_info.value) + def test_init_auto_creates_client_when_none(self) -> None: + """When no client is provided, a default AsyncMemoryClient is created and flagged for closing.""" + with ( + patch("mem0.client.main.AsyncMemoryClient.__init__", return_value=None) as mock_init, + patch("mem0.client.main.AsyncMemoryClient._validate_api_key", return_value=None), + ): + provider = Mem0ContextProvider(source_id="mem0", api_key="test-key", user_id="u1") + mock_init.assert_called_once_with(api_key="test-key") + assert provider._should_close_client is True - async def test_messages_adding_sets_per_operation_thread_id(self, mock_mem0_client: AsyncMock) -> None: - """Test that invoked sets per-operation thread ID.""" - provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client) - - await provider.thread_created("thread123") - - assert provider._per_operation_thread_id == "thread123" + def test_provided_client_not_flagged_for_close(self, mock_mem0_client: AsyncMock) -> None: + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + assert provider._should_close_client is False -class TestMem0ProviderMessagesAdding: - """Test invoked method.""" - - async def test_messages_adding_fails_without_filters(self, mock_mem0_client: AsyncMock) -> None: - """Test that invoked fails when no filters are provided.""" - provider = Mem0Provider(mem0_client=mock_mem0_client) - message = ChatMessage(role="user", text="Hello!") - - with pytest.raises(ServiceInitializationError) as exc_info: - await provider.invoked(message) - - assert "At least one of the filters" in str(exc_info.value) - - async def test_messages_adding_single_message(self, mock_mem0_client: AsyncMock) -> None: - """Test adding a single message.""" - provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client) - message = ChatMessage(role="user", text="Hello!") - - await provider.invoked(message) - - mock_mem0_client.add.assert_called_once() - call_args = mock_mem0_client.add.call_args - assert call_args.kwargs["messages"] == [{"role": "user", "content": "Hello!"}] - assert call_args.kwargs["user_id"] == "user123" - - async def test_messages_adding_multiple_messages( - self, mock_mem0_client: AsyncMock, sample_messages: list[ChatMessage] - ) -> None: - """Test adding multiple messages.""" - provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client) - - await provider.invoked(sample_messages) - - mock_mem0_client.add.assert_called_once() - call_args = mock_mem0_client.add.call_args - expected_messages = [ - {"role": "user", "content": "Hello, how are you?"}, - {"role": "assistant", "content": "I'm doing well, thank you!"}, - {"role": "system", "content": "You are a helpful assistant"}, - ] - assert call_args.kwargs["messages"] == expected_messages - - async def test_messages_adding_with_agent_id( - self, mock_mem0_client: AsyncMock, sample_messages: list[ChatMessage] - ) -> None: - """Test adding messages with agent_id.""" - provider = Mem0Provider(agent_id="agent123", mem0_client=mock_mem0_client) - - await provider.invoked(sample_messages) - - call_args = mock_mem0_client.add.call_args - assert call_args.kwargs["agent_id"] == "agent123" - assert call_args.kwargs["user_id"] is None - - async def test_messages_adding_with_application_id( - self, mock_mem0_client: AsyncMock, sample_messages: list[ChatMessage] - ) -> None: - """Test adding messages with application_id in metadata.""" - provider = Mem0Provider(user_id="user123", application_id="app123", mem0_client=mock_mem0_client) - - await provider.invoked(sample_messages) - - call_args = mock_mem0_client.add.call_args - assert call_args.kwargs["metadata"] == {"application_id": "app123"} - - async def test_messages_adding_with_scope_to_per_operation_thread_id( - self, mock_mem0_client: AsyncMock, sample_messages: list[ChatMessage] - ) -> None: - """Test adding messages with scope_to_per_operation_thread_id enabled.""" - provider = Mem0Provider( - user_id="user123", - thread_id="base_thread", - scope_to_per_operation_thread_id=True, - mem0_client=mock_mem0_client, - ) - provider._per_operation_thread_id = "operation_thread" - - await provider.thread_created(thread_id="operation_thread") - await provider.invoked(sample_messages) - - call_args = mock_mem0_client.add.call_args - assert call_args.kwargs["run_id"] == "operation_thread" - - async def test_messages_adding_without_scope_uses_base_thread_id( - self, mock_mem0_client: AsyncMock, sample_messages: list[ChatMessage] - ) -> None: - """Test adding messages without scope uses base thread_id.""" - provider = Mem0Provider( - user_id="user123", - thread_id="base_thread", - scope_to_per_operation_thread_id=False, - mem0_client=mock_mem0_client, - ) - - await provider.invoked(sample_messages) - - call_args = mock_mem0_client.add.call_args - assert call_args.kwargs["run_id"] == "base_thread" - - async def test_messages_adding_filters_empty_messages(self, mock_mem0_client: AsyncMock) -> None: - """Test that empty or invalid messages are filtered out.""" - provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client) - messages = [ - ChatMessage(role="user", text=""), # Empty text - ChatMessage(role="user", text=" "), # Whitespace only - ChatMessage(role="user", text="Valid message"), - ] - - await provider.invoked(messages) - - call_args = mock_mem0_client.add.call_args - # Should only include the valid message - assert call_args.kwargs["messages"] == [{"role": "user", "content": "Valid message"}] - - async def test_messages_adding_skips_when_no_valid_messages(self, mock_mem0_client: AsyncMock) -> None: - """Test that mem0 client is not called when no valid messages exist.""" - provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client) - messages = [ - ChatMessage(role="user", text=""), - ChatMessage(role="user", text=" "), - ] - - await provider.invoked(messages) - - mock_mem0_client.add.assert_not_called() +# -- before_run tests ---------------------------------------------------------- -class TestMem0ProviderModelInvoking: - """Test invoking method.""" +class TestBeforeRun: + """Test before_run hook.""" - async def test_model_invoking_fails_without_filters(self, mock_mem0_client: AsyncMock) -> None: - """Test that invoking fails when no filters are provided.""" - provider = Mem0Provider(mem0_client=mock_mem0_client) - message = ChatMessage(role="user", text="What's the weather?") - - with pytest.raises(ServiceInitializationError) as exc_info: - await provider.invoking(message) - - assert "At least one of the filters" in str(exc_info.value) - - async def test_model_invoking_single_message(self, mock_mem0_client: AsyncMock) -> None: - """Test invoking with a single message.""" - provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client) - message = ChatMessage(role="user", text="What's the weather?") - - # Mock search results + async def test_memories_added_to_context(self, mock_mem0_client: AsyncMock) -> None: + """Mocked mem0 search returns memories → messages added to context with prompt.""" mock_mem0_client.search.return_value = [ - {"memory": "User likes outdoor activities"}, - {"memory": "User lives in Seattle"}, + {"memory": "User likes Python"}, + {"memory": "User prefers dark mode"}, ] + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") - context = await provider.invoking(message) + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] - mock_mem0_client.search.assert_called_once() - call_args = mock_mem0_client.search.call_args - assert call_args.kwargs["query"] == "What's the weather?" - assert call_args.kwargs["filters"] == {"user_id": "user123"} + mock_mem0_client.search.assert_awaited_once() + assert "mem0" in ctx.context_messages + added = ctx.context_messages["mem0"] + assert len(added) == 1 + assert "User likes Python" in added[0].text # type: ignore[operator] + assert "User prefers dark mode" in added[0].text # type: ignore[operator] + assert provider.context_prompt in added[0].text # type: ignore[operator] - assert isinstance(context, Context) - expected_instructions = ( - "## Memories\nConsider the following memories when answering user questions:\n" - "User likes outdoor activities\nUser lives in Seattle" - ) + async def test_empty_input_skips_search(self, mock_mem0_client: AsyncMock) -> None: + """Empty input messages → no search performed.""" + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="")], session_id="s1") - assert context.messages - assert context.messages[0].text == expected_instructions + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] - async def test_model_invoking_multiple_messages( - self, mock_mem0_client: AsyncMock, sample_messages: list[ChatMessage] - ) -> None: - """Test invoking with multiple messages.""" - provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client) - - mock_mem0_client.search.return_value = [{"memory": "Previous conversation context"}] - - await provider.invoking(sample_messages) - - call_args = mock_mem0_client.search.call_args - expected_query = "Hello, how are you?\nI'm doing well, thank you!\nYou are a helpful assistant" - assert call_args.kwargs["query"] == expected_query - - async def test_model_invoking_with_agent_id(self, mock_mem0_client: AsyncMock) -> None: - """Test invoking with agent_id.""" - provider = Mem0Provider(agent_id="agent123", mem0_client=mock_mem0_client) - message = ChatMessage(role="user", text="Hello") + mock_mem0_client.search.assert_not_awaited() + assert "mem0" not in ctx.context_messages + async def test_empty_search_results_no_messages(self, mock_mem0_client: AsyncMock) -> None: + """Empty search results → no messages added.""" mock_mem0_client.search.return_value = [] + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="test")], session_id="s1") - await provider.invoking(message) + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] - call_args = mock_mem0_client.search.call_args - assert call_args.kwargs["filters"] == {"agent_id": "agent123"} + assert "mem0" not in ctx.context_messages - async def test_model_invoking_with_scope_to_per_operation_thread_id(self, mock_mem0_client: AsyncMock) -> None: - """Test invoking with scope_to_per_operation_thread_id enabled.""" - provider = Mem0Provider( - user_id="user123", - thread_id="base_thread", - scope_to_per_operation_thread_id=True, - mem0_client=mock_mem0_client, - ) - provider._per_operation_thread_id = "operation_thread" - message = ChatMessage(role="user", text="Hello") + async def test_validates_filters_before_search(self, mock_mem0_client: AsyncMock) -> None: + """Raises ServiceInitializationError when no filters.""" + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="test")], session_id="s1") + with pytest.raises(ServiceInitializationError, match="At least one of the filters"): + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + async def test_v1_1_response_format(self, mock_mem0_client: AsyncMock) -> None: + """Search response in v1.1 dict format with 'results' key.""" + mock_mem0_client.search.return_value = {"results": [{"memory": "remembered fact"}]} + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="test")], session_id="s1") + + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + added = ctx.context_messages["mem0"] + assert "remembered fact" in added[0].text # type: ignore[operator] + + async def test_search_query_combines_input_messages(self, mock_mem0_client: AsyncMock) -> None: + """Multiple input messages are joined for the search query.""" mock_mem0_client.search.return_value = [] - - await provider.invoking(message) - - call_args = mock_mem0_client.search.call_args - assert call_args.kwargs["filters"] == {"user_id": "user123", "run_id": "operation_thread"} - - async def test_model_invoking_no_memories_returns_none_instructions(self, mock_mem0_client: AsyncMock) -> None: - """Test that no memories returns context with None instructions.""" - provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client) - message = ChatMessage(role="user", text="Hello") - - mock_mem0_client.search.return_value = [] - - context = await provider.invoking(message) - - assert isinstance(context, Context) - assert not context.messages - - async def test_model_invoking_function_approval_response_returns_none_instructions( - self, mock_mem0_client: AsyncMock - ) -> None: - """Test invoking with function approval response content messages returns context with None instructions.""" - - provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client) - function_call = Content.from_function_call(call_id="1", name="test_func", arguments='{"arg1": "value1"}') - message = ChatMessage( - role="user", - contents=[ - Content.from_function_approval_response( - id="approval_1", - function_call=function_call, - approved=True, - ) + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + session = AgentSession(session_id="test-session") + ctx = SessionContext( + input_messages=[ + Message(role="user", text="Hello"), + Message(role="user", text="World"), ], + session_id="s1", ) + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + call_kwargs = mock_mem0_client.search.call_args.kwargs + assert call_kwargs["query"] == "Hello\nWorld" + + async def test_oss_client_passes_direct_kwargs(self, mock_oss_mem0_client: AsyncMock) -> None: + """OSS AsyncMemory client should receive user_id as direct kwarg, not in filters.""" + mock_oss_mem0_client.search.return_value = [{"memory": "User likes Python"}] + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_oss_mem0_client, user_id="u1") + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + call_kwargs = mock_oss_mem0_client.search.call_args.kwargs + assert call_kwargs["query"] == "Hello" + assert call_kwargs["user_id"] == "u1" + assert "filters" not in call_kwargs + + async def test_oss_client_all_scoping_params(self, mock_oss_mem0_client: AsyncMock) -> None: + """OSS client with all scoping parameters passes them as direct kwargs.""" + mock_oss_mem0_client.search.return_value = [] + provider = Mem0ContextProvider( + source_id="mem0", mem0_client=mock_oss_mem0_client, user_id="u1", agent_id="a1", application_id="app1" + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + call_kwargs = mock_oss_mem0_client.search.call_args.kwargs + assert call_kwargs["user_id"] == "u1" + assert call_kwargs["agent_id"] == "a1" + assert "filters" not in call_kwargs + + async def test_platform_client_passes_filters_dict(self, mock_mem0_client: AsyncMock) -> None: + """Platform AsyncMemoryClient should receive scoping params in a filters dict.""" mock_mem0_client.search.return_value = [] + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") - context = await provider.invoking(message) + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] - assert isinstance(context, Context) - assert not context.messages + call_kwargs = mock_mem0_client.search.call_args.kwargs + assert call_kwargs["query"] == "Hello" + assert "filters" in call_kwargs + assert call_kwargs["filters"]["user_id"] == "u1" - async def test_model_invoking_filters_empty_message_text(self, mock_mem0_client: AsyncMock) -> None: - """Test that empty message text is filtered out from query.""" - provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client) - messages = [ - ChatMessage(role="user", text=""), - ChatMessage(role="user", text="Valid message"), - ChatMessage(role="user", text=" "), + +# -- after_run tests ----------------------------------------------------------- + + +class TestAfterRun: + """Test after_run hook.""" + + async def test_stores_input_and_response(self, mock_mem0_client: AsyncMock) -> None: + """Stores input+response messages to mem0 via client.add.""" + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="question")], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", text="answer")]) + + await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + mock_mem0_client.add.assert_awaited_once() + call_kwargs = mock_mem0_client.add.call_args.kwargs + assert call_kwargs["messages"] == [ + {"role": "user", "content": "question"}, + {"role": "assistant", "content": "answer"}, ] + assert call_kwargs["user_id"] == "u1" + assert call_kwargs["run_id"] == "s1" - mock_mem0_client.search.return_value = [] - - await provider.invoking(messages) - - call_args = mock_mem0_client.search.call_args - assert call_args.kwargs["query"] == "Valid message" - - async def test_model_invoking_custom_context_prompt(self, mock_mem0_client: AsyncMock) -> None: - """Test invoking with custom context prompt.""" - custom_prompt = "## Custom Context\nRemember these details:" - provider = Mem0Provider( - user_id="user123", - context_prompt=custom_prompt, - mem0_client=mock_mem0_client, + async def test_only_stores_user_assistant_system(self, mock_mem0_client: AsyncMock) -> None: + """Only stores user/assistant/system messages with text.""" + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + session = AgentSession(session_id="test-session") + ctx = SessionContext( + input_messages=[ + Message(role="user", text="hello"), + Message(role="tool", text="tool output"), + ], + session_id="s1", ) - message = ChatMessage(role="user", text="Hello") + ctx._response = AgentResponse(messages=[Message(role="assistant", text="reply")]) - mock_mem0_client.search.return_value = [{"memory": "Test memory"}] + await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] - context = await provider.invoking(message) + call_kwargs = mock_mem0_client.add.call_args.kwargs + roles = [m["role"] for m in call_kwargs["messages"]] + assert "tool" not in roles + assert roles == ["user", "assistant"] - expected_instructions = "## Custom Context\nRemember these details:\nTest memory" - assert context.messages - assert context.messages[0].text == expected_instructions - - -class TestMem0ProviderValidation: - """Test validation methods.""" - - def test_validate_per_operation_thread_id_success(self, mock_mem0_client: AsyncMock) -> None: - """Test successful validation of per-operation thread ID.""" - provider = Mem0Provider( - user_id="user123", - scope_to_per_operation_thread_id=True, - mem0_client=mock_mem0_client, + async def test_skips_empty_messages(self, mock_mem0_client: AsyncMock) -> None: + """Skips messages with empty text.""" + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + session = AgentSession(session_id="test-session") + ctx = SessionContext( + input_messages=[ + Message(role="user", text=""), + Message(role="user", text=" "), + ], + session_id="s1", ) - provider._per_operation_thread_id = "thread123" + ctx._response = AgentResponse(messages=[]) - # Should not raise exception for same thread ID - provider._validate_per_operation_thread_id("thread123") + await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] - # Should not raise exception for None - provider._validate_per_operation_thread_id(None) + mock_mem0_client.add.assert_not_awaited() - def test_validate_per_operation_thread_id_failure(self, mock_mem0_client: AsyncMock) -> None: - """Test validation failure for conflicting thread IDs.""" - provider = Mem0Provider( - user_id="user123", - scope_to_per_operation_thread_id=True, - mem0_client=mock_mem0_client, + async def test_uses_session_id_as_run_id(self, mock_mem0_client: AsyncMock) -> None: + """Uses session_id as run_id.""" + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="my-session") + ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) + + await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + assert mock_mem0_client.add.call_args.kwargs["run_id"] == "my-session" + + async def test_validates_filters(self, mock_mem0_client: AsyncMock) -> None: + """Raises ServiceInitializationError when no filters.""" + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) + + with pytest.raises(ServiceInitializationError, match="At least one of the filters"): + await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + async def test_stores_with_application_id_metadata(self, mock_mem0_client: AsyncMock) -> None: + """application_id is passed in metadata.""" + provider = Mem0ContextProvider( + source_id="mem0", mem0_client=mock_mem0_client, user_id="u1", application_id="app1" ) - provider._per_operation_thread_id = "thread123" + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") + ctx._response = AgentResponse(messages=[]) - with pytest.raises(ValueError) as exc_info: - provider._validate_per_operation_thread_id("different_thread") + await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] - assert "can only be used with one thread at a time" in str(exc_info.value) + assert mock_mem0_client.add.call_args.kwargs["metadata"] == {"application_id": "app1"} - def test_validate_per_operation_thread_id_disabled_scope(self, mock_mem0_client: AsyncMock) -> None: - """Test that validation is skipped when scope is disabled.""" - provider = Mem0Provider( - user_id="user123", - scope_to_per_operation_thread_id=False, + +# -- _validate_filters tests -------------------------------------------------- + + +class TestValidateFilters: + """Test _validate_filters method.""" + + def test_raises_when_no_filters(self, mock_mem0_client: AsyncMock) -> None: + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client) + with pytest.raises(ServiceInitializationError, match="At least one of the filters"): + provider._validate_filters() + + def test_passes_with_user_id(self, mock_mem0_client: AsyncMock) -> None: + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + provider._validate_filters() # should not raise + + def test_passes_with_agent_id(self, mock_mem0_client: AsyncMock) -> None: + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, agent_id="a1") + provider._validate_filters() + + def test_passes_with_application_id(self, mock_mem0_client: AsyncMock) -> None: + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, application_id="app1") + provider._validate_filters() + + +# -- _build_filters tests ----------------------------------------------------- + + +class TestBuildFilters: + """Test _build_filters method.""" + + def test_user_id_only(self, mock_mem0_client: AsyncMock) -> None: + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + assert provider._build_filters() == {"user_id": "u1"} + + def test_all_params(self, mock_mem0_client: AsyncMock) -> None: + provider = Mem0ContextProvider( + source_id="mem0", mem0_client=mock_mem0_client, + user_id="u1", + agent_id="a1", + application_id="app1", ) - provider._per_operation_thread_id = "thread123" - - # Should not raise exception even with different thread ID - provider._validate_per_operation_thread_id("different_thread") - - -class TestMem0ProviderBuildFilters: - """Test the _build_filters method.""" - - def test_build_filters_with_user_id_only(self, mock_mem0_client: AsyncMock) -> None: - """Test building filters with only user_id.""" - provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client) - - filters = provider._build_filters() - assert filters == {"user_id": "user123"} - - def test_build_filters_with_all_parameters(self, mock_mem0_client: AsyncMock) -> None: - """Test building filters with all initialization parameters.""" - provider = Mem0Provider( - user_id="user123", - agent_id="agent456", - thread_id="thread789", - application_id="app999", - mem0_client=mock_mem0_client, - ) - - filters = provider._build_filters() - assert filters == { - "user_id": "user123", - "agent_id": "agent456", - "run_id": "thread789", - "app_id": "app999", + assert provider._build_filters(session_id="sess1") == { + "user_id": "u1", + "agent_id": "a1", + "run_id": "sess1", + "app_id": "app1", } - def test_build_filters_excludes_none_values(self, mock_mem0_client: AsyncMock) -> None: - """Test that None values are excluded from filters.""" - provider = Mem0Provider( - user_id="user123", - agent_id=None, - thread_id=None, - application_id=None, - mem0_client=mock_mem0_client, - ) - + def test_excludes_none_values(self, mock_mem0_client: AsyncMock) -> None: + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") filters = provider._build_filters() - assert filters == {"user_id": "user123"} assert "agent_id" not in filters assert "run_id" not in filters assert "app_id" not in filters - def test_build_filters_with_per_operation_thread_id(self, mock_mem0_client: AsyncMock) -> None: - """Test that per-operation thread ID takes precedence over base thread_id.""" - provider = Mem0Provider( - user_id="user123", - thread_id="base_thread", - scope_to_per_operation_thread_id=True, - mem0_client=mock_mem0_client, - ) - provider._per_operation_thread_id = "operation_thread" + def test_session_id_mapped_to_run_id(self, mock_mem0_client: AsyncMock) -> None: + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + filters = provider._build_filters(session_id="s99") + assert filters["run_id"] == "s99" - filters = provider._build_filters() - assert filters == { - "user_id": "user123", - "run_id": "operation_thread", # Per-operation thread, not base_thread - } - - def test_build_filters_uses_base_thread_when_no_per_operation(self, mock_mem0_client: AsyncMock) -> None: - """Test that base thread_id is used when per-operation thread is not set.""" - provider = Mem0Provider( - user_id="user123", - thread_id="base_thread", - scope_to_per_operation_thread_id=True, - mem0_client=mock_mem0_client, - ) - # _per_operation_thread_id is None - - filters = provider._build_filters() - assert filters == { - "user_id": "user123", - "run_id": "base_thread", # Falls back to base thread_id - } - - def test_build_filters_returns_empty_dict_when_no_parameters(self, mock_mem0_client: AsyncMock) -> None: - """Test that _build_filters returns an empty dict when no parameters are set.""" - provider = Mem0Provider(mem0_client=mock_mem0_client) - - filters = provider._build_filters() - assert filters == {} + def test_empty_when_no_params(self, mock_mem0_client: AsyncMock) -> None: + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client) + assert provider._build_filters() == {} -class TestMem0Telemetry: - """Test telemetry configuration for Mem0.""" +# -- Context manager tests ----------------------------------------------------- - def test_mem0_telemetry_disabled_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Test that MEM0_TELEMETRY is set to 'false' by default when importing the package.""" - # Ensure MEM0_TELEMETRY is not set before importing the module under test - monkeypatch.delenv("MEM0_TELEMETRY", raising=False) - # Remove cached modules to force re-import and trigger module-level initialization - modules_to_remove = [key for key in sys.modules if key.startswith("agent_framework_mem0")] - for mod in modules_to_remove: - del sys.modules[mod] +class TestContextManager: + """Test __aenter__/__aexit__ delegation.""" - # Import (and reload) the module so that it can set MEM0_TELEMETRY when unset - import agent_framework_mem0 + async def test_aenter_delegates_to_client(self, mock_mem0_client: AsyncMock) -> None: + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + result = await provider.__aenter__() + assert result is provider + mock_mem0_client.__aenter__.assert_awaited_once() - importlib.reload(agent_framework_mem0) + async def test_aexit_closes_auto_created_client(self, mock_mem0_client: AsyncMock) -> None: + """Auto-created clients (_should_close_client=True) are closed on exit.""" + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + provider._should_close_client = True + await provider.__aexit__(None, None, None) + mock_mem0_client.__aexit__.assert_awaited_once() - # The environment variable should be set to "false" after importing - assert os.environ.get("MEM0_TELEMETRY") == "false" + async def test_aexit_does_not_close_provided_client(self, mock_mem0_client: AsyncMock) -> None: + """Provided clients (_should_close_client=False) are NOT closed on exit.""" + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + assert provider._should_close_client is False + await provider.__aexit__(None, None, None) + mock_mem0_client.__aexit__.assert_not_awaited() - def test_mem0_telemetry_respects_user_setting(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Test that user-set MEM0_TELEMETRY value is not overwritten.""" - # Remove cached modules to force re-import - modules_to_remove = [key for key in sys.modules if key.startswith("agent_framework_mem0")] - for mod in modules_to_remove: - del sys.modules[mod] - - # Set user preference before import - monkeypatch.setenv("MEM0_TELEMETRY", "true") - - # Re-import the module - import agent_framework_mem0 - - importlib.reload(agent_framework_mem0) - - # User setting should be preserved - assert os.environ.get("MEM0_TELEMETRY") == "true" + async def test_async_with_syntax(self, mock_mem0_client: AsyncMock) -> None: + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") + async with provider as p: + assert p is provider diff --git a/python/packages/ollama/README.md b/python/packages/ollama/README.md index f67d538507..d0bdf58cf3 100644 --- a/python/packages/ollama/README.md +++ b/python/packages/ollama/README.md @@ -10,4 +10,4 @@ and see the [README](https://github.com/microsoft/agent-framework/tree/main/pyth # Run samples with the Ollama Conector -You can find samples how to run the connector under the [Getting_started] (./getting_started/README.md) folder +You can find samples how to run the connector in the [Ollama provider samples](../../samples/02-agents/providers/ollama). diff --git a/python/packages/ollama/agent_framework_ollama/_chat_client.py b/python/packages/ollama/agent_framework_ollama/_chat_client.py index 684e5c6d9d..a074562a83 100644 --- a/python/packages/ollama/agent_framework_ollama/_chat_client.py +++ b/python/packages/ollama/agent_framework_ollama/_chat_client.py @@ -9,7 +9,6 @@ from collections.abc import ( Awaitable, Callable, Mapping, - MutableMapping, Sequence, ) from itertools import chain @@ -18,7 +17,6 @@ from typing import Any, ClassVar, Generic, TypedDict from agent_framework import ( BaseChatClient, ChatAndFunctionMiddlewareTypes, - ChatMessage, ChatMiddlewareLayer, ChatOptions, ChatResponse, @@ -27,15 +25,13 @@ from agent_framework import ( FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool, - HostedWebSearchTool, + Message, ResponseStream, - ToolProtocol, UsageDetails, get_logger, ) -from agent_framework._pydantic import AFBaseSettings +from agent_framework._settings import load_settings from agent_framework.exceptions import ( - ServiceInitializationError, ServiceInvalidRequestError, ServiceResponseException, ) @@ -45,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 @@ -65,13 +61,13 @@ else: __all__ = ["OllamaChatClient", "OllamaChatOptions"] -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None) +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) # region Ollama Chat Options TypedDict -class OllamaChatOptions(ChatOptions[TResponseModel], Generic[TResponseModel], total=False): +class OllamaChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): """Ollama-specific chat options dict. Extends base ChatOptions with Ollama-specific parameters. @@ -272,29 +268,27 @@ OLLAMA_MODEL_OPTION_TRANSLATIONS: dict[str, str] = { } """Maps ChatOptions keys to Ollama model option parameter names.""" -TOllamaChatOptions = TypeVar("TOllamaChatOptions", bound=TypedDict, default="OllamaChatOptions", covariant=True) # type: ignore[valid-type] +OllamaChatOptionsT = TypeVar("OllamaChatOptionsT", bound=TypedDict, default="OllamaChatOptions", covariant=True) # type: ignore[valid-type] # 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") class OllamaChatClient( - ChatMiddlewareLayer[TOllamaChatOptions], - FunctionInvocationLayer[TOllamaChatOptions], - ChatTelemetryLayer[TOllamaChatOptions], - BaseChatClient[TOllamaChatOptions], + ChatMiddlewareLayer[OllamaChatOptionsT], + FunctionInvocationLayer[OllamaChatOptionsT], + ChatTelemetryLayer[OllamaChatOptionsT], + BaseChatClient[OllamaChatOptionsT], ): """Ollama Chat completion class with middleware, telemetry, and function invocation support.""" @@ -325,25 +319,21 @@ 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) + self.host = str(self.client._client.base_url) # pyright: ignore[reportUnknownMemberType,reportPrivateUsage,reportUnknownArgumentType] super().__init__( middleware=middleware, @@ -356,7 +346,7 @@ class OllamaChatClient( def _inner_get_response( self, *, - messages: Sequence[ChatMessage], + messages: Sequence[Message], options: Mapping[str, Any], stream: bool = False, **kwargs: Any, @@ -397,7 +387,7 @@ class OllamaChatClient( return _get_response() - def _prepare_options(self, messages: Sequence[ChatMessage], options: Mapping[str, Any]) -> dict[str, Any]: + def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, Any]) -> dict[str, Any]: # Handle instructions by prepending to messages as system message instructions = options.get("instructions") if instructions: @@ -448,13 +438,13 @@ class OllamaChatClient( return run_options - def _prepare_messages_for_ollama(self, messages: Sequence[ChatMessage]) -> list[OllamaMessage]: + def _prepare_messages_for_ollama(self, messages: Sequence[Message]) -> list[OllamaMessage]: ollama_messages = [self._prepare_message_for_ollama(msg) for msg in messages] # Flatten the list of lists into a single list return list(chain.from_iterable(ollama_messages)) - def _prepare_message_for_ollama(self, message: ChatMessage) -> list[OllamaMessage]: - message_converters: dict[str, Callable[[ChatMessage], list[OllamaMessage]]] = { + def _prepare_message_for_ollama(self, message: Message) -> list[OllamaMessage]: + message_converters: dict[str, Callable[[Message], list[OllamaMessage]]] = { "system": self._format_system_message, "user": self._format_user_message, "assistant": self._format_assistant_message, @@ -462,10 +452,10 @@ class OllamaChatClient( } return message_converters[message.role](message) - def _format_system_message(self, message: ChatMessage) -> list[OllamaMessage]: + def _format_system_message(self, message: Message) -> list[OllamaMessage]: return [OllamaMessage(role="system", content=message.text)] - def _format_user_message(self, message: ChatMessage) -> list[OllamaMessage]: + def _format_user_message(self, message: Message) -> list[OllamaMessage]: if not any(c.type in {"text", "data"} for c in message.contents) and not message.text: raise ServiceInvalidRequestError( "Ollama connector currently only supports user messages with TextContent or DataContent." @@ -483,7 +473,7 @@ class OllamaChatClient( user_message["images"] = [c.uri.split(",")[1] for c in data_contents if c.uri] return [user_message] - def _format_assistant_message(self, message: ChatMessage) -> list[OllamaMessage]: + def _format_assistant_message(self, message: Message) -> list[OllamaMessage]: text_content = message.text # Ollama shouldn't have encrypted reasoning, so we just process text. reasoning_contents = "".join((c.text or "") for c in message.contents if c.type == "text_reasoning") @@ -506,7 +496,7 @@ class OllamaChatClient( ] return [assistant_message] - def _format_tool_message(self, message: ChatMessage) -> list[OllamaMessage]: + def _format_tool_message(self, message: Message) -> list[OllamaMessage]: # Ollama does not support multiple tool results in a single message, so we create a separate return [ OllamaMessage(role="tool", content=str(item.result), tool_name=item.call_id) @@ -538,7 +528,7 @@ class OllamaChatClient( contents = self._parse_contents_from_ollama(response) return ChatResponse( - messages=[ChatMessage(role="assistant", contents=contents)], + messages=[Message(role="assistant", contents=contents)], model_id=response.model, created_at=response.created_at, usage_details=UsageDetails( @@ -559,21 +549,22 @@ class OllamaChatClient( resp.append(fcc) return resp - def _prepare_tools_for_ollama(self, tools: list[ToolProtocol | MutableMapping[str, Any]]) -> list[dict[str, Any]]: - chat_tools: list[dict[str, Any]] = [] + def _prepare_tools_for_ollama(self, tools: list[Any]) -> list[Any]: + """Prepare tools for the Ollama API. + + Converts FunctionTool to JSON schema format. All other tools pass through unchanged. + + Args: + tools: List of tools to prepare. + + Returns: + List of tool definitions ready for the Ollama API. + """ + chat_tools: list[Any] = [] for tool in tools: - if isinstance(tool, ToolProtocol): - match tool: - case FunctionTool(): - chat_tools.append(tool.to_json_schema_spec()) - case HostedWebSearchTool(): - raise ServiceInvalidRequestError("HostedWebSearchTool is not supported by the Ollama client.") - case _: - raise ServiceInvalidRequestError( - "Unsupported tool type '" - f"{type(tool).__name__}" - "' for Ollama client. Supported tool types: FunctionTool." - ) + if isinstance(tool, FunctionTool): + chat_tools.append(tool.to_json_schema_spec()) else: - chat_tools.append(tool if isinstance(tool, dict) else dict(tool)) + # Pass through all other tools unchanged + chat_tools.append(tool) return chat_tools diff --git a/python/packages/ollama/pyproject.toml b/python/packages/ollama/pyproject.toml index 0534b7c37a..229a69113d 100644 --- a/python/packages/ollama/pyproject.toml +++ b/python/packages/ollama/pyproject.toml @@ -4,7 +4,7 @@ description = "Ollama integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://learn.microsoft.com/en-us/agent-framework/" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "ollama >= 0.5.3", ] diff --git a/python/packages/ollama/tests/test_ollama_chat_client.py b/python/packages/ollama/tests/test_ollama_chat_client.py index 3d1f51e4c8..14f21332d9 100644 --- a/python/packages/ollama/tests/test_ollama_chat_client.py +++ b/python/packages/ollama/tests/test_ollama_chat_client.py @@ -8,17 +8,16 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from agent_framework import ( BaseChatClient, - ChatMessage, ChatResponseUpdate, Content, - HostedWebSearchTool, + Message, chat_middleware, tool, ) from agent_framework.exceptions import ( - ServiceInitializationError, ServiceInvalidRequestError, ServiceResponseException, + SettingNotFoundError, ) from ollama import AsyncClient from ollama._types import ChatResponse as OllamaChatResponse @@ -77,7 +76,7 @@ def ollama_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # @fixture -def chat_history() -> list[ChatMessage]: +def chat_history() -> list[Message]: return [] @@ -183,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, @@ -208,7 +207,7 @@ def test_serialize(ollama_unit_test_env: dict[str, str]) -> None: def test_chat_middleware(ollama_unit_test_env: dict[str, str]) -> None: @chat_middleware async def sample_middleware(context, call_next): - await call_next(context) + await call_next() ollama_chat_client = OllamaChatClient(middleware=[sample_middleware]) assert len(ollama_chat_client.middleware) == 1 @@ -244,12 +243,12 @@ async def test_empty_messages() -> None: async def test_cmc( mock_chat: AsyncMock, ollama_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], mock_chat_completion_response: AsyncStream[OllamaChatResponse], ) -> None: mock_chat.return_value = mock_chat_completion_response - chat_history.append(ChatMessage(text="hello world", role="system")) - chat_history.append(ChatMessage(text="hello world", role="user")) + chat_history.append(Message(text="hello world", role="system")) + chat_history.append(Message(text="hello world", role="user")) ollama_client = OllamaChatClient() result = await ollama_client.get_response(messages=chat_history) @@ -261,11 +260,11 @@ async def test_cmc( async def test_cmc_reasoning( mock_chat: AsyncMock, ollama_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], mock_chat_completion_response_reasoning: AsyncStream[OllamaChatResponse], ) -> None: mock_chat.return_value = mock_chat_completion_response_reasoning - chat_history.append(ChatMessage(text="hello world", role="user")) + chat_history.append(Message(text="hello world", role="user")) ollama_client = OllamaChatClient() result = await ollama_client.get_response(messages=chat_history) @@ -278,11 +277,11 @@ async def test_cmc_reasoning( async def test_cmc_chat_failure( mock_chat: AsyncMock, ollama_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], ) -> None: # Simulate a failure in the Ollama client mock_chat.side_effect = Exception("Connection error") - chat_history.append(ChatMessage(text="hello world", role="user")) + chat_history.append(Message(text="hello world", role="user")) ollama_client = OllamaChatClient() @@ -297,12 +296,12 @@ async def test_cmc_chat_failure( async def test_cmc_streaming( mock_chat: AsyncMock, ollama_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], mock_streaming_chat_completion_response: AsyncStream[OllamaChatResponse], ) -> None: mock_chat.return_value = mock_streaming_chat_completion_response - chat_history.append(ChatMessage(text="hello world", role="system")) - chat_history.append(ChatMessage(text="hello world", role="user")) + chat_history.append(Message(text="hello world", role="system")) + chat_history.append(Message(text="hello world", role="user")) ollama_client = OllamaChatClient() result = ollama_client.get_response(messages=chat_history, stream=True) @@ -315,11 +314,11 @@ async def test_cmc_streaming( async def test_cmc_streaming_reasoning( mock_chat: AsyncMock, ollama_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], mock_streaming_chat_completion_response_reasoning: AsyncStream[OllamaChatResponse], ) -> None: mock_chat.return_value = mock_streaming_chat_completion_response_reasoning - chat_history.append(ChatMessage(text="hello world", role="user")) + chat_history.append(Message(text="hello world", role="user")) ollama_client = OllamaChatClient() result = ollama_client.get_response(messages=chat_history, stream=True) @@ -333,11 +332,11 @@ async def test_cmc_streaming_reasoning( async def test_cmc_streaming_chat_failure( mock_chat: AsyncMock, ollama_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], ) -> None: # Simulate a failure in the Ollama client for streaming mock_chat.side_effect = Exception("Streaming connection error") - chat_history.append(ChatMessage(text="hello world", role="user")) + chat_history.append(Message(text="hello world", role="user")) ollama_client = OllamaChatClient() @@ -353,7 +352,7 @@ async def test_cmc_streaming_chat_failure( async def test_cmc_streaming_with_tool_call( mock_chat: AsyncMock, ollama_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], mock_streaming_chat_completion_response: AsyncStream[OllamaChatResponse], mock_streaming_chat_completion_tool_call: AsyncStream[OllamaChatResponse], ) -> None: @@ -362,7 +361,7 @@ async def test_cmc_streaming_with_tool_call( mock_streaming_chat_completion_response, ] - chat_history.append(ChatMessage(text="hello world", role="user")) + chat_history.append(Message(text="hello world", role="user")) ollama_client = OllamaChatClient() result = ollama_client.get_response(messages=chat_history, stream=True, options={"tools": [hello_world]}) @@ -384,39 +383,42 @@ async def test_cmc_streaming_with_tool_call( assert text_result.text == "test" -async def test_cmc_with_hosted_tool_call( +@patch.object(AsyncClient, "chat", new_callable=AsyncMock) +async def test_cmc_with_dict_tool_passthrough( + mock_chat: AsyncMock, ollama_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], + mock_chat_completion_response: OllamaChatResponse, ) -> None: - with pytest.raises(ServiceInvalidRequestError): - additional_properties = { - "user_location": { - "country": "US", - "city": "Seattle", - } - } + """Test that dict-based tools are passed through to Ollama.""" + mock_chat.return_value = mock_chat_completion_response + chat_history.append(Message(text="hello world", role="user")) - chat_history.append(ChatMessage(text="hello world", role="user")) + ollama_client = OllamaChatClient() + await ollama_client.get_response( + messages=chat_history, + options={ + "tools": [{"type": "function", "function": {"name": "custom_tool", "parameters": {}}}], + }, + ) - ollama_client = OllamaChatClient() - await ollama_client.get_response( - messages=chat_history, - options={ - "tools": HostedWebSearchTool(additional_properties=additional_properties), - }, - ) + # Verify the tool was passed through to the Ollama client + mock_chat.assert_called_once() + call_kwargs = mock_chat.call_args.kwargs + assert "tools" in call_kwargs + assert call_kwargs["tools"] == [{"type": "function", "function": {"name": "custom_tool", "parameters": {}}}] @patch.object(AsyncClient, "chat", new_callable=AsyncMock) async def test_cmc_with_data_content_type( mock_chat: AsyncMock, ollama_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], mock_chat_completion_response: OllamaChatResponse, ) -> None: mock_chat.return_value = mock_chat_completion_response chat_history.append( - ChatMessage( + Message( contents=[Content.from_uri(uri="data:image/png;base64,xyz", media_type="image/png")], role="user", ) @@ -432,14 +434,14 @@ async def test_cmc_with_data_content_type( async def test_cmc_with_invalid_data_content_media_type( mock_chat: AsyncMock, ollama_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], mock_streaming_chat_completion_response: AsyncStream[OllamaChatResponse], ) -> None: with pytest.raises(ServiceInvalidRequestError): mock_chat.return_value = mock_streaming_chat_completion_response # Remote Uris are not supported by Ollama client chat_history.append( - ChatMessage( + Message( contents=[Content.from_uri(uri="data:audio/mp3;base64,xyz", media_type="audio/mp3")], role="user", ) @@ -455,14 +457,14 @@ async def test_cmc_with_invalid_data_content_media_type( async def test_cmc_with_invalid_content_type( mock_chat: AsyncMock, ollama_unit_test_env: dict[str, str], - chat_history: list[ChatMessage], + chat_history: list[Message], mock_chat_completion_response: AsyncStream[OllamaChatResponse], ) -> None: with pytest.raises(ServiceInvalidRequestError): mock_chat.return_value = mock_chat_completion_response # Remote Uris are not supported by Ollama client chat_history.append( - ChatMessage( + Message( contents=[Content.from_uri(uri="http://example.com/image.png", media_type="image/png")], role="user", ) @@ -475,9 +477,9 @@ async def test_cmc_with_invalid_content_type( @skip_if_azure_integration_tests_disabled async def test_cmc_integration_with_tool_call( - chat_history: list[ChatMessage], + chat_history: list[Message], ) -> None: - chat_history.append(ChatMessage(text="Call the hello world function and repeat what it says", role="user")) + chat_history.append(Message(text="Call the hello world function and repeat what it says", role="user")) ollama_client = OllamaChatClient() result = await ollama_client.get_response(messages=chat_history, options={"tools": [hello_world]}) @@ -490,9 +492,9 @@ async def test_cmc_integration_with_tool_call( @skip_if_azure_integration_tests_disabled async def test_cmc_integration_with_chat_completion( - chat_history: list[ChatMessage], + chat_history: list[Message], ) -> None: - chat_history.append(ChatMessage(text="Say Hello World", role="user")) + chat_history.append(Message(text="Say Hello World", role="user")) ollama_client = OllamaChatClient() result = await ollama_client.get_response(messages=chat_history) @@ -502,9 +504,9 @@ async def test_cmc_integration_with_chat_completion( @skip_if_azure_integration_tests_disabled async def test_cmc_streaming_integration_with_tool_call( - chat_history: list[ChatMessage], + chat_history: list[Message], ) -> None: - chat_history.append(ChatMessage(text="Call the hello world function and repeat what it says", role="user")) + chat_history.append(Message(text="Call the hello world function and repeat what it says", role="user")) ollama_client = OllamaChatClient() result: AsyncIterable[ChatResponseUpdate] = ollama_client.get_response( @@ -527,9 +529,9 @@ async def test_cmc_streaming_integration_with_tool_call( @skip_if_azure_integration_tests_disabled async def test_cmc_streaming_integration_with_chat_completion( - chat_history: list[ChatMessage], + chat_history: list[Message], ) -> None: - chat_history.append(ChatMessage(text="Say Hello World", role="user")) + chat_history.append(Message(text="Say Hello World", role="user")) ollama_client = OllamaChatClient() result: AsyncIterable[ChatResponseUpdate] = ollama_client.get_response(messages=chat_history, stream=True) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py b/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py index 4d93a3e69b..f01f3700f7 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py @@ -12,7 +12,7 @@ from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass from typing import Any, ClassVar, TypeAlias -from agent_framework._types import ChatMessage +from agent_framework._types import Message from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse from agent_framework._workflows._events import WorkflowEvent from agent_framework._workflows._executor import Executor, handler @@ -46,17 +46,17 @@ class GroupChatParticipantMessage: to other participants in the group chat to keep them synchronized. """ - messages: list[ChatMessage] + messages: list[Message] @dataclass class GroupChatResponseMessage: """Response envelope emitted by participants back to the orchestrator.""" - message: ChatMessage + message: Message -TerminationCondition: TypeAlias = Callable[[list[ChatMessage]], bool | Awaitable[bool]] +TerminationCondition: TypeAlias = Callable[[list[Message]], bool | Awaitable[bool]] GroupChatWorkflowContextOutT: TypeAlias = AgentExecutorRequest | GroupChatRequestMessage | GroupChatParticipantMessage @@ -167,7 +167,7 @@ class BaseGroupChatOrchestrator(Executor, ABC): self._round_index: int = 0 self._participant_registry = participant_registry # Shared conversation state management - self._full_conversation: list[ChatMessage] = [] + self._full_conversation: list[Message] = [] # region Handlers @@ -175,11 +175,11 @@ class BaseGroupChatOrchestrator(Executor, ABC): async def handle_str( self, task: str, - ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]], + ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]], ) -> None: """Handler for string input as workflow entry point. - Wraps the string in a USER role ChatMessage and delegates to _handle_task_message. + Wraps the string in a USER role Message and delegates to _handle_task_message. Args: task: Plain text task description from user @@ -188,32 +188,32 @@ class BaseGroupChatOrchestrator(Executor, ABC): Usage: workflow.run("Write a blog post about AI agents") """ - await self._handle_messages([ChatMessage(role="user", text=task)], ctx) + await self._handle_messages([Message(role="user", text=task)], ctx) @handler async def handle_message( self, - task: ChatMessage, - ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]], + task: Message, + ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]], ) -> None: - """Handler for single ChatMessage input as workflow entry point. + """Handler for single Message input as workflow entry point. Wraps the message in a list and delegates to _handle_task_message. Args: - task: ChatMessage from user + task: Message from user ctx: Workflow context Usage: - workflow.run(ChatMessage(role="user", text="Write a blog post about AI agents")) + workflow.run(Message(role="user", text="Write a blog post about AI agents")) """ await self._handle_messages([task], ctx) @handler async def handle_messages( self, - task: list[ChatMessage], - ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]], + task: list[Message], + ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]], ) -> None: """Handler for list of ChatMessages as workflow entry point. @@ -224,19 +224,19 @@ class BaseGroupChatOrchestrator(Executor, ABC): ctx: Workflow context Usage: workflow.run([ - ChatMessage(role="user", text="Write a blog post about AI agents"), - ChatMessage(role="user", text="Make it engaging and informative.") + Message(role="user", text="Write a blog post about AI agents"), + Message(role="user", text="Make it engaging and informative.") ]) """ if not task: - raise ValueError("At least one ChatMessage is required to start the group chat workflow.") + raise ValueError("At least one Message is required to start the group chat workflow.") await self._handle_messages(task, ctx) @handler async def handle_participant_response( self, response: AgentExecutorResponse | GroupChatResponseMessage, - ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]], + ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]], ) -> None: """Handler for participant responses. @@ -263,8 +263,8 @@ class BaseGroupChatOrchestrator(Executor, ABC): async def _handle_messages( self, - messages: list[ChatMessage], - ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]], + messages: list[Message], + ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]], ) -> None: """Handle task messages from users as workflow entry point. @@ -279,7 +279,7 @@ class BaseGroupChatOrchestrator(Executor, ABC): async def _handle_response( self, response: AgentExecutorResponse | GroupChatResponseMessage, - ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]], + ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]], ) -> None: """Handle a participant response. @@ -295,7 +295,7 @@ class BaseGroupChatOrchestrator(Executor, ABC): # Conversation state management (shared across all patterns) - def _append_messages(self, messages: Sequence[ChatMessage]) -> None: + def _append_messages(self, messages: Sequence[Message]) -> None: """Append messages to the conversation history. Args: @@ -303,7 +303,7 @@ class BaseGroupChatOrchestrator(Executor, ABC): """ self._full_conversation.extend(messages) - def _get_conversation(self) -> list[ChatMessage]: + def _get_conversation(self) -> list[Message]: """Get a copy of the current conversation. Returns: @@ -313,8 +313,8 @@ class BaseGroupChatOrchestrator(Executor, ABC): def _process_participant_response( self, response: AgentExecutorResponse | GroupChatResponseMessage - ) -> list[ChatMessage]: - """Extract ChatMessage from participant response. + ) -> list[Message]: + """Extract Message from participant response. Args: response: Response from participant @@ -351,7 +351,7 @@ class BaseGroupChatOrchestrator(Executor, ABC): result = await result return result - async def _check_terminate_and_yield(self, ctx: WorkflowContext[Never, list[ChatMessage]]) -> bool: + async def _check_terminate_and_yield(self, ctx: WorkflowContext[Never, list[Message]]) -> bool: """Check termination conditions and yield completion if met. Args: @@ -368,22 +368,22 @@ class BaseGroupChatOrchestrator(Executor, ABC): return False - def _create_completion_message(self, message: str) -> ChatMessage: + def _create_completion_message(self, message: str) -> Message: """Create a standardized completion message. Args: message: Completion text Returns: - ChatMessage with completion content + Message with completion content """ - return ChatMessage(role="assistant", text=message, author_name=self._name) + return Message(role="assistant", text=message, author_name=self._name) # Participant routing (shared across all patterns) async def _broadcast_messages_to_participants( self, - messages: list[ChatMessage], + messages: list[Message], ctx: WorkflowContext[AgentExecutorRequest | GroupChatParticipantMessage], participants: Sequence[str] | None = None, ) -> None: @@ -439,9 +439,9 @@ class BaseGroupChatOrchestrator(Executor, ABC): """ if self._participant_registry.is_agent(target): # AgentExecutors receive simple message list - messages: list[ChatMessage] = [] + messages: list[Message] = [] if additional_instruction: - messages.append(ChatMessage(role="user", text=additional_instruction)) + messages.append(Message(role="user", text=additional_instruction)) request = AgentExecutorRequest(messages=messages, should_respond=True) await ctx.send_message(request, target_id=target) await ctx.add_event( @@ -490,7 +490,7 @@ class BaseGroupChatOrchestrator(Executor, ABC): return False - async def _check_round_limit_and_yield(self, ctx: WorkflowContext[Never, list[ChatMessage]]) -> bool: + async def _check_round_limit_and_yield(self, ctx: WorkflowContext[Never, list[Message]]) -> bool: """Check round limit and yield completion if reached. Args: diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py index 9163168859..062e87806c 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py @@ -6,7 +6,7 @@ import logging from collections.abc import Callable, Sequence from typing import Any -from agent_framework import ChatMessage, SupportsAgentRun +from agent_framework import 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 @@ -29,8 +29,7 @@ parallel workflow with: - a default aggregator that combines all agent conversations and completes the workflow Notes: -- Participants can be provided as SupportsAgentRun or Executor instances via `participants=[...]`, - or as factories returning SupportsAgentRun or Executor via `participant_factories=[...]`. +- Participants can be provided as SupportsAgentRun or Executor instances via `participants=[...]`. - A custom aggregator can be provided as: - an Executor instance (it should handle list[AgentExecutorResponse], yield output), or @@ -57,14 +56,14 @@ class _DispatchToAllParticipants(Executor): await ctx.send_message(request) @handler - async def from_message(self, message: ChatMessage, ctx: WorkflowContext[AgentExecutorRequest]) -> None: + async def from_message(self, message: Message, ctx: WorkflowContext[AgentExecutorRequest]) -> None: request = AgentExecutorRequest(messages=normalize_messages_input(message), should_respond=True) await ctx.send_message(request) @handler async def from_messages( self, - messages: list[str | ChatMessage], + messages: list[str | Message], ctx: WorkflowContext[AgentExecutorRequest], ) -> None: request = AgentExecutorRequest(messages=normalize_messages_input(messages), should_respond=True) @@ -74,7 +73,7 @@ class _DispatchToAllParticipants(Executor): class _AggregateAgentConversations(Executor): """Aggregates agent responses and completes with combined ChatMessages. - Emits a list[ChatMessage] shaped as: + Emits a list[Message] shaped as: [ single_user_prompt?, agent1_final_assistant, agent2_final_assistant, ... ] - Extracts a single user prompt (first user message seen across results). @@ -83,9 +82,7 @@ class _AggregateAgentConversations(Executor): """ @handler - async def aggregate( - self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, list[ChatMessage]] - ) -> None: + async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, list[Message]]) -> None: if not results: logger.error("Concurrent aggregator received empty results list") raise ValueError("Aggregation failed: no results provided") @@ -99,8 +96,8 @@ class _AggregateAgentConversations(Executor): role_str = str(role).lower() return r_str == role_str - prompt_message: ChatMessage | None = None - assistant_replies: list[ChatMessage] = [] + prompt_message: Message | None = None + assistant_replies: list[Message] = [] for r in results: resp_messages = list(getattr(r.agent_response, "messages", []) or []) @@ -133,7 +130,7 @@ class _AggregateAgentConversations(Executor): logger.error(f"Aggregation failed: no assistant replies found across {len(results)} results") raise RuntimeError("Aggregation failed: no assistant replies found") - output: list[ChatMessage] = [] + output: list[Message] = [] if prompt_message is not None: output.append(prompt_message) else: @@ -187,11 +184,8 @@ class ConcurrentBuilder: r"""High-level builder for concurrent agent workflows. - `participants=[...]` accepts a list of SupportsAgentRun (recommended) or Executor. - - `participant_factories=[...]` accepts a list of factories for SupportsAgentRun (recommended) - or Executor factories - `build()` wires: dispatcher -> fan-out -> participants -> fan-in -> aggregator. - `with_aggregator(...)` overrides the default aggregator with an Executor or callback. - - `register_aggregator(...)` accepts a factory for an Executor as custom aggregator. Usage: @@ -199,12 +193,9 @@ class ConcurrentBuilder: from agent_framework_orchestrations import ConcurrentBuilder - # Minimal: use default aggregator (returns list[ChatMessage]) + # Minimal: use default aggregator (returns list[Message]) workflow = ConcurrentBuilder(participants=[agent1, agent2, agent3]).build() - # With agent factories - workflow = ConcurrentBuilder(participant_factories=[create_agent1, create_agent2, create_agent3]).build() - # Custom aggregator via callback (sync or async). The callback receives # list[AgentExecutorResponse] and its return value becomes the workflow's output. @@ -215,20 +206,6 @@ class ConcurrentBuilder: workflow = ConcurrentBuilder(participants=[agent1, agent2, agent3]).with_aggregator(summarize).build() - # Custom aggregator via a factory - class MyAggregator(Executor): - @handler - async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None: - await ctx.yield_output(" | ".join(r.agent_response.messages[-1].text for r in results)) - - - workflow = ( - ConcurrentBuilder(participant_factories=[create_agent1, create_agent2, create_agent3]) - .register_aggregator(lambda: MyAggregator(id="my_aggregator")) - .build() - ) - - # Enable checkpoint persistence so runs can resume workflow = ConcurrentBuilder(participants=[agent1, agent2, agent3], checkpoint_storage=storage).build() @@ -239,58 +216,29 @@ class ConcurrentBuilder: def __init__( self, *, - participants: Sequence[SupportsAgentRun | Executor] | None = None, - participant_factories: Sequence[Callable[[], SupportsAgentRun | Executor]] | None = None, + participants: Sequence[SupportsAgentRun | Executor], checkpoint_storage: CheckpointStorage | None = None, intermediate_outputs: bool = False, ) -> None: """Initialize the ConcurrentBuilder. Args: - participants: Optional sequence of agent or executor instances to run in parallel. - participant_factories: Optional sequence of callables returning agent or executor instances. + participants: Sequence of agent or executor instances to run in parallel. checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence. intermediate_outputs: If True, enables intermediate outputs from agent participants before aggregation. """ self._participants: list[SupportsAgentRun | Executor] = [] - self._participant_factories: list[Callable[[], SupportsAgentRun | Executor]] = [] self._aggregator: Executor | None = None - self._aggregator_factory: Callable[[], Executor] | None = None self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage self._request_info_enabled: bool = False self._request_info_filter: set[str] | None = None self._intermediate_outputs: bool = intermediate_outputs - if participants is None and participant_factories is None: - raise ValueError("Either participants or participant_factories must be provided.") - - if participant_factories is not None: - self._set_participant_factories(participant_factories) - if participants is not None: - self._set_participants(participants) - - def _set_participant_factories( - self, - participant_factories: Sequence[Callable[[], SupportsAgentRun | Executor]], - ) -> None: - """Set participant factories (internal).""" - if self._participants: - raise ValueError("Cannot provide both participants and participant_factories.") - - if self._participant_factories: - raise ValueError("participant_factories already set.") - - if not participant_factories: - raise ValueError("participant_factories cannot be empty") - - self._participant_factories = list(participant_factories) + self._set_participants(participants) def _set_participants(self, participants: Sequence[SupportsAgentRun | Executor]) -> None: """Set participants (internal).""" - if self._participant_factories: - raise ValueError("Cannot provide both participants and participant_factories.") - if self._participants: raise ValueError("participants already set.") @@ -315,39 +263,6 @@ class ConcurrentBuilder: self._participants = list(participants) - def register_aggregator(self, aggregator_factory: Callable[[], Executor]) -> "ConcurrentBuilder": - r"""Define a custom aggregator for this concurrent workflow. - - Accepts a factory (callable) that returns an Executor instance. The executor - should handle `list[AgentExecutorResponse]` and yield output using `ctx.yield_output(...)`. - - Args: - aggregator_factory: Callable that returns an Executor instance - - Example: - .. code-block:: python - - class MyCustomExecutor(Executor): ... - - - wf = ( - ConcurrentBuilder() - .register_participants([create_researcher, create_marketer, create_legal]) - .register_aggregator(lambda: MyCustomExecutor(id="my_aggregator")) - .build() - ) - """ - if self._aggregator is not None: - raise ValueError( - "Cannot mix .with_aggregator(...) and .register_aggregator(...) in the same builder instance." - ) - - if self._aggregator_factory is not None: - raise ValueError("register_aggregator() has already been called on this builder instance.") - - self._aggregator_factory = aggregator_factory - return self - def with_aggregator( self, aggregator: Executor @@ -393,11 +308,6 @@ class ConcurrentBuilder: wf = ConcurrentBuilder(participants=[a1, a2, a3]).with_aggregator(summarize).build() """ - if self._aggregator_factory is not None: - raise ValueError( - "Cannot mix .with_aggregator(...) and .register_aggregator(...) in the same builder instance." - ) - if self._aggregator is not None: raise ValueError("with_aggregator() has already been called on this builder instance.") @@ -445,19 +355,10 @@ class ConcurrentBuilder: def _resolve_participants(self) -> list[Executor]: """Resolve participant instances into Executor objects.""" - if not self._participants and not self._participant_factories: - raise ValueError("No participants provided. Pass participants or participant_factories to the constructor.") - # We don't need to check if both are set since that is handled in the respective methods + if not self._participants: + raise ValueError("No participants provided. Pass participants to the constructor.") - participants: list[Executor | SupportsAgentRun] = [] - if self._participant_factories: - # Resolve the participant factories now. This doesn't break the factory pattern - # since the Sequential builder still creates new instances per workflow build. - for factory in self._participant_factories: - p = factory() - participants.append(p) - else: - participants = self._participants + participants: list[Executor | SupportsAgentRun] = self._participants executors: list[Executor] = [] for p in participants: @@ -485,7 +386,7 @@ class ConcurrentBuilder: - If request info is enabled, the orchestration emits a request info event with outputs from all participants before sending the outputs to the aggregator - Aggregator yields output and the workflow becomes idle. The output is either: - - list[ChatMessage] (default aggregator: one user + one assistant per agent) + - list[Message] (default aggregator: one user + one assistant per agent) - custom payload from the provided aggregator Returns: @@ -502,15 +403,7 @@ class ConcurrentBuilder: """ # Internal nodes dispatcher = _DispatchToAllParticipants(id="dispatcher") - aggregator = ( - self._aggregator - if self._aggregator is not None - else ( - self._aggregator_factory() - if self._aggregator_factory is not None - else _AggregateAgentConversations(id="aggregator") - ) - ) + aggregator = self._aggregator if self._aggregator is not None else _AggregateAgentConversations(id="aggregator") # Resolve participants and participant factories to executors participants: list[Executor] = self._resolve_participants() diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py index f4edbbdcb1..a99e221409 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py @@ -21,6 +21,7 @@ existing observability and streaming semantics continue to apply. from __future__ import annotations import inspect +import json import logging import sys from collections import OrderedDict @@ -28,13 +29,10 @@ from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass from typing import Any, ClassVar, cast -from agent_framework import ChatAgent, SupportsAgentRun -from agent_framework._threads import AgentThread -from agent_framework._types import ChatMessage +from agent_framework import Agent, AgentSession, 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 @@ -69,7 +67,7 @@ class GroupChatState: Attributes: current_round: The current round index of the group chat, starting from 0. participants: A mapping of participant names to their descriptions in the group chat. - conversation: The full conversation history up to this point as a list of ChatMessage. + conversation: The full conversation history up to this point as a list of Message. """ # Round index, starting from 0 @@ -77,7 +75,7 @@ class GroupChatState: # participant name to description mapping as a ordered dict participants: OrderedDict[str, str] # Full conversation history up to this point - conversation: list[ChatMessage] + conversation: list[Message] # region Default orchestrator @@ -165,13 +163,13 @@ class GroupChatOrchestrator(BaseGroupChatOrchestrator): @override async def _handle_messages( self, - messages: list[ChatMessage], - ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]], + messages: list[Message], + ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]], ) -> None: """Initialize orchestrator state and start the conversation loop.""" self._append_messages(messages) # Termination condition will also be applied to the input messages - if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[ChatMessage]], ctx)): + if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): return next_speaker = await self._get_next_speaker() @@ -192,7 +190,7 @@ class GroupChatOrchestrator(BaseGroupChatOrchestrator): async def _handle_response( self, response: AgentExecutorResponse | GroupChatResponseMessage, - ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]], + ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]], ) -> None: """Handle a participant response.""" messages = self._process_participant_response(response) @@ -200,9 +198,9 @@ class GroupChatOrchestrator(BaseGroupChatOrchestrator): messages = clean_conversation_for_handoff(messages) self._append_messages(messages) - if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[ChatMessage]], ctx)): + if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): return - if await self._check_round_limit_and_yield(cast(WorkflowContext[Never, list[ChatMessage]], ctx)): + if await self._check_round_limit_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): return next_speaker = await self._get_next_speaker() @@ -287,13 +285,13 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator): def __init__( self, - agent: ChatAgent, + agent: Agent, participant_registry: ParticipantRegistry, *, max_rounds: int | None = None, termination_condition: TerminationCondition | None = None, retry_attempts: int | None = None, - thread: AgentThread | None = None, + session: AgentSession | None = None, ) -> None: """Initialize the GroupChatOrchestrator. @@ -304,7 +302,7 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator): max_rounds: Optional limit on selection rounds to prevent infinite loops. termination_condition: Optional callable that halts the conversation when it returns True retry_attempts: Optional number of retry attempts for the agent in case of failure. - thread: Optional agent thread to use for the orchestrator agent. + session: Optional agent session to use for the orchestrator agent. """ super().__init__( resolve_agent_id(agent), @@ -315,32 +313,32 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator): ) self._agent = agent self._retry_attempts = retry_attempts - self._thread = thread or agent.get_new_thread() + self._session = session or agent.create_session() # Cache for messages since last agent invocation # This is different from the full conversation history maintained by the base orchestrator - self._cache: list[ChatMessage] = [] + self._cache: list[Message] = [] @override - def _append_messages(self, messages: Sequence[ChatMessage]) -> None: + def _append_messages(self, messages: Sequence[Message]) -> None: self._cache.extend(messages) return super()._append_messages(messages) @override async def _handle_messages( self, - messages: list[ChatMessage], - ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]], + messages: list[Message], + ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]], ) -> None: """Initialize orchestrator state and start the conversation loop.""" self._append_messages(messages) # Termination condition will also be applied to the input messages - if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[ChatMessage]], ctx)): + if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): return agent_orchestration_output = await self._invoke_agent() if await self._check_agent_terminate_and_yield( agent_orchestration_output, - cast(WorkflowContext[Never, list[ChatMessage]], ctx), + cast(WorkflowContext[Never, list[Message]], ctx), ): return @@ -361,22 +359,22 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator): async def _handle_response( self, response: AgentExecutorResponse | GroupChatResponseMessage, - ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]], + ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]], ) -> None: """Handle a participant response.""" messages = self._process_participant_response(response) # Remove tool-related content to prevent API errors from empty messages messages = clean_conversation_for_handoff(messages) self._append_messages(messages) - if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[ChatMessage]], ctx)): + if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): return - if await self._check_round_limit_and_yield(cast(WorkflowContext[Never, list[ChatMessage]], ctx)): + if await self._check_round_limit_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): return agent_orchestration_output = await self._invoke_agent() if await self._check_agent_terminate_and_yield( agent_orchestration_output, - cast(WorkflowContext[Never, list[ChatMessage]], ctx), + cast(WorkflowContext[Never, list[Message]], ctx), ): return @@ -396,18 +394,88 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator): ) self._increment_round() + @staticmethod + def _parse_last_json_object(text: str) -> AgentOrchestrationOutput | None: + """Best-effort parser for concatenated JSON and return the last object. + + Stop-gap workaround: + In some runs, the orchestrator manager text can contain multiple JSON objects + concatenated back-to-back (for example: `{...}{...}`), which causes + `model_validate_json` to fail with trailing characters. Until the root cause + is fully understood and fixed, decode sequential top-level JSON values and + validate the last one. + """ + decoder = json.JSONDecoder() + index = 0 + parsed: Any | None = None + + while index < len(text): + while index < len(text) and text[index].isspace(): + index += 1 + if index >= len(text): + break + parsed, index = decoder.raw_decode(text, index) + + if parsed is None: + return None + return AgentOrchestrationOutput.model_validate(parsed) + + @classmethod + def _parse_agent_output(cls, agent_response: Any) -> AgentOrchestrationOutput: + """Parse manager output with defensive fallbacks. + + Preferred path is structured output (`agent_response.value`) when available. + If only text is available, first attempt strict JSON parsing and then apply a + temporary concatenated-JSON fallback as a stop-gap. + """ + try: + structured_value = agent_response.value + except Exception: + structured_value = None + + if structured_value is not None: + return AgentOrchestrationOutput.model_validate(structured_value) + + text_candidates: list[str] = [] + for message in reversed(agent_response.messages): + if message.role == "assistant" and message.text.strip(): + text_candidates.append(message.text.strip()) + break + + response_text = agent_response.text.strip() + if response_text and response_text not in text_candidates: + text_candidates.append(response_text) + + last_error: Exception | None = None + for candidate in text_candidates: + try: + return AgentOrchestrationOutput.model_validate_json(candidate) + except Exception as ex: + last_error = ex + + try: + # Stop-gap fallback for rare cases where multiple JSON objects are + # returned in one text payload (concatenated with no separator). + parsed = cls._parse_last_json_object(candidate) + if parsed is not None: + return parsed + except Exception as ex: + last_error = ex + + raise ValueError("Failed to parse agent orchestration output.") from last_error + async def _invoke_agent(self) -> AgentOrchestrationOutput: """Invoke the orchestrator agent to determine the next speaker and termination.""" - async def _invoke_agent_helper(conversation: list[ChatMessage]) -> AgentOrchestrationOutput: + async def _invoke_agent_helper(conversation: list[Message]) -> AgentOrchestrationOutput: # Run the agent in non-streaming mode for simplicity agent_response = await self._agent.run( messages=conversation, - thread=self._thread, + session=self._session, options={"response_format": AgentOrchestrationOutput}, ) # Parse and validate the structured output - agent_orchestration_output = AgentOrchestrationOutput.model_validate_json(agent_response.text) + agent_orchestration_output = self._parse_agent_output(agent_response) if not agent_orchestration_output.terminate and not agent_orchestration_output.next_speaker: raise ValueError("next_speaker must be provided if not terminating the conversation.") @@ -431,7 +499,7 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator): ]) ) # Prepend instruction as system message - current_conversation.append(ChatMessage(role="user", text=instruction)) + current_conversation.append(Message(role="user", text=instruction)) retry_attempts = self._retry_attempts while True: @@ -445,7 +513,7 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator): logger.debug(f"Retrying agent orchestration invocation, attempts left: {retry_attempts}") # We don't need the full conversation since the thread should maintain history current_conversation = [ - ChatMessage( + Message( role="user", text=f"Your input could not be parsed due to an error: {ex}. Please try again.", ) @@ -454,7 +522,7 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator): async def _check_agent_terminate_and_yield( self, agent_orchestration_output: AgentOrchestrationOutput, - ctx: WorkflowContext[Never, list[ChatMessage]], + ctx: WorkflowContext[Never, list[Message]], ) -> bool: """Check if the agent requested termination and yield completion if so. @@ -478,9 +546,9 @@ 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) - serialized_thread = await self._thread.serialize() - state["thread"] = serialized_thread + state["cache"] = self._cache + serialized_session = self._session.to_dict() + state["session"] = serialized_session return state @@ -488,10 +556,10 @@ 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", [])) - serialized_thread = state.get("thread") - if serialized_thread: - self._thread = await self._agent.deserialize_thread(serialized_thread) + self._cache = state.get("cache", []) + serialized_session = state.get("session") + if serialized_session: + self._session = AgentSession.from_dict(serialized_session) # endregion @@ -518,7 +586,7 @@ class GroupChatBuilder: into a complete workflow graph that can be executed. Outputs: - The final conversation history as a list of ChatMessage once the group chat completes. + The final conversation history as a list of Message once the group chat completes. """ DEFAULT_ORCHESTRATOR_ID: ClassVar[str] = "group_chat_orchestrator" @@ -529,7 +597,7 @@ class GroupChatBuilder: participants: Sequence[SupportsAgentRun | Executor] | None = None, participant_factories: Sequence[Callable[[], SupportsAgentRun | Executor]] | None = None, # Orchestrator config (exactly one required) - orchestrator_agent: ChatAgent | Callable[[], ChatAgent] | None = None, + orchestrator_agent: Agent | Callable[[], Agent] | None = None, orchestrator: BaseGroupChatOrchestrator | Callable[[], BaseGroupChatOrchestrator] | None = None, selection_func: GroupChatSelectionFunction | None = None, orchestrator_name: str | None = None, @@ -544,7 +612,7 @@ class GroupChatBuilder: Args: participants: Optional sequence of agent or executor instances for the group chat. participant_factories: Optional sequence of callables returning agent or executor instances. - orchestrator_agent: An instance of ChatAgent or a callable that produces one to manage the group chat. + orchestrator_agent: An instance of Agent or a callable that produces one to manage the group chat. orchestrator: An instance of BaseGroupChatOrchestrator or a callable that produces one to manage the group chat. selection_func: Callable that receives the current GroupChatState and returns the name of the next @@ -561,9 +629,9 @@ class GroupChatBuilder: # Orchestrator related members self._orchestrator: BaseGroupChatOrchestrator | None = None - self._orchestrator_factory: Callable[[], ChatAgent | BaseGroupChatOrchestrator] | None = None + self._orchestrator_factory: Callable[[], Agent | BaseGroupChatOrchestrator] | None = None self._selection_func: GroupChatSelectionFunction | None = None - self._agent_orchestrator: ChatAgent | None = None + self._agent_orchestrator: Agent | None = None self._termination_condition: TerminationCondition | None = termination_condition self._max_rounds: int | None = max_rounds self._orchestrator_name: str | None = None @@ -598,7 +666,7 @@ class GroupChatBuilder: def _set_orchestrator( self, *, - orchestrator_agent: ChatAgent | Callable[[], ChatAgent] | None = None, + orchestrator_agent: Agent | Callable[[], Agent] | None = None, orchestrator: BaseGroupChatOrchestrator | Callable[[], BaseGroupChatOrchestrator] | None = None, selection_func: GroupChatSelectionFunction | None = None, orchestrator_name: str | None = None, @@ -606,7 +674,7 @@ class GroupChatBuilder: """Set the orchestrator for this group chat workflow (internal). Args: - orchestrator_agent: An instance of ChatAgent or a callable that produces one to manage the group chat. + orchestrator_agent: An instance of Agent or a callable that produces one to manage the group chat. orchestrator: An instance of BaseGroupChatOrchestrator or a callable that produces one to manage the group chat. selection_func: Callable that receives the current GroupChatState and returns @@ -635,7 +703,7 @@ class GroupChatBuilder: if sum(x is not None for x in [orchestrator_agent, orchestrator, selection_func]) != 1: raise ValueError("Exactly one of orchestrator_agent, orchestrator, or selection_func must be provided.") - if orchestrator_agent is not None and isinstance(orchestrator_agent, ChatAgent): + if orchestrator_agent is not None and isinstance(orchestrator_agent, Agent): self._agent_orchestrator = orchestrator_agent elif orchestrator is not None and isinstance(orchestrator, BaseGroupChatOrchestrator): self._orchestrator = orchestrator @@ -707,11 +775,11 @@ class GroupChatBuilder: .. code-block:: python - from agent_framework import ChatMessage + from agent_framework import Message from agent_framework_orchestrations import GroupChatBuilder - def stop_after_two_calls(conversation: list[ChatMessage]) -> bool: + def stop_after_two_calls(conversation: list[Message]) -> bool: calls = sum(1 for msg in conversation if msg.role == "assistant" and msg.author_name == "specialist") return calls >= 2 @@ -852,7 +920,7 @@ class GroupChatBuilder: if self._orchestrator_factory: orchestrator_instance = self._orchestrator_factory() - if isinstance(orchestrator_instance, ChatAgent): + if isinstance(orchestrator_instance, Agent): return AgentBasedGroupChatOrchestrator( agent=orchestrator_instance, participant_registry=ParticipantRegistry(participants), @@ -862,7 +930,7 @@ class GroupChatBuilder: if isinstance(orchestrator_instance, BaseGroupChatOrchestrator): return orchestrator_instance raise TypeError( - f"Orchestrator factory must return ChatAgent or BaseGroupChatOrchestrator instance. " + f"Orchestrator factory must return Agent or BaseGroupChatOrchestrator instance. " f"Got {type(orchestrator_instance).__name__}." ) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py index a227b6955e..ea5f8bd201 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py @@ -32,15 +32,15 @@ Key properties: import inspect import logging import sys -from collections.abc import Awaitable, Callable, Mapping, Sequence +from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass from typing import Any, cast -from agent_framework import ChatAgent, SupportsAgentRun +from agent_framework import Agent, SupportsAgentRun from agent_framework._middleware import FunctionInvocationContext, FunctionMiddleware -from agent_framework._threads import AgentThread +from agent_framework._sessions import AgentSession from agent_framework._tools import FunctionTool, tool -from agent_framework._types import AgentResponse, AgentResponseUpdate, ChatMessage +from agent_framework._types import AgentResponse, AgentResponseUpdate, Message 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 @@ -129,11 +129,11 @@ class _AutoHandoffMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """Intercept matching handoff tool calls and inject synthetic results.""" if context.function.name not in self._handoff_functions: - await call_next(context) + await call_next() return from agent_framework._middleware import MiddlewareTermination @@ -154,28 +154,28 @@ class HandoffAgentUserRequest: agent_response: AgentResponse @staticmethod - def create_response(response: str | list[str] | ChatMessage | list[ChatMessage]) -> list[ChatMessage]: + def create_response(response: str | list[str] | Message | list[Message]) -> list[Message]: """Create a HandoffAgentUserRequest from a simple text response.""" - messages: list[ChatMessage] = [] + messages: list[Message] = [] if isinstance(response, str): - messages.append(ChatMessage(role="user", text=response)) - elif isinstance(response, ChatMessage): + messages.append(Message(role="user", text=response)) + elif isinstance(response, Message): messages.append(response) elif isinstance(response, list): for item in response: - if isinstance(item, ChatMessage): + if isinstance(item, Message): messages.append(item) elif isinstance(item, str): - messages.append(ChatMessage(role="user", text=item)) + messages.append(Message(role="user", text=item)) else: - raise TypeError("List items must be either str or ChatMessage instances") + raise TypeError("List items must be either str or Message instances") else: - raise TypeError("Response must be str, list of str, ChatMessage, or list of ChatMessage") + raise TypeError("Response must be str, list of str, Message, or list of Message") return messages @staticmethod - def terminate() -> list[ChatMessage]: + def terminate() -> list[Message]: """Create a termination response for the handoff workflow.""" return [] @@ -196,7 +196,7 @@ class HandoffAgentExecutor(AgentExecutor): agent: SupportsAgentRun, handoffs: Sequence[HandoffConfiguration], *, - agent_thread: AgentThread | None = None, + agent_session: AgentSession | None = None, is_start_agent: bool = False, termination_condition: TerminationCondition | None = None, autonomous_mode: bool = False, @@ -208,7 +208,7 @@ class HandoffAgentExecutor(AgentExecutor): Args: agent: The agent to execute handoffs: Sequence of handoff configurations defining target agents - agent_thread: Optional AgentThread that manages the agent's execution context + agent_session: Optional AgentSession that manages the agent's execution context is_start_agent: Whether this agent is the starting agent in the handoff workflow. There can only be one starting agent in a handoff workflow. termination_condition: Optional callable that determines when to terminate the workflow @@ -222,7 +222,7 @@ class HandoffAgentExecutor(AgentExecutor): autonomous_mode_turn_limit: Maximum number of autonomous turns before requesting user input. """ cloned_agent = self._prepare_agent_with_handoffs(agent, handoffs) - super().__init__(cloned_agent, agent_thread=agent_thread) + super().__init__(cloned_agent, session=agent_session) self._handoff_targets = {handoff.target_id for handoff in handoffs} self._termination_condition = termination_condition @@ -248,10 +248,8 @@ class HandoffAgentExecutor(AgentExecutor): Returns: A new AgentExecutor instance with handoff tools added """ - if not isinstance(agent, ChatAgent): - raise TypeError( - "Handoff can only be applied to ChatAgent. Please ensure the agent is a ChatAgent instance." - ) + if not isinstance(agent, Agent): + raise TypeError("Handoff can only be applied to Agent. Please ensure the agent is a Agent instance.") # Clone the agent to avoid mutating the original cloned_agent = self._clone_chat_agent(agent) # type: ignore @@ -265,13 +263,13 @@ class HandoffAgentExecutor(AgentExecutor): return cloned_agent - def _clone_chat_agent(self, agent: ChatAgent) -> ChatAgent: - """Produce a deep copy of the ChatAgent while preserving runtime configuration.""" + def _clone_chat_agent(self, agent: Agent) -> Agent: + """Produce a deep copy of the Agent while preserving runtime configuration.""" options = agent.default_options middleware = list(agent.middleware or []) # Reconstruct the original tools list by combining regular tools with MCP tools. - # ChatAgent.__init__ separates MCP tools during initialization, + # Agent.__init__ separates MCP tools during initialization, # so we need to recombine them here to pass the complete tools list to the constructor. # This makes sure MCP tools are preserved when cloning agents for handoff workflows. tools_from_options = options.get("tools") @@ -303,31 +301,30 @@ class HandoffAgentExecutor(AgentExecutor): "user": options.get("user"), } - return ChatAgent( - chat_client=agent.chat_client, + return Agent( + client=agent.client, id=agent.id, name=agent.name, description=agent.description, - chat_message_store_factory=agent.chat_message_store_factory, - context_provider=agent.context_provider, + context_providers=agent.context_providers, middleware=middleware, default_options=cloned_options, # type: ignore[arg-type] ) - def _apply_auto_tools(self, agent: ChatAgent, targets: Sequence[HandoffConfiguration]) -> None: + def _apply_auto_tools(self, agent: Agent, targets: Sequence[HandoffConfiguration]) -> None: """Attach synthetic handoff tools to a chat agent and return the target lookup table. Creates handoff tools for each specialist agent that this agent can route to. Args: - agent: The ChatAgent to add handoff tools to + agent: The Agent to add handoff tools to targets: Sequence of handoff configurations defining target agents """ default_options = agent.default_options existing_tools = list(default_options.get("tools") or []) existing_names = {getattr(tool, "name", "") for tool in existing_tools if hasattr(tool, "name")} - new_tools: list[FunctionTool[Any, Any]] = [] + new_tools: list[FunctionTool[Any]] = [] for target in targets: handoff_tool = self._create_handoff_tool(target.target_id, target.description) if handoff_tool.name in existing_names: @@ -343,7 +340,7 @@ class HandoffAgentExecutor(AgentExecutor): else: default_options["tools"] = existing_tools - def _create_handoff_tool(self, target_id: str, description: str | None = None) -> FunctionTool[Any, Any]: + def _create_handoff_tool(self, target_id: str, description: str | None = None) -> FunctionTool[Any]: """Construct the synthetic handoff tool that signals routing to `target_id`.""" tool_name = get_handoff_tool_name(target_id) doc = description or f"Handoff to the {target_id} agent." @@ -375,7 +372,7 @@ class HandoffAgentExecutor(AgentExecutor): self._full_conversation.extend(self._cache) # Check termination condition before running the agent - if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[ChatMessage]], ctx)): + if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): return # Run the agent @@ -427,19 +424,19 @@ class HandoffAgentExecutor(AgentExecutor): # or a termination condition is met. # This allows the agent to perform long-running tasks without returning control # to the coordinator or user prematurely. - self._cache.extend([ChatMessage(role="user", text=self._autonomous_mode_prompt)]) + self._cache.extend([Message(role="user", text=self._autonomous_mode_prompt)]) self._autonomous_mode_turns += 1 await self._run_agent_and_emit(ctx) else: # The response is handled via `handle_response` self._autonomous_mode_turns = 0 # Reset autonomous mode turn counter on handoff - await ctx.request_info(HandoffAgentUserRequest(response), list[ChatMessage]) + await ctx.request_info(HandoffAgentUserRequest(response), list[Message]) @response_handler async def handle_response( self, original_request: HandoffAgentUserRequest, - response: list[ChatMessage], + response: list[Message], ctx: WorkflowContext[AgentExecutorResponse, AgentResponse], ) -> None: """Handle user response for a request that is issued after agent runs. @@ -458,7 +455,7 @@ class HandoffAgentExecutor(AgentExecutor): If the response is empty, it indicates termination of the handoff workflow. """ if not response: - await cast(WorkflowContext[Never, list[ChatMessage]], ctx).yield_output(self._full_conversation) + await cast(WorkflowContext[Never, list[Message]], ctx).yield_output(self._full_conversation) return # Broadcast the user response to all other agents @@ -472,7 +469,7 @@ class HandoffAgentExecutor(AgentExecutor): async def _broadcast_messages( self, - messages: list[ChatMessage], + messages: list[Message], ctx: WorkflowContext[AgentExecutorRequest], ) -> None: """Broadcast the workflow cache to the agent before running.""" @@ -506,7 +503,7 @@ class HandoffAgentExecutor(AgentExecutor): return None - async def _check_terminate_and_yield(self, ctx: WorkflowContext[Never, list[ChatMessage]]) -> bool: + async def _check_terminate_and_yield(self, ctx: WorkflowContext[Never, list[Message]]) -> bool: """Check termination conditions and yield completion if met. Args: @@ -561,10 +558,10 @@ class HandoffBuilder: Participants must be agents. Support for custom executors is not available in handoff workflows. Outputs: - The final conversation history as a list of ChatMessage once the group chat completes. + The final conversation history as a list of Message once the group chat completes. Note: - 1. Agents in handoff workflows must be ChatAgent instances and support local tool calls. + 1. Agents in handoff workflows must be Agent instances and support local tool calls. 2. Handoff doesn't support intermediate outputs from agents. All outputs are returned as they become available. This is because agents in handoff workflows are not considered sub-agents of a central orchestrator, thus all outputs are directly emitted. @@ -575,7 +572,6 @@ class HandoffBuilder: *, name: str | None = None, participants: Sequence[SupportsAgentRun] | None = None, - participant_factories: Mapping[str, Callable[[], SupportsAgentRun]] | None = None, description: str | None = None, checkpoint_storage: CheckpointStorage | None = None, termination_condition: TerminationCondition | None = None, @@ -584,8 +580,7 @@ class HandoffBuilder: The builder starts in an unconfigured state and requires you to call: 1. `.participants([...])` - Register agents - 2. or `.participant_factories({...})` - Register agent factories - 3. `.build()` - Construct the final Workflow + 2. `.build()` - Construct the final Workflow Optional configuration methods allow you to customize context management, termination logic, and persistence. @@ -596,9 +591,6 @@ class HandoffBuilder: participants: Optional list of agents that will participate in the handoff workflow. You can also call `.participants([...])` later. Each participant must have a unique identifier (`.name` is preferred if set, otherwise `.id` is used). - participant_factories: Optional mapping of factory names to callables that produce agents when invoked. - This allows for lazy instantiation and state isolation per workflow instance - created by this builder. description: Optional human-readable description explaining the workflow's purpose. Useful for documentation and observability. checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence. @@ -610,10 +602,7 @@ class HandoffBuilder: # Participant related members self._participants: dict[str, SupportsAgentRun] = {} - self._participant_factories: dict[str, Callable[[], SupportsAgentRun]] = {} self._start_id: str | None = None - if participant_factories: - self.register_participants(participant_factories) if participants: self.participants(participants) @@ -631,71 +620,7 @@ class HandoffBuilder: self._autonomous_mode_enabled_agents: list[str] = [] # Termination related members - self._termination_condition: Callable[[list[ChatMessage]], bool | Awaitable[bool]] | None = ( - termination_condition - ) - - def register_participants( - self, participant_factories: Mapping[str, Callable[[], SupportsAgentRun]] - ) -> "HandoffBuilder": - """Register factories that produce agents for the handoff workflow. - - Each factory is a callable that returns an SupportsAgentRun instance. - Factories are invoked when building the workflow, allowing for lazy instantiation - and state isolation per workflow instance. - - Args: - participant_factories: Mapping of factory names to callables that return SupportsAgentRun - instances. Each produced participant must have a unique identifier - (`.name` is preferred if set, otherwise `.id` is used). - - Returns: - Self for method chaining. - - Raises: - ValueError: If participant_factories is empty or `.participants(...)` or `.register_participants(...)` - has already been called. - - Example: - .. code-block:: python - - from agent_framework import ChatAgent - from agent_framework_orchestrations import HandoffBuilder - - - def create_triage() -> ChatAgent: - return ... - - - def create_refund_agent() -> ChatAgent: - return ... - - - def create_billing_agent() -> ChatAgent: - return ... - - - factories = { - "triage": create_triage, - "refund": create_refund_agent, - "billing": create_billing_agent, - } - - # Handoff will be created automatically unless specified otherwise - # The default creates a mesh topology where all agents can handoff to all others - builder = HandoffBuilder().register_participants(factories) - builder.with_start_agent("triage") - """ - if self._participants: - raise ValueError("Cannot mix .participants() and .register_participants() in the same builder instance.") - - if self._participant_factories: - raise ValueError("register_participants() has already been called on this builder instance.") - if not participant_factories: - raise ValueError("participant_factories cannot be empty") - - self._participant_factories = dict(participant_factories) - return self + self._termination_condition: Callable[[list[Message]], bool | Awaitable[bool]] | None = termination_condition def participants(self, participants: Sequence[SupportsAgentRun]) -> "HandoffBuilder": """Register the agents that will participate in the handoff workflow. @@ -708,8 +633,8 @@ class HandoffBuilder: Self for method chaining. Raises: - ValueError: If participants is empty, contains duplicates, or `.participants()` or - `.register_participants()` has already been called. + ValueError: If participants is empty, contains duplicates, or `.participants()` + has already been called. TypeError: If participants are not SupportsAgentRun instances. Example: @@ -727,9 +652,6 @@ class HandoffBuilder: builder = HandoffBuilder().participants([triage, refund, billing]) builder.with_start_agent(triage) """ - if self._participant_factories: - raise ValueError("Cannot mix .participants() and .register_participants() in the same builder instance.") - if self._participants: raise ValueError("participants have already been assigned") @@ -755,8 +677,8 @@ class HandoffBuilder: def add_handoff( self, - source: str | SupportsAgentRun, - targets: Sequence[str] | Sequence[SupportsAgentRun], + source: SupportsAgentRun, + targets: Sequence[SupportsAgentRun], *, description: str | None = None, ) -> "HandoffBuilder": @@ -768,16 +690,8 @@ class HandoffBuilder: to all others by default (mesh topology). Args: - source: The agent that can initiate the handoff. Can be: - - Factory name (str): If using participant factories - - SupportsAgentRun instance: The actual agent object - - Cannot mix factory names and instances across source and targets - targets: One or more target agents that the source can hand off to. Can be: - - Factory name (str): If using participant factories - - SupportsAgentRun instance: The actual agent object - - Single target: ["billing_agent"] or [agent_instance] - - Multiple targets: ["billing_agent", "support_agent"] or [agent1, agent2] - - Cannot mix factory names and instances across source and targets + source: The agent that can initiate the handoff. + targets: One or more target agents that the source can hand off to. description: Optional custom description for the handoff. If not provided, the description of the target agent(s) will be used. If the target agent has no description, no description will be set for the handoff tool, which is not recommended. @@ -789,25 +703,10 @@ class HandoffBuilder: Self for method chaining. Raises: - ValueError: 1) If source or targets are not in the participants list, or if - participants(...) hasn't been called yet. - 2) If source or targets are factory names (str) but participant_factories(...) - hasn't been called yet, or if they are not in the participant_factories list. - TypeError: If mixing factory names (str) and SupportsAgentRun/Executor instances + ValueError: If source or targets are not in the participants list, or if + participants(...) hasn't been called yet. Examples: - Single target (using factory name): - - .. code-block:: python - - builder.add_handoff("triage_agent", "billing_agent") - - Multiple targets (using factory names): - - .. code-block:: python - - builder.add_handoff("triage_agent", ["billing_agent", "support_agent", "escalation_agent"]) - Multiple targets (using agent instances): .. code-block:: python @@ -830,96 +729,54 @@ class HandoffBuilder: - Handoff tools are automatically registered for each source agent - If a source agent is configured multiple times via add_handoff, targets are merged """ - if isinstance(source, str) and all(isinstance(t, str) for t in targets): - # Both source and targets are factory names - if not self._participant_factories: - raise ValueError("Call participant_factories(...) before add_handoff(...)") + if not self._participants: + raise ValueError("Call participants(...) before add_handoff(...)") - if source not in self._participant_factories: - raise ValueError(f"Source factory name '{source}' is not in the participant_factories list") + # Resolve source agent ID + source_id = self._resolve_to_id(source) + if source_id not in self._participants: + raise ValueError(f"Source agent '{source}' is not in the participants list") - for target in targets: - if target not in self._participant_factories: - raise ValueError(f"Target factory name '{target}' is not in the participant_factories list") + # Resolve all target IDs + target_ids: list[str] = [] + for target in targets: + target_id = self._resolve_to_id(target) + if target_id not in self._participants: + raise ValueError(f"Target agent '{target}' is not in the participants list") + target_ids.append(target_id) - # Merge with existing handoff configuration for this source - if source in self._handoff_config: - # Add new targets to existing list, avoiding duplicates - for t in targets: - if t in self._handoff_config[source]: - logger.warning(f"Handoff from '{source}' to '{t}' is already configured; overwriting.") - self._handoff_config[source].add(HandoffConfiguration(target=t, description=description)) - else: - self._handoff_config[source] = set() - for t in targets: - self._handoff_config[source].add(HandoffConfiguration(target=t, description=description)) - return self + # Merge with existing handoff configuration for this source + if source_id not in self._handoff_config: + self._handoff_config[source_id] = set() - if isinstance(source, (SupportsAgentRun)) and all(isinstance(t, SupportsAgentRun) for t in targets): - # Both source and targets are instances - if not self._participants: - raise ValueError("Call participants(...) before add_handoff(...)") + for t in target_ids: + config = HandoffConfiguration(target=t, description=description) + if config in self._handoff_config[source_id]: + logger.warning(f"Handoff from '{source_id}' to '{t}' is already configured; overwriting.") + # Remove old config so the new one (with updated description) takes effect + self._handoff_config[source_id].discard(config) + self._handoff_config[source_id].add(config) - # Resolve source agent ID - source_id = self._resolve_to_id(source) - if source_id not in self._participants: - raise ValueError(f"Source agent '{source}' is not in the participants list") + return self - # Resolve all target IDs - target_ids: list[str] = [] - for target in targets: - target_id = self._resolve_to_id(target) - if target_id not in self._participants: - raise ValueError(f"Target agent '{target}' is not in the participants list") - target_ids.append(target_id) - - # Merge with existing handoff configuration for this source - if source_id in self._handoff_config: - # Add new targets to existing list, avoiding duplicates - for t in target_ids: - if t in self._handoff_config[source_id]: - logger.warning(f"Handoff from '{source_id}' to '{t}' is already configured; overwriting.") - self._handoff_config[source_id].add(HandoffConfiguration(target=t, description=description)) - else: - self._handoff_config[source_id] = set() - for t in target_ids: - self._handoff_config[source_id].add(HandoffConfiguration(target=t, description=description)) - - return self - - raise TypeError( - "Cannot mix factory names (str) and SupportsAgentRun instances across source and targets in add_handoff()" - ) - - def with_start_agent(self, agent: str | SupportsAgentRun) -> "HandoffBuilder": + def with_start_agent(self, agent: SupportsAgentRun) -> "HandoffBuilder": """Set the agent that will initiate the handoff workflow. If not specified, the first registered participant will be used as the starting agent. Args: - agent: The agent that will start the workflow. Can be: - - Factory name (str): If using participant factories - - SupportsAgentRun instance: The actual agent object + agent: The agent that will start the workflow. + Returns: Self for method chaining. """ - if isinstance(agent, str): - if self._participant_factories: - if agent not in self._participant_factories: - raise ValueError(f"Start agent factory name '{agent}' is not in the participant_factories list") - else: - raise ValueError("Call register_participants(...) before with_start_agent(...)") - self._start_id = agent - elif isinstance(agent, SupportsAgentRun): - resolved_id = self._resolve_to_id(agent) - if self._participants: - if resolved_id not in self._participants: - raise ValueError(f"Start agent '{resolved_id}' is not in the participants list") - else: - raise ValueError("Call participants(...) before with_start_agent(...)") - self._start_id = resolved_id + resolved_id = self._resolve_to_id(agent) + if self._participants: + if resolved_id not in self._participants: + raise ValueError(f"Start agent '{resolved_id}' is not in the participants list") else: - raise TypeError("Start agent must be a factory name (str) or an SupportsAgentRun instance") + raise ValueError("Call participants(...) before with_start_agent(...)") + self._start_id = resolved_id return self @@ -1026,7 +883,7 @@ class HandoffBuilder: # Asynchronous condition - async def check_termination(conv: list[ChatMessage]) -> bool: + async def check_termination(conv: list[Message]) -> bool: # Can perform async operations return len(conv) > 20 @@ -1090,48 +947,21 @@ class HandoffBuilder: # region Internal Helper Methods def _resolve_agents(self) -> dict[str, SupportsAgentRun]: - """Resolve participant factories into agent instances. - - If agent instances were provided directly via participants(...), those are - returned as-is. If participant factories were provided via participant_factories(...), - those are invoked to create the agent instances. + """Resolve participant instances into agent instances. Returns: - Map of executor IDs or factory names to `SupportsAgentRun` instances + Map of executor IDs to `SupportsAgentRun` instances """ - if not self._participants and not self._participant_factories: - raise ValueError("No participants provided. Call .participants() or .register_participants() first.") - # We don't need to check if both are set since that is handled in the respective methods + if not self._participants: + raise ValueError("No participants provided. Call .participants() first.") - if self._participants: - return self._participants + return self._participants - if self._participant_factories: - # Invoke each factory to create participant instances - factory_names_to_agents: dict[str, SupportsAgentRun] = {} - for factory_name, factory in self._participant_factories.items(): - instance = factory() - if isinstance(instance, SupportsAgentRun): - resolved_id = self._resolve_to_id(instance) - else: - raise TypeError(f"Participants must be SupportsAgentRun instances. Got {type(instance).__name__}.") - - if resolved_id in factory_names_to_agents: - raise ValueError(f"Duplicate participant name '{resolved_id}' detected") - - # Map executors by factory name (not executor.id) because handoff configs reference factory names - # This allows users to configure handoffs using the factory names they provided - factory_names_to_agents[factory_name] = instance - - return factory_names_to_agents - - raise ValueError("No executors or participant_factories have been configured") - - def _resolve_handoffs(self, agents: Mapping[str, SupportsAgentRun]) -> dict[str, list[HandoffConfiguration]]: - """Handoffs may be specified using factory names or instances; resolve to executor IDs. + def _resolve_handoffs(self, agents: dict[str, SupportsAgentRun]) -> dict[str, list[HandoffConfiguration]]: + """Resolve handoff configurations to executor IDs. Args: - agents: Map of agent IDs or factory names to `SupportsAgentRun` instances + agents: Map of agent IDs to `SupportsAgentRun` instances Returns: Map of executor IDs to list of HandoffConfiguration instances @@ -1145,14 +975,14 @@ class HandoffBuilder: if not source_agent: raise ValueError( f"Handoff source agent '{source_id}' not found. " - "Please make sure source has been added as either a participant or participant_factory." + "Please make sure source has been added as a participant." ) for handoff_config in handoff_configurations: target_agent = agents.get(handoff_config.target_id) if not target_agent: raise ValueError( f"Handoff target agent '{handoff_config.target_id}' not found for source '{source_id}'. " - "Please make sure target has been added as either a participant or participant_factory." + "Please make sure target has been added as a participant." ) updated_handoff_configurations.setdefault(self._resolve_to_id(source_agent), []).append( @@ -1184,7 +1014,7 @@ class HandoffBuilder: """Resolve agents into HandoffAgentExecutors. Args: - agents: Map of agent IDs or factory names to `SupportsAgentRun` instances + agents: Map of agent IDs to `SupportsAgentRun` instances handoffs: Map of executor IDs to list of HandoffConfiguration instances Returns: diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py index 779dad2d5a..17b927326b 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py @@ -14,7 +14,7 @@ from typing import Any, ClassVar, TypeVar, cast from agent_framework import ( AgentResponse, - ChatMessage, + Message, SupportsAgentRun, ) from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse @@ -56,7 +56,7 @@ ORCH_MSG_KIND_INSTRUCTION = "instruction" ORCH_MSG_KIND_NOTICE = "notice" -def _message_to_payload(message: ChatMessage) -> Any: +def _message_to_payload(message: Message) -> Any: if hasattr(message, "to_dict") and callable(getattr(message, "to_dict", None)): with contextlib.suppress(Exception): return message.to_dict() # type: ignore[attr-defined] @@ -72,24 +72,24 @@ def _message_to_payload(message: ChatMessage) -> Any: return message -def _message_from_payload(payload: Any) -> ChatMessage: - if isinstance(payload, ChatMessage): +def _message_from_payload(payload: Any) -> Message: + if isinstance(payload, Message): return payload - if hasattr(ChatMessage, "from_dict") and isinstance(payload, dict): + if hasattr(Message, "from_dict") and isinstance(payload, dict): with contextlib.suppress(Exception): - return ChatMessage.from_dict(payload) # type: ignore[attr-defined,no-any-return] - if hasattr(ChatMessage, "from_json") and isinstance(payload, str): + return Message.from_dict(payload) # type: ignore[attr-defined,no-any-return] + if hasattr(Message, "from_json") and isinstance(payload, str): with contextlib.suppress(Exception): - return ChatMessage.from_json(payload) # type: ignore[attr-defined,no-any-return] + return Message.from_json(payload) # type: ignore[attr-defined,no-any-return] if isinstance(payload, dict): with contextlib.suppress(Exception): - return ChatMessage(**payload) # type: ignore[arg-type] + return Message(**payload) # type: ignore[arg-type] if isinstance(payload, str): with contextlib.suppress(Exception): decoded = json.loads(payload) if isinstance(decoded, dict): return _message_from_payload(decoded) - raise TypeError("Unable to reconstruct ChatMessage from payload") + raise TypeError("Unable to reconstruct Message from payload") # region Magentic One Prompts @@ -247,7 +247,7 @@ The answer should be phrased as if you were speaking to the user. # region Messages and Types -def _new_chat_history() -> list[ChatMessage]: +def _new_chat_history() -> list[Message]: """Typed default factory for chat history list to satisfy type checkers.""" return [] @@ -261,8 +261,8 @@ def _new_participant_descriptions() -> dict[str, str]: class _MagenticTaskLedger(DictConvertible): """Internal: Task ledger for the Standard Magentic manager.""" - facts: ChatMessage - plan: ChatMessage + facts: Message + plan: Message def to_dict(self) -> dict[str, Any]: return {"facts": _message_to_payload(self.facts), "plan": _message_to_payload(self.plan)} @@ -328,7 +328,7 @@ class MagenticContext(DictConvertible): """Context for the Magentic manager.""" task: str - chat_history: list[ChatMessage] = field(default_factory=_new_chat_history) + chat_history: list[Message] = field(default_factory=_new_chat_history) participant_descriptions: dict[str, str] = field(default_factory=_new_participant_descriptions) round_count: int = 0 stall_count: int = 0 @@ -353,7 +353,7 @@ class MagenticContext(DictConvertible): raise ValueError("MagenticContext requires a 'task' string field.") # `chat_history` is required chat_history_payload = data.get("chat_history", []) - history: list[ChatMessage] = [] + history: list[Message] = [] for item in chat_history_payload: history.append(_message_from_payload(item)) # `participant_descriptions` is required @@ -396,7 +396,7 @@ def _team_block(participants: dict[str, str]) -> str: def _extract_json(text: str) -> dict[str, Any]: """Potentially temp helper method. - Note: this method is required right now because the ChatClientProtocol, when calling + Note: this method is required right now because the SupportsChatGetResponse, when calling response.text, returns duplicate JSON payloads - need to figure out why. The `text` method is concatenating multiple text contents from diff msgs into a single string. @@ -472,12 +472,12 @@ class MagenticManagerBase(ABC): self.task_ledger_full_prompt: str = ORCHESTRATOR_TASK_LEDGER_FULL_PROMPT @abstractmethod - async def plan(self, magentic_context: MagenticContext) -> ChatMessage: + async def plan(self, magentic_context: MagenticContext) -> Message: """Create a plan for the task.""" ... @abstractmethod - async def replan(self, magentic_context: MagenticContext) -> ChatMessage: + async def replan(self, magentic_context: MagenticContext) -> Message: """Replan for the task.""" ... @@ -487,7 +487,7 @@ class MagenticManagerBase(ABC): ... @abstractmethod - async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage: + async def prepare_final_answer(self, magentic_context: MagenticContext) -> Message: """Prepare the final answer.""" ... @@ -501,7 +501,7 @@ class MagenticManagerBase(ABC): class StandardMagenticManager(MagenticManagerBase): - """Standard Magentic manager that performs real LLM calls via a ChatAgent. + """Standard Magentic manager that performs real LLM calls via a Agent. The manager constructs prompts that mirror the original Magentic One orchestration: - Facts gathering @@ -580,8 +580,8 @@ class StandardMagenticManager(MagenticManagerBase): async def _complete( self, - messages: list[ChatMessage], - ) -> ChatMessage: + messages: list[Message], + ) -> Message: """Call the underlying agent and return the last assistant message. The agent's run method is called which applies the agent's configured options @@ -595,19 +595,19 @@ class StandardMagenticManager(MagenticManagerBase): return response.messages[-1] - async def plan(self, magentic_context: MagenticContext) -> ChatMessage: + async def plan(self, magentic_context: MagenticContext) -> Message: """Create facts and plan using the model, then render a combined task ledger as a single assistant message.""" team_text = _team_block(magentic_context.participant_descriptions) # Gather facts - facts_user = ChatMessage( + facts_user = Message( role="user", text=self.task_ledger_facts_prompt.format(task=magentic_context.task), ) facts_msg = await self._complete([*magentic_context.chat_history, facts_user]) # Create plan - plan_user = ChatMessage( + plan_user = Message( role="user", text=self.task_ledger_plan_prompt.format(team=team_text), ) @@ -626,9 +626,9 @@ class StandardMagenticManager(MagenticManagerBase): facts=facts_msg.text, plan=plan_msg.text, ) - return ChatMessage(role="assistant", text=combined, author_name=MAGENTIC_MANAGER_NAME) + return Message(role="assistant", text=combined, author_name=MAGENTIC_MANAGER_NAME) - async def replan(self, magentic_context: MagenticContext) -> ChatMessage: + async def replan(self, magentic_context: MagenticContext) -> Message: """Update facts and plan when stalling or looping has been detected.""" if self.task_ledger is None: raise RuntimeError("replan() called before plan(); call plan() once before requesting a replan.") @@ -636,7 +636,7 @@ class StandardMagenticManager(MagenticManagerBase): team_text = _team_block(magentic_context.participant_descriptions) # Update facts - facts_update_user = ChatMessage( + facts_update_user = Message( role="user", text=self.task_ledger_facts_update_prompt.format( task=magentic_context.task, old_facts=self.task_ledger.facts.text @@ -645,7 +645,7 @@ class StandardMagenticManager(MagenticManagerBase): updated_facts = await self._complete([*magentic_context.chat_history, facts_update_user]) # Update plan - plan_update_user = ChatMessage( + plan_update_user = Message( role="user", text=self.task_ledger_plan_update_prompt.format(team=team_text), ) @@ -669,7 +669,7 @@ class StandardMagenticManager(MagenticManagerBase): facts=updated_facts.text, plan=updated_plan.text, ) - return ChatMessage(role="assistant", text=combined, author_name=MAGENTIC_MANAGER_NAME) + return Message(role="assistant", text=combined, author_name=MAGENTIC_MANAGER_NAME) async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: """Use the model to produce a JSON progress ledger based on the conversation so far. @@ -689,7 +689,7 @@ class StandardMagenticManager(MagenticManagerBase): team=team_text, names=names_csv, ) - user_message = ChatMessage(role="user", text=prompt) + user_message = Message(role="user", text=prompt) # Include full context to help the model decide current stage, with small retry loop attempts = 0 @@ -713,13 +713,13 @@ class StandardMagenticManager(MagenticManagerBase): f"Progress ledger parse failed after {self.progress_ledger_retry_count} attempt(s): {last_error}" ) - async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage: + async def prepare_final_answer(self, magentic_context: MagenticContext) -> Message: """Ask the model to produce the final answer addressed to the user.""" prompt = self.final_answer_prompt.format(task=magentic_context.task) - user_message = ChatMessage(role="user", text=prompt) + user_message = Message(role="user", text=prompt) response = await self._complete([*magentic_context.chat_history, user_message]) # Ensure role is assistant - return ChatMessage( + return Message( role="assistant", text=response.text, author_name=response.author_name or MAGENTIC_MANAGER_NAME, @@ -771,7 +771,7 @@ class MagenticOrchestratorEvent: """Data payload for magentic_orchestrator events.""" event_type: MagenticOrchestratorEventType - content: ChatMessage | MagenticProgressLedger + content: Message | MagenticProgressLedger # region Request info related types @@ -786,7 +786,7 @@ class MagenticPlanReviewResponse: the plan is considered approved. """ - review: list[ChatMessage] + review: list[Message] @staticmethod def approve() -> "MagenticPlanReviewResponse": @@ -794,14 +794,14 @@ class MagenticPlanReviewResponse: return MagenticPlanReviewResponse(review=[]) @staticmethod - def revise(feedback: str | list[str] | ChatMessage | list[ChatMessage]) -> "MagenticPlanReviewResponse": + def revise(feedback: str | list[str] | Message | list[Message]) -> "MagenticPlanReviewResponse": """Create a revision response with feedback.""" if isinstance(feedback, str): - feedback = [ChatMessage(role="user", text=feedback)] - elif isinstance(feedback, ChatMessage): + feedback = [Message(role="user", text=feedback)] + elif isinstance(feedback, Message): feedback = [feedback] elif isinstance(feedback, list): - feedback = [ChatMessage(role="user", text=item) if isinstance(item, str) else item for item in feedback] + feedback = [Message(role="user", text=item) if isinstance(item, str) else item for item in feedback] return MagenticPlanReviewResponse(review=feedback) @@ -820,7 +820,7 @@ class MagenticPlanReviewRequest: is_stalled: Whether the workflow is currently stalled. """ - plan: ChatMessage + plan: Message current_progress: MagenticProgressLedger | None is_stalled: bool @@ -828,7 +828,7 @@ class MagenticPlanReviewRequest: """Create an approval response.""" return MagenticPlanReviewResponse.approve() - def revise(self, feedback: str | list[str] | ChatMessage | list[ChatMessage]) -> MagenticPlanReviewResponse: + def revise(self, feedback: str | list[str] | Message | list[Message]) -> MagenticPlanReviewResponse: """Create a revision response with feedback.""" return MagenticPlanReviewResponse.revise(feedback) @@ -877,7 +877,7 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): # Task related state self._magentic_context: MagenticContext | None = None - self._task_ledger: ChatMessage | None = None + self._task_ledger: Message | None = None self._progress_ledger: MagenticProgressLedger | None = None # Termination related state @@ -887,8 +887,8 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): @override async def _handle_messages( self, - messages: list[ChatMessage], - ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]], + messages: list[Message], + ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]], ) -> None: """Handle the initial task messages to start the workflow.""" if self._terminated: @@ -942,7 +942,7 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): async def _handle_response( self, response: AgentExecutorResponse | GroupChatResponseMessage, - ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]], + ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]], ) -> None: """Handle a response message from a participant.""" if self._magentic_context is None or self._task_ledger is None: @@ -968,7 +968,7 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): self, original_request: MagenticPlanReviewRequest, response: MagenticPlanReviewResponse, - ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]], + ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]], ) -> None: """Handle the human response to the plan review request. @@ -1029,7 +1029,7 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): async def _run_inner_loop( self, - ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]], + ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]], ) -> None: """Run the inner orchestration loop. Coordination phase. Serialized with a lock.""" if self._magentic_context is None or self._task_ledger is None: @@ -1039,16 +1039,14 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): async def _run_inner_loop_helper( self, - ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]], + ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]], ) -> None: """Run inner loop with exclusive access.""" # Narrow optional context for the remainder of this method if self._magentic_context is None: raise RuntimeError("Context not initialized") # Check limits first - within_limits = await self._check_within_limits_or_complete( - cast(WorkflowContext[Never, list[ChatMessage]], ctx) - ) + within_limits = await self._check_within_limits_or_complete(cast(WorkflowContext[Never, list[Message]], ctx)) if not within_limits: return @@ -1083,7 +1081,7 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): # Check for task completion if self._progress_ledger.is_request_satisfied.answer: logger.info("Magentic Orchestrator: Task completed") - await self._prepare_final_answer(cast(WorkflowContext[Never, list[ChatMessage]], ctx)) + await self._prepare_final_answer(cast(WorkflowContext[Never, list[Message]], ctx)) return # Check for stalling or looping @@ -1107,11 +1105,11 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): if next_speaker not in self._participant_registry.participants: logger.warning(f"Invalid next speaker: {next_speaker}") - await self._prepare_final_answer(cast(WorkflowContext[Never, list[ChatMessage]], ctx)) + await self._prepare_final_answer(cast(WorkflowContext[Never, list[Message]], ctx)) return # Add instruction to conversation (assistant guidance) - instruction_msg = ChatMessage( + instruction_msg = Message( role="assistant", text=str(instruction), author_name=MAGENTIC_MANAGER_NAME, @@ -1128,7 +1126,7 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): async def _reset_and_replan( self, - ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]], + ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]], ) -> None: """Reset context and replan.""" if self._magentic_context is None: @@ -1166,7 +1164,7 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): async def _run_outer_loop( self, - ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]], + ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]], ) -> None: """Run the outer orchestration loop - planning phase.""" if self._magentic_context is None: @@ -1183,7 +1181,7 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): # Start inner loop await self._run_inner_loop(ctx) - async def _prepare_final_answer(self, ctx: WorkflowContext[Never, list[ChatMessage]]) -> None: + async def _prepare_final_answer(self, ctx: WorkflowContext[Never, list[Message]]) -> None: """Prepare the final answer using the manager.""" if self._magentic_context is None: raise RuntimeError("Context not initialized") @@ -1196,7 +1194,7 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): self._terminated = True - async def _check_within_limits_or_complete(self, ctx: WorkflowContext[Never, list[ChatMessage]]) -> bool: + async def _check_within_limits_or_complete(self, ctx: WorkflowContext[Never, list[Message]]) -> bool: """Check if orchestrator is within operational limits. If limits are exceeded, yield a termination message and mark the workflow as terminated. @@ -1223,7 +1221,7 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): # Yield the full conversation with an indication of termination due to limits await ctx.yield_output([ *self._magentic_context.chat_history, - ChatMessage( + Message( role="assistant", text=f"Workflow terminated due to reaching maximum {limit_type} count.", author_name=MAGENTIC_MANAGER_NAME, @@ -1340,8 +1338,8 @@ class MagenticAgentExecutor(AgentExecutor): # Request into related self._pending_agent_requests.clear() self._pending_responses_to_agent.clear() - # Reset threads - self._agent_thread = self._agent.get_new_thread() + # Reset sessions + self._agent_thread = self._agent.create_session() # endregion Magentic Agent Executor @@ -1374,8 +1372,7 @@ class MagenticBuilder: def __init__( self, *, - participants: Sequence[SupportsAgentRun | Executor] | None = None, - participant_factories: Sequence[Callable[[], SupportsAgentRun | Executor]] | None = None, + participants: Sequence[SupportsAgentRun | Executor], # Manager config (exactly one required) manager: MagenticManagerBase | None = None, manager_factory: Callable[[], MagenticManagerBase] | None = None, @@ -1401,8 +1398,7 @@ class MagenticBuilder: """Initialize the Magentic workflow builder. Args: - participants: Optional sequence of agent or executor instances for the workflow. - participant_factories: Optional sequence of callables returning agent or executor instances. + participants: Sequence of agent or executor instances for the workflow. manager: Pre-configured manager instance (subclass of MagenticManagerBase). manager_factory: Callable that returns a new MagenticManagerBase instance. manager_agent: Agent instance for creating a StandardMagenticManager. @@ -1423,7 +1419,6 @@ class MagenticBuilder: intermediate_outputs: If True, enables intermediate outputs from agent participants. """ self._participants: dict[str, SupportsAgentRun | Executor] = {} - self._participant_factories: list[Callable[[], SupportsAgentRun | Executor]] = [] # Manager related members self._manager: MagenticManagerBase | None = None @@ -1437,13 +1432,7 @@ class MagenticBuilder: # Intermediate outputs self._intermediate_outputs = intermediate_outputs - if participants is None and participant_factories is None: - raise ValueError("Either participants or participant_factories must be provided.") - - if participant_factories is not None: - self._set_participant_factories(participant_factories) - if participants is not None: - self._set_participants(participants) + self._set_participants(participants) # Set manager if provided if any(x is not None for x in [manager, manager_factory, manager_agent, manager_agent_factory]): @@ -1465,27 +1454,8 @@ class MagenticBuilder: max_round_count=max_round_count, ) - def _set_participant_factories( - self, - participant_factories: Sequence[Callable[[], SupportsAgentRun | Executor]], - ) -> None: - """Set participant factories (internal).""" - if self._participants: - raise ValueError("Cannot provide both participants and participant_factories.") - - if self._participant_factories: - raise ValueError("participant_factories already set.") - - if not participant_factories: - raise ValueError("participant_factories cannot be empty") - - self._participant_factories = list(participant_factories) - def _set_participants(self, participants: Sequence[SupportsAgentRun | Executor]) -> None: """Set participants (internal).""" - if self._participant_factories: - raise ValueError("Cannot provide both participants and participant_factories.") - if self._participants: raise ValueError("participants already set.") @@ -1750,17 +1720,10 @@ class MagenticBuilder: def _resolve_participants(self) -> list[Executor]: """Resolve participant instances into Executor objects.""" - if not self._participants and not self._participant_factories: - raise ValueError("No participants provided. Pass participants or participant_factories to the constructor.") - # We don't need to check if both are set since that is handled in the respective methods + if not self._participants: + raise ValueError("No participants provided. Pass participants to the constructor.") - participants: list[Executor | SupportsAgentRun] = [] - if self._participant_factories: - for factory in self._participant_factories: - participant = factory() - participants.append(participant) - else: - participants = list(self._participants.values()) + participants: list[Executor | SupportsAgentRun] = list(self._participants.values()) executors: list[Executor] = [] for participant in participants: diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py index 51f4e27898..5e4a5d6a28 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from agent_framework._agents import SupportsAgentRun -from agent_framework._types import ChatMessage +from agent_framework._types import Message from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse from agent_framework._workflows._agent_utils import resolve_agent_id from agent_framework._workflows._executor import Executor, handler @@ -44,18 +44,18 @@ class AgentRequestInfoResponse: """Response containing additional information requested from users for agents. Attributes: - messages: list[ChatMessage]: Additional messages provided by users. If empty, + messages: list[Message]: Additional messages provided by users. If empty, the agent response is approved as-is. """ - messages: list[ChatMessage] + messages: list[Message] @staticmethod - def from_messages(messages: list[ChatMessage]) -> "AgentRequestInfoResponse": + def from_messages(messages: list[Message]) -> "AgentRequestInfoResponse": """Create an AgentRequestInfoResponse from a list of ChatMessages. Args: - messages: List of ChatMessage instances provided by users. + messages: List of Message instances provided by users. Returns: AgentRequestInfoResponse instance. @@ -72,7 +72,7 @@ class AgentRequestInfoResponse: Returns: AgentRequestInfoResponse instance. """ - return AgentRequestInfoResponse(messages=[ChatMessage(role="user", text=text) for text in texts]) + return AgentRequestInfoResponse(messages=[Message(role="user", text=text) for text in texts]) @staticmethod def approve() -> "AgentRequestInfoResponse": diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_state.py b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_state.py index fe8ba64126..e8f8a81080 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_state.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_state.py @@ -11,11 +11,11 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Any -from agent_framework._types import ChatMessage +from agent_framework._types import Message -def _new_chat_message_list() -> list[ChatMessage]: - """Factory function for typed empty ChatMessage list. +def _new_chat_message_list() -> list[Message]: + """Factory function for typed empty Message list. Satisfies the type checker. """ @@ -47,11 +47,11 @@ class OrchestrationState: task: Optional primary task/question being orchestrated """ - conversation: list[ChatMessage] = field(default_factory=_new_chat_message_list) + conversation: list[Message] = field(default_factory=_new_chat_message_list) round_index: int = 0 orchestrator_name: str = "" metadata: dict[str, Any] = field(default_factory=_new_metadata_dict) - task: ChatMessage | None = None + task: Message | None = None def to_dict(self) -> dict[str, Any]: """Serialize to dict for checkpointing. @@ -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/agent_framework_orchestrations/_orchestrator_helpers.py b/python/packages/orchestrations/agent_framework_orchestrations/_orchestrator_helpers.py index c48af3c6de..757e77f095 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_orchestrator_helpers.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_orchestrator_helpers.py @@ -8,12 +8,12 @@ No inheritance required - just import and call. import logging -from agent_framework._types import ChatMessage +from agent_framework._types import Message logger = logging.getLogger(__name__) -def clean_conversation_for_handoff(conversation: list[ChatMessage]) -> list[ChatMessage]: +def clean_conversation_for_handoff(conversation: list[Message]) -> list[Message]: """Remove tool-related content from conversation for clean handoffs. During handoffs, tool calls can cause API errors because: @@ -37,7 +37,7 @@ def clean_conversation_for_handoff(conversation: list[ChatMessage]) -> list[Chat Returns: Cleaned conversation safe for handoff routing """ - cleaned: list[ChatMessage] = [] + cleaned: list[Message] = [] for msg in conversation: # Skip tool response messages entirely if msg.role == "tool": @@ -58,7 +58,7 @@ def clean_conversation_for_handoff(conversation: list[ChatMessage]) -> list[Chat # Has tool content - only keep if it also has text if msg.text and msg.text.strip(): # Create fresh text-only message while preserving additional_properties - msg_copy = ChatMessage( + msg_copy = Message( role=msg.role, text=msg.text, author_name=msg.author_name, @@ -74,7 +74,7 @@ def create_completion_message( text: str | None = None, author_name: str, reason: str = "completed", -) -> ChatMessage: +) -> Message: """Create a standardized completion message. Simple helper to avoid duplicating completion message creation. @@ -85,10 +85,10 @@ def create_completion_message( reason: Reason for completion (for default text generation) Returns: - ChatMessage with assistant role + Message with assistant role """ message_text = text or f"Conversation {reason}." - return ChatMessage( + return Message( role="assistant", text=message_text, author_name=author_name, diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py index 3ddecd56dc..5ef4f7fe8c 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py @@ -4,9 +4,8 @@ This module provides a high-level, agent-focused API to assemble a sequential workflow where: -- Participants can be provided as SupportsAgentRun or Executor instances via `participants=[...]`, - or as factories returning SupportsAgentRun or Executor via `participant_factories=[...]` -- A shared conversation context (list[ChatMessage]) is passed along the chain +- Participants are provided as SupportsAgentRun or Executor instances via `participants=[...]` +- A shared conversation context (list[Message]) is passed along the chain - Agents append their assistant messages to the context - Custom executors can transform or summarize and return a refined context - The workflow finishes with the final context produced by the last participant @@ -17,16 +16,16 @@ Typical wiring: Notes: - Participants can mix SupportsAgentRun and Executor objects - Agents are auto-wrapped by WorkflowBuilder as AgentExecutor (unless already wrapped) -- AgentExecutor produces AgentExecutorResponse; _ResponseToConversation converts this to list[ChatMessage] -- Non-agent executors must define a handler that consumes `list[ChatMessage]` and sends back - the updated `list[ChatMessage]` via their workflow context +- AgentExecutor produces AgentExecutorResponse; _ResponseToConversation converts this to list[Message] +- Non-agent executors must define a handler that consumes `list[Message]` and sends back + the updated `list[Message]` via their workflow context Why include the small internal adapter executors? - Input normalization ("input-conversation"): ensures the workflow always starts with a - `list[ChatMessage]` regardless of whether callers pass a `str`, a single `ChatMessage`, + `list[Message]` regardless of whether callers pass a `str`, a single `Message`, or a list. This keeps the first hop strongly typed and avoids boilerplate in participants. - Agent response adaptation ("to-conversation:"): agents (via AgentExecutor) - emit `AgentExecutorResponse`. The adapter converts that to a `list[ChatMessage]` + emit `AgentExecutorResponse`. The adapter converts that to a `list[Message]` using `full_conversation` so original prompts aren't lost when chaining. - Result output ("end"): yields the final conversation list and the workflow becomes idle giving a consistent terminal payload shape for both agents and custom executors. @@ -38,10 +37,10 @@ confusion and to mirror how the concurrent builder uses explicit dispatcher/aggr """ # noqa: E501 import logging -from collections.abc import Callable, Sequence +from collections.abc import Sequence from typing import Any -from agent_framework import ChatMessage, SupportsAgentRun +from agent_framework import Message, SupportsAgentRun from agent_framework._workflows._agent_executor import ( AgentExecutor, AgentExecutorResponse, @@ -63,18 +62,18 @@ logger = logging.getLogger(__name__) class _InputToConversation(Executor): - """Normalizes initial input into a list[ChatMessage] conversation.""" + """Normalizes initial input into a list[Message] conversation.""" @handler - async def from_str(self, prompt: str, ctx: WorkflowContext[list[ChatMessage]]) -> None: + async def from_str(self, prompt: str, ctx: WorkflowContext[list[Message]]) -> None: await ctx.send_message(normalize_messages_input(prompt)) @handler - async def from_message(self, message: ChatMessage, ctx: WorkflowContext[list[ChatMessage]]) -> None: + async def from_message(self, message: Message, ctx: WorkflowContext[list[Message]]) -> None: await ctx.send_message(normalize_messages_input(message)) @handler - async def from_messages(self, messages: list[str | ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None: + async def from_messages(self, messages: list[str | Message], ctx: WorkflowContext[list[Message]]) -> None: await ctx.send_message(normalize_messages_input(messages)) @@ -84,10 +83,10 @@ class _EndWithConversation(Executor): @handler async def end_with_messages( self, - conversation: list[ChatMessage], - ctx: WorkflowContext[Any, list[ChatMessage]], + conversation: list[Message], + ctx: WorkflowContext[Any, list[Message]], ) -> None: - """Handler for ending with a list of ChatMessage. + """Handler for ending with a list of Message. This is used when the last participant is a custom executor. """ @@ -97,7 +96,7 @@ class _EndWithConversation(Executor): async def end_with_agent_executor_response( self, response: AgentExecutorResponse, - ctx: WorkflowContext[Any, list[ChatMessage] | None], + ctx: WorkflowContext[Any, list[Message] | None], ) -> None: """Handle case where last participant is an agent. @@ -110,12 +109,10 @@ class SequentialBuilder: r"""High-level builder for sequential agent/executor workflows with shared context. - `participants=[...]` accepts a list of SupportsAgentRun (recommended) or Executor instances - - `participant_factories=[...]` accepts a list of factories for SupportsAgentRun (recommended) - or Executor factories - - Executors must define a handler that consumes list[ChatMessage] and sends out a list[ChatMessage] - - The workflow wires participants in order, passing a list[ChatMessage] down the chain + - Executors must define a handler that consumes list[Message] and sends out a list[Message] + - The workflow wires participants in order, passing a list[Message] down the chain - Agents append their assistant messages to the conversation - - Custom executors can transform/summarize and return a list[ChatMessage] + - Custom executors can transform/summarize and return a list[Message] - The final output is the conversation produced by the last participant Usage: @@ -127,11 +124,6 @@ class SequentialBuilder: # With agent instances workflow = SequentialBuilder(participants=[agent1, agent2, summarizer_exec]).build() - # With agent factories - workflow = SequentialBuilder( - participant_factories=[create_agent1, create_agent2, create_summarizer_exec] - ).build() - # Enable checkpoint persistence workflow = SequentialBuilder(participants=[agent1, agent2], checkpoint_storage=storage).build() @@ -149,55 +141,27 @@ class SequentialBuilder: def __init__( self, *, - participants: Sequence[SupportsAgentRun | Executor] | None = None, - participant_factories: Sequence[Callable[[], SupportsAgentRun | Executor]] | None = None, + participants: Sequence[SupportsAgentRun | Executor], checkpoint_storage: CheckpointStorage | None = None, intermediate_outputs: bool = False, ) -> None: """Initialize the SequentialBuilder. Args: - participants: Optional sequence of agent or executor instances to run sequentially. - participant_factories: Optional sequence of callables returning agent or executor instances. + participants: Sequence of agent or executor instances to run sequentially. checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence. intermediate_outputs: If True, enables intermediate outputs from agent participants. """ self._participants: list[SupportsAgentRun | Executor] = [] - self._participant_factories: list[Callable[[], SupportsAgentRun | Executor]] = [] self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage self._request_info_enabled: bool = False self._request_info_filter: set[str] | None = None self._intermediate_outputs: bool = intermediate_outputs - if participants is None and participant_factories is None: - raise ValueError("Either participants or participant_factories must be provided.") - - if participant_factories is not None: - self._set_participant_factories(participant_factories) - if participants is not None: - self._set_participants(participants) - - def _set_participant_factories( - self, - participant_factories: Sequence[Callable[[], SupportsAgentRun | Executor]], - ) -> None: - """Set participant factories (internal).""" - if self._participants: - raise ValueError("Cannot provide both participants and participant_factories.") - - if self._participant_factories: - raise ValueError("participant_factories already set.") - - if not participant_factories: - raise ValueError("participant_factories cannot be empty") - - self._participant_factories = list(participant_factories) + self._set_participants(participants) def _set_participants(self, participants: Sequence[SupportsAgentRun | Executor]) -> None: """Set participants (internal).""" - if self._participant_factories: - raise ValueError("Cannot provide both participants and participant_factories.") - if self._participants: raise ValueError("participants already set.") @@ -256,19 +220,10 @@ class SequentialBuilder: def _resolve_participants(self) -> list[Executor]: """Resolve participant instances into Executor objects.""" - if not self._participants and not self._participant_factories: - raise ValueError("No participants provided. Pass participants or participant_factories to the constructor.") - # We don't need to check if both are set since that is handled in the respective methods + if not self._participants: + raise ValueError("No participants provided. Pass participants to the constructor.") - participants: list[Executor | SupportsAgentRun] = [] - if self._participant_factories: - # Resolve the participant factories now. This doesn't break the factory pattern - # since the Sequential builder still creates new instances per workflow build. - for factory in self._participant_factories: - p = factory() - participants.append(p) - else: - participants = self._participants + participants: list[Executor | SupportsAgentRun] = self._participants executors: list[Executor] = [] for p in participants: @@ -291,7 +246,7 @@ class SequentialBuilder: """Build and validate the sequential workflow. Wiring pattern: - - _InputToConversation normalizes the initial input into list[ChatMessage] + - _InputToConversation normalizes the initial input into list[Message] - For each participant in order: - If Agent (or AgentExecutor): pass conversation to the agent, then optionally route through a request info interceptor, then convert response to conversation diff --git a/python/packages/orchestrations/pyproject.toml b/python/packages/orchestrations/pyproject.toml index 6e3ab8f46e..73a6c1209a 100644 --- a/python/packages/orchestrations/pyproject.toml +++ b/python/packages/orchestrations/pyproject.toml @@ -4,7 +4,7 @@ description = "Orchestration patterns for Microsoft Agent Framework. Includes Se authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", ] [tool.uv] diff --git a/python/packages/orchestrations/tests/test_concurrent.py b/python/packages/orchestrations/tests/test_concurrent.py index cecc8500c8..8712aae3fd 100644 --- a/python/packages/orchestrations/tests/test_concurrent.py +++ b/python/packages/orchestrations/tests/test_concurrent.py @@ -7,8 +7,8 @@ from agent_framework import ( AgentExecutorRequest, AgentExecutorResponse, AgentResponse, - ChatMessage, Executor, + Message, WorkflowContext, WorkflowRunState, handler, @@ -32,7 +32,7 @@ class _FakeAgentExec(Executor): @handler async def run(self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse]) -> None: - response = AgentResponse(messages=ChatMessage(role="assistant", text=self._reply_text)) + response = AgentResponse(messages=Message(role="assistant", text=self._reply_text)) full_conversation = list(request.messages) + list(response.messages) await ctx.send_message(AgentExecutorResponse(self.id, response, full_conversation=full_conversation)) @@ -49,47 +49,6 @@ def test_concurrent_builder_rejects_duplicate_executors() -> None: ConcurrentBuilder(participants=[a, b]) -def test_concurrent_builder_rejects_duplicate_executors_from_factories() -> None: - """Test that duplicate executor IDs from factories are detected at build time.""" - - def create_dup1() -> Executor: - return _FakeAgentExec("dup", "A") - - def create_dup2() -> Executor: - return _FakeAgentExec("dup", "B") # same executor id - - builder = ConcurrentBuilder(participant_factories=[create_dup1, create_dup2]) - with pytest.raises(ValueError, match="Duplicate executor ID 'dup' detected in workflow."): - builder.build() - - -def test_concurrent_builder_rejects_mixed_participants_and_factories() -> None: - """Test that passing both participants and participant_factories to the constructor raises an error.""" - with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"): - ConcurrentBuilder( - participants=[_FakeAgentExec("a", "A")], - participant_factories=[lambda: _FakeAgentExec("b", "B")], - ) - - -def test_concurrent_builder_rejects_both_participants_and_factories() -> None: - """Test that passing both participants and participant_factories raises an error.""" - with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"): - ConcurrentBuilder( - participants=[_FakeAgentExec("a", "A")], - participant_factories=[lambda: _FakeAgentExec("b", "B")], - ) - - -def test_concurrent_builder_rejects_both_factories_and_participants() -> None: - """Test that passing both participant_factories and participants raises an error.""" - with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"): - ConcurrentBuilder( - participant_factories=[lambda: _FakeAgentExec("a", "A")], - participants=[_FakeAgentExec("b", "B")], - ) - - async def test_concurrent_default_aggregator_emits_single_user_and_assistants() -> None: # Three synthetic agent executors e1 = _FakeAgentExec("agentA", "Alpha") @@ -99,18 +58,18 @@ async def test_concurrent_default_aggregator_emits_single_user_and_assistants() wf = ConcurrentBuilder(participants=[e1, e2, e3]).build() completed = False - output: list[ChatMessage] | None = None + output: list[Message] | None = None async for ev in wf.run("prompt: hello world", stream=True): if ev.type == "status" and ev.state == WorkflowRunState.IDLE: completed = True elif ev.type == "output": - output = cast(list[ChatMessage], ev.data) + output = cast(list[Message], ev.data) if completed and output is not None: break assert completed assert output is not None - messages: list[ChatMessage] = output + messages: list[Message] = output # Expect one user message + one assistant message per participant assert len(messages) == 1 + 3 @@ -130,7 +89,7 @@ async def test_concurrent_custom_aggregator_callback_is_used() -> None: async def summarize(results: list[AgentExecutorResponse]) -> str: texts: list[str] = [] for r in results: - msgs: list[ChatMessage] = r.agent_response.messages + msgs: list[Message] = r.agent_response.messages texts.append(msgs[-1].text if msgs else "") return " | ".join(sorted(texts)) @@ -161,7 +120,7 @@ async def test_concurrent_custom_aggregator_sync_callback_is_used() -> None: def summarize_sync(results: list[AgentExecutorResponse], _ctx: WorkflowContext[Any]) -> str: # type: ignore[unused-argument] texts: list[str] = [] for r in results: - msgs: list[ChatMessage] = r.agent_response.messages + msgs: list[Message] = r.agent_response.messages texts.append(msgs[-1].text if msgs else "") return " | ".join(sorted(texts)) @@ -205,7 +164,7 @@ async def test_concurrent_with_aggregator_executor_instance() -> None: async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None: texts: list[str] = [] for r in results: - msgs: list[ChatMessage] = r.agent_response.messages + msgs: list[Message] = r.agent_response.messages texts.append(msgs[-1].text if msgs else "") await ctx.yield_output(" & ".join(sorted(texts))) @@ -231,79 +190,6 @@ async def test_concurrent_with_aggregator_executor_instance() -> None: assert output == "One & Two" -async def test_concurrent_with_aggregator_executor_factory() -> None: - """Test with_aggregator using an Executor factory.""" - - class CustomAggregator(Executor): - @handler - async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None: - texts: list[str] = [] - for r in results: - msgs: list[ChatMessage] = r.agent_response.messages - texts.append(msgs[-1].text if msgs else "") - await ctx.yield_output(" | ".join(sorted(texts))) - - e1 = _FakeAgentExec("agentA", "One") - e2 = _FakeAgentExec("agentB", "Two") - - wf = ( - ConcurrentBuilder(participants=[e1, e2]) - .register_aggregator(lambda: CustomAggregator(id="custom_aggregator")) - .build() - ) - - completed = False - output: str | None = None - async for ev in wf.run("prompt: factory test", stream=True): - if ev.type == "status" and ev.state == WorkflowRunState.IDLE: - completed = True - elif ev.type == "output": - output = cast(str, ev.data) - if completed and output is not None: - break - - assert completed - assert output is not None - assert isinstance(output, str) - assert output == "One | Two" - - -async def test_concurrent_with_aggregator_executor_factory_with_default_id() -> None: - """Test with_aggregator using an Executor class directly as factory (with default __init__ parameters).""" - - class CustomAggregator(Executor): - def __init__(self, id: str = "default_aggregator") -> None: - super().__init__(id) - - @handler - async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None: - texts: list[str] = [] - for r in results: - msgs: list[ChatMessage] = r.agent_response.messages - texts.append(msgs[-1].text if msgs else "") - await ctx.yield_output(" | ".join(sorted(texts))) - - e1 = _FakeAgentExec("agentA", "One") - e2 = _FakeAgentExec("agentB", "Two") - - wf = ConcurrentBuilder(participants=[e1, e2]).register_aggregator(CustomAggregator).build() - - completed = False - output: str | None = None - async for ev in wf.run("prompt: factory test", stream=True): - if ev.type == "status" and ev.state == WorkflowRunState.IDLE: - completed = True - elif ev.type == "output": - output = cast(str, ev.data) - if completed and output is not None: - break - - assert completed - assert output is not None - assert isinstance(output, str) - assert output == "One | Two" - - def test_concurrent_builder_rejects_multiple_calls_to_with_aggregator() -> None: """Test that multiple calls to .with_aggregator() raises an error.""" @@ -318,20 +204,6 @@ def test_concurrent_builder_rejects_multiple_calls_to_with_aggregator() -> None: ) -def test_concurrent_builder_rejects_multiple_calls_to_register_aggregator() -> None: - """Test that multiple calls to .register_aggregator() raises an error.""" - - class CustomAggregator(Executor): - pass - - with pytest.raises(ValueError, match=r"register_aggregator\(\) has already been called"): - ( - ConcurrentBuilder(participants=[_FakeAgentExec("a", "A")]) - .register_aggregator(lambda: CustomAggregator(id="agg1")) - .register_aggregator(lambda: CustomAggregator(id="agg2")) - ) - - async def test_concurrent_checkpoint_resume_round_trip() -> None: storage = InMemoryCheckpointStorage() @@ -343,7 +215,7 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None: wf = ConcurrentBuilder(participants=list(participants), checkpoint_storage=storage).build() - baseline_output: list[ChatMessage] | None = None + baseline_output: list[Message] | None = None async for ev in wf.run("checkpoint concurrent", stream=True): if ev.type == "output": baseline_output = ev.data # type: ignore[assignment] @@ -352,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"), @@ -367,7 +236,7 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None: ) wf_resume = ConcurrentBuilder(participants=list(resumed_participants), checkpoint_storage=storage).build() - resumed_output: list[ChatMessage] | None = None + resumed_output: list[Message] | None = None async for ev in wf_resume.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True): if ev.type == "output": resumed_output = ev.data # type: ignore[assignment] @@ -389,7 +258,7 @@ async def test_concurrent_checkpoint_runtime_only() -> None: agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")] wf = ConcurrentBuilder(participants=agents).build() - baseline_output: list[ChatMessage] | None = None + baseline_output: list[Message] | None = None async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True): if ev.type == "output": baseline_output = ev.data # type: ignore[assignment] @@ -398,19 +267,18 @@ 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() - resumed_output: list[ChatMessage] | None = None + resumed_output: list[Message] | None = None async for ev in wf_resume.run( checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage, stream=True ): @@ -439,7 +307,7 @@ async def test_concurrent_checkpoint_runtime_overrides_buildtime() -> None: agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")] wf = ConcurrentBuilder(participants=agents, checkpoint_storage=buildtime_storage).build() - baseline_output: list[ChatMessage] | None = None + baseline_output: list[Message] | None = None async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True): if ev.type == "output": baseline_output = ev.data # type: ignore[assignment] @@ -448,18 +316,13 @@ 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" -def test_concurrent_builder_rejects_empty_participant_factories() -> None: - with pytest.raises(ValueError): - ConcurrentBuilder(participant_factories=[]) - - async def test_concurrent_builder_reusable_after_build_with_participants() -> None: """Test that the builder can be reused to build multiple identical workflows with participants().""" e1 = _FakeAgentExec("agentA", "One") @@ -471,74 +334,3 @@ async def test_concurrent_builder_reusable_after_build_with_participants() -> No assert builder._participants[0] is e1 # type: ignore assert builder._participants[1] is e2 # type: ignore - assert builder._participant_factories == [] # type: ignore - - -async def test_concurrent_builder_reusable_after_build_with_factories() -> None: - """Test that the builder can be reused to build multiple workflows with register_participants().""" - call_count = 0 - - def create_agent_executor_a() -> Executor: - nonlocal call_count - call_count += 1 - return _FakeAgentExec("agentA", "One") - - def create_agent_executor_b() -> Executor: - nonlocal call_count - call_count += 1 - return _FakeAgentExec("agentB", "Two") - - builder = ConcurrentBuilder(participant_factories=[create_agent_executor_a, create_agent_executor_b]) - - # Build the first workflow - wf1 = builder.build() - - assert builder._participants == [] # type: ignore - assert len(builder._participant_factories) == 2 # type: ignore - assert call_count == 2 - - # Build the second workflow - wf2 = builder.build() - assert call_count == 4 - - # Verify that the two workflows have different executor instances - assert wf1.executors["agentA"] is not wf2.executors["agentA"] - assert wf1.executors["agentB"] is not wf2.executors["agentB"] - - -async def test_concurrent_with_register_participants() -> None: - """Test workflow creation using register_participants with factories.""" - - def create_agent1() -> Executor: - return _FakeAgentExec("agentA", "Alpha") - - def create_agent2() -> Executor: - return _FakeAgentExec("agentB", "Beta") - - def create_agent3() -> Executor: - return _FakeAgentExec("agentC", "Gamma") - - wf = ConcurrentBuilder(participant_factories=[create_agent1, create_agent2, create_agent3]).build() - - completed = False - output: list[ChatMessage] | None = None - async for ev in wf.run("test prompt", stream=True): - if ev.type == "status" and ev.state == WorkflowRunState.IDLE: - completed = True - elif ev.type == "output": - output = cast(list[ChatMessage], ev.data) - if completed and output is not None: - break - - assert completed - assert output is not None - messages: list[ChatMessage] = output - - # Expect one user message + one assistant message per participant - assert len(messages) == 1 + 3 - assert messages[0].role == "user" - assert "test prompt" in messages[0].text - - assistant_texts = {m.text for m in messages[1:]} - assert assistant_texts == {"Alpha", "Beta", "Gamma"} - assert all(m.role == "assistant" for m in messages[1:]) diff --git a/python/packages/orchestrations/tests/test_group_chat.py b/python/packages/orchestrations/tests/test_group_chat.py index 718b8eb3a7..7115e9c7d7 100644 --- a/python/packages/orchestrations/tests/test_group_chat.py +++ b/python/packages/orchestrations/tests/test_group_chat.py @@ -5,16 +5,16 @@ from typing import Any, cast import pytest from agent_framework import ( + Agent, AgentExecutorResponse, AgentResponse, AgentResponseUpdate, - AgentThread, + AgentSession, BaseAgent, - ChatAgent, - ChatMessage, ChatResponse, ChatResponseUpdate, Content, + Message, WorkflowEvent, WorkflowRunState, ) @@ -38,10 +38,10 @@ class StubAgent(BaseAgent): def run( # type: ignore[override] self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse] | AsyncIterable[AgentResponseUpdate]: if stream: @@ -49,7 +49,7 @@ class StubAgent(BaseAgent): return self._run_impl() async def _run_impl(self) -> AgentResponse: - response = ChatMessage(role="assistant", text=self._reply_text, author_name=self.name) + response = Message(role="assistant", text=self._reply_text, author_name=self.name) return AgentResponse(messages=[response]) async def _run_stream_impl(self) -> AsyncIterable[AgentResponseUpdate]: @@ -69,16 +69,16 @@ class MockChatClient: raise NotImplementedError -class StubManagerAgent(ChatAgent): +class StubManagerAgent(Agent): def __init__(self) -> None: - super().__init__(chat_client=MockChatClient(), name="manager_agent", description="Stub manager") + super().__init__(client=MockChatClient(), name="manager_agent", description="Stub manager") self._call_count = 0 async def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> AgentResponse: if self._call_count == 0: @@ -87,7 +87,7 @@ class StubManagerAgent(ChatAgent): payload = {"terminate": False, "reason": "Selecting agent", "next_speaker": "agent", "final_message": None} return AgentResponse( messages=[ - ChatMessage( + Message( role="assistant", text=( '{"terminate": false, "reason": "Selecting agent", ' @@ -108,7 +108,7 @@ class StubManagerAgent(ChatAgent): } return AgentResponse( messages=[ - ChatMessage( + Message( role="assistant", text=( '{"terminate": true, "reason": "Task complete", ' @@ -121,6 +121,51 @@ class StubManagerAgent(ChatAgent): ) +class ConcatenatedJsonManagerAgent(Agent): + """Manager agent that emits concatenated JSON in a single assistant message.""" + + def __init__(self) -> None: + super().__init__(client=MockChatClient(), name="concat_manager", description="Concatenated JSON manager") + self._call_count = 0 + + async def run( + self, + messages: str | Message | Sequence[str | Message] | None = None, + *, + session: AgentSession | None = None, + **kwargs: Any, + ) -> AgentResponse: + if self._call_count == 0: + self._call_count += 1 + return AgentResponse( + messages=[ + Message( + role="assistant", + text=( + '{"terminate": false, "reason": "invalid candidate", ' + '"next_speaker": "unknown", "final_message": null} ' + '{"terminate": false, "reason": "pick known participant", ' + '"next_speaker": "agent", "final_message": null}' + ), + author_name=self.name, + ) + ] + ) + + return AgentResponse( + messages=[ + Message( + role="assistant", + text=( + '{"terminate": true, "reason": "Task complete", ' + '"next_speaker": null, "final_message": "concatenated manager final"}' + ), + author_name=self.name, + ) + ] + ) + + def make_sequence_selector() -> Callable[[GroupChatState], str]: state_counter = {"value": 0} @@ -143,10 +188,10 @@ class StubMagenticManager(MagenticManagerBase): super().__init__(max_stall_count=3, max_round_count=5) self._round = 0 - async def plan(self, magentic_context: MagenticContext) -> ChatMessage: - return ChatMessage(role="assistant", text="plan", author_name="magentic_manager") + async def plan(self, magentic_context: MagenticContext) -> Message: + return Message(role="assistant", text="plan", author_name="magentic_manager") - async def replan(self, magentic_context: MagenticContext) -> ChatMessage: + async def replan(self, magentic_context: MagenticContext) -> Message: return await self.plan(magentic_context) async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: @@ -169,8 +214,8 @@ class StubMagenticManager(MagenticManagerBase): instruction_or_question=MagenticProgressLedgerItem(reason="", answer=""), ) - async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage: - return ChatMessage(role="assistant", text="final", author_name="magentic_manager") + async def prepare_final_answer(self, magentic_context: MagenticContext) -> Message: + return Message(role="assistant", text="final", author_name="magentic_manager") async def test_group_chat_builder_basic_flow() -> None: @@ -185,12 +230,12 @@ async def test_group_chat_builder_basic_flow() -> None: orchestrator_name="manager", ).build() - outputs: list[list[ChatMessage]] = [] + outputs: list[list[Message]] = [] async for event in workflow.run("coordinate task", stream=True): if event.type == "output": data = event.data if isinstance(data, list): - outputs.append(cast(list[ChatMessage], data)) + outputs.append(cast(list[Message], data)) assert len(outputs) == 1 assert len(outputs[0]) >= 1 @@ -213,14 +258,37 @@ async def test_group_chat_as_agent_accepts_conversation() -> None: agent = workflow.as_agent(name="group-chat-agent") conversation = [ - ChatMessage(role="user", text="kickoff", author_name="user"), - ChatMessage(role="assistant", text="noted", author_name="alpha"), + Message(role="user", text="kickoff", author_name="user"), + Message(role="assistant", text="noted", author_name="alpha"), ] response = await agent.run(conversation) assert response.messages, "Expected agent conversation output" +async def test_agent_manager_handles_concatenated_json_output() -> None: + manager = ConcatenatedJsonManagerAgent() + worker = StubAgent("agent", "worker response") + + workflow = GroupChatBuilder( + participants=[worker], + orchestrator_agent=manager, + ).build() + + outputs: list[list[Message]] = [] + async for event in workflow.run("coordinate task", stream=True): + if event.type == "output": + data = event.data + if isinstance(data, list): + outputs.append(cast(list[Message], data)) + + assert outputs + conversation = outputs[-1] + assert any(msg.author_name == "agent" and msg.text == "worker response" for msg in conversation) + assert conversation[-1].author_name == manager.name + assert conversation[-1].text == "concatenated manager final" + + # Comprehensive tests for group chat functionality @@ -240,12 +308,9 @@ class TestGroupChatBuilder: builder.build() def test_build_without_participants_raises_error(self) -> None: - """Test that constructing without participants raises ValueError.""" - with pytest.raises( - ValueError, - match=r"Either participants or participant_factories must be provided\.", - ): - GroupChatBuilder() + """Test that constructing with empty participants raises ValueError.""" + with pytest.raises(ValueError): + GroupChatBuilder(participants=[]) def test_duplicate_manager_configuration_raises_error(self) -> None: """Test that configuring multiple orchestrator options raises ValueError.""" @@ -281,7 +346,7 @@ class TestGroupChatBuilder: super().__init__(name="", description="test") def run( - self, messages: Any = None, *, stream: bool = False, thread: Any = None, **kwargs: Any + self, messages: Any = None, *, stream: bool = False, session: Any = None, **kwargs: Any ) -> AgentResponse | AsyncIterable[AgentResponseUpdate]: if stream: @@ -327,12 +392,12 @@ class TestGroupChatWorkflow: selection_func=selector, ).build() - outputs: list[list[ChatMessage]] = [] + outputs: list[list[Message]] = [] async for event in workflow.run("test task", stream=True): if event.type == "output": data = event.data if isinstance(data, list): - outputs.append(cast(list[ChatMessage], data)) + outputs.append(cast(list[Message], data)) # Should have terminated due to max_rounds, expect at least one output assert len(outputs) >= 1 @@ -348,7 +413,7 @@ class TestGroupChatWorkflow: def selector(state: GroupChatState) -> str: return "agent" - def termination_condition(conversation: list[ChatMessage]) -> bool: + def termination_condition(conversation: list[Message]) -> bool: replies = [msg for msg in conversation if msg.role == "assistant" and msg.author_name == "agent"] return len(replies) >= 2 @@ -360,12 +425,12 @@ class TestGroupChatWorkflow: selection_func=selector, ).build() - outputs: list[list[ChatMessage]] = [] + outputs: list[list[Message]] = [] async for event in workflow.run("test task", stream=True): if event.type == "output": data = event.data if isinstance(data, list): - outputs.append(cast(list[ChatMessage], data)) + outputs.append(cast(list[Message], data)) assert outputs, "Expected termination to yield output" conversation = outputs[-1] @@ -386,12 +451,12 @@ class TestGroupChatWorkflow: orchestrator_agent=manager, ).build() - outputs: list[list[ChatMessage]] = [] + outputs: list[list[Message]] = [] async for event in workflow.run("test task", stream=True): if event.type == "output": data = event.data if isinstance(data, list): - outputs.append(cast(list[ChatMessage], data)) + outputs.append(cast(list[Message], data)) assert outputs, "Expected termination to yield output" conversation = outputs[-1] @@ -432,12 +497,12 @@ class TestCheckpointing: selection_func=selector, ).build() - outputs: list[list[ChatMessage]] = [] + outputs: list[list[Message]] = [] async for event in workflow.run("test task", stream=True): if event.type == "output": data = event.data if isinstance(data, list): - outputs.append(cast(list[ChatMessage], data)) + outputs.append(cast(list[Message], data)) assert len(outputs) == 1 # Should complete normally @@ -455,12 +520,12 @@ class TestConversationHandling: workflow = GroupChatBuilder(participants=[agent], max_rounds=1, selection_func=selector).build() - with pytest.raises(ValueError, match="At least one ChatMessage is required to start the group chat workflow."): + with pytest.raises(ValueError, match="At least one Message is required to start the group chat workflow."): async for _ in workflow.run([], stream=True): pass async def test_handle_string_input(self) -> None: - """Test handling string input creates proper ChatMessage.""" + """Test handling string input creates proper Message.""" def selector(state: GroupChatState) -> str: # Verify the conversation has the user message @@ -473,18 +538,18 @@ class TestConversationHandling: workflow = GroupChatBuilder(participants=[agent], max_rounds=1, selection_func=selector).build() - outputs: list[list[ChatMessage]] = [] + outputs: list[list[Message]] = [] async for event in workflow.run("test string", stream=True): if event.type == "output": data = event.data if isinstance(data, list): - outputs.append(cast(list[ChatMessage], data)) + outputs.append(cast(list[Message], data)) assert len(outputs) == 1 async def test_handle_chat_message_input(self) -> None: - """Test handling ChatMessage input directly.""" - task_message = ChatMessage(role="user", text="test message") + """Test handling Message input directly.""" + task_message = Message(role="user", text="test message") def selector(state: GroupChatState) -> str: # Verify the task message was preserved in conversation @@ -496,20 +561,20 @@ class TestConversationHandling: workflow = GroupChatBuilder(participants=[agent], max_rounds=1, selection_func=selector).build() - outputs: list[list[ChatMessage]] = [] + outputs: list[list[Message]] = [] async for event in workflow.run(task_message, stream=True): if event.type == "output": data = event.data if isinstance(data, list): - outputs.append(cast(list[ChatMessage], data)) + outputs.append(cast(list[Message], data)) assert len(outputs) == 1 async def test_handle_conversation_list_input(self) -> None: """Test handling conversation list preserves context.""" conversation = [ - ChatMessage(role="system", text="system message"), - ChatMessage(role="user", text="user message"), + Message(role="system", text="system message"), + Message(role="user", text="user message"), ] def selector(state: GroupChatState) -> str: @@ -522,12 +587,12 @@ class TestConversationHandling: workflow = GroupChatBuilder(participants=[agent], max_rounds=1, selection_func=selector).build() - outputs: list[list[ChatMessage]] = [] + outputs: list[list[Message]] = [] async for event in workflow.run(conversation, stream=True): if event.type == "output": data = event.data if isinstance(data, list): - outputs.append(cast(list[ChatMessage], data)) + outputs.append(cast(list[Message], data)) assert len(outputs) == 1 @@ -552,12 +617,12 @@ class TestRoundLimitEnforcement: selection_func=selector, ).build() - outputs: list[list[ChatMessage]] = [] + outputs: list[list[Message]] = [] async for event in workflow.run("test", stream=True): if event.type == "output": data = event.data if isinstance(data, list): - outputs.append(cast(list[ChatMessage], data)) + outputs.append(cast(list[Message], data)) # Should have at least one output (the round limit message) assert len(outputs) >= 1 @@ -585,12 +650,12 @@ class TestRoundLimitEnforcement: selection_func=selector, ).build() - outputs: list[list[ChatMessage]] = [] + outputs: list[list[Message]] = [] async for event in workflow.run("test", stream=True): if event.type == "output": data = event.data if isinstance(data, list): - outputs.append(cast(list[ChatMessage], data)) + outputs.append(cast(list[Message], data)) # Should have at least one output (the round limit message) assert len(outputs) >= 1 @@ -611,10 +676,10 @@ async def test_group_chat_checkpoint_runtime_only() -> None: wf = GroupChatBuilder(participants=[agent_a, agent_b], max_rounds=2, selection_func=selector).build() - baseline_output: list[ChatMessage] | None = None + baseline_output: list[Message] | None = None async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True): if ev.type == "output": - baseline_output = cast(list[ChatMessage], ev.data) if isinstance(ev.data, list) else None # type: ignore + baseline_output = cast(list[Message], ev.data) if isinstance(ev.data, list) else None # type: ignore if ev.type == "status" and ev.state in ( WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, @@ -623,7 +688,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" @@ -647,10 +712,10 @@ async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None: checkpoint_storage=buildtime_storage, selection_func=selector, ).build() - baseline_output: list[ChatMessage] | None = None + baseline_output: list[Message] | None = None async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True): if ev.type == "output": - baseline_output = cast(list[ChatMessage], ev.data) if isinstance(ev.data, list) else None # type: ignore + baseline_output = cast(list[Message], ev.data) if isinstance(ev.data, list) else None # type: ignore if ev.type == "status" and ev.state in ( WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, @@ -659,8 +724,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" @@ -775,150 +840,6 @@ def test_group_chat_builder_with_request_info_returns_self(): assert result2 is builder2 -# region Participant Factory Tests - - -def test_group_chat_builder_rejects_empty_participant_factories(): - """Test that GroupChatBuilder rejects empty participant_factories list.""" - - def selector(state: GroupChatState) -> str: - return list(state.participants.keys())[0] - - with pytest.raises(ValueError, match=r"participant_factories cannot be empty"): - GroupChatBuilder(participant_factories=[]) - - with pytest.raises( - ValueError, - match=r"Either participants or participant_factories must be provided\.", - ): - GroupChatBuilder() - - -def test_group_chat_builder_rejects_mixing_participants_and_factories(): - """Test that passing both participants and participant_factories to the constructor raises an error.""" - alpha = StubAgent("alpha", "reply from alpha") - - with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"): - GroupChatBuilder( - participants=[alpha], - participant_factories=[lambda: StubAgent("beta", "reply from beta")], - ) - - -def test_group_chat_builder_rejects_both_factories_and_participants(): - """Test that passing both participant_factories and participants raises an error.""" - with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"): - GroupChatBuilder( - participant_factories=[lambda: StubAgent("alpha", "reply from alpha")], - participants=[StubAgent("beta", "reply from beta")], - ) - - -def test_group_chat_builder_rejects_both_participants_and_factories(): - """Test that passing both participants and participant_factories raises an error.""" - with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"): - GroupChatBuilder( - participants=[StubAgent("alpha", "reply from alpha")], - participant_factories=[lambda: StubAgent("beta", "reply from beta")], - ) - - -async def test_group_chat_with_participant_factories(): - """Test workflow creation using participant_factories.""" - call_count = 0 - - def create_alpha() -> StubAgent: - nonlocal call_count - call_count += 1 - return StubAgent("alpha", "reply from alpha") - - def create_beta() -> StubAgent: - nonlocal call_count - call_count += 1 - return StubAgent("beta", "reply from beta") - - selector = make_sequence_selector() - - workflow = GroupChatBuilder( - participant_factories=[create_alpha, create_beta], - max_rounds=2, - selection_func=selector, - ).build() - - # Factories should be called during build - assert call_count == 2 - - outputs: list[WorkflowEvent] = [] - async for event in workflow.run("coordinate task", stream=True): - if event.type == "output": - outputs.append(event) - - assert len(outputs) == 1 - - -async def test_group_chat_participant_factories_reusable_builder(): - """Test that the builder can be reused to build multiple workflows with factories.""" - call_count = 0 - - def create_alpha() -> StubAgent: - nonlocal call_count - call_count += 1 - return StubAgent("alpha", "reply from alpha") - - def create_beta() -> StubAgent: - nonlocal call_count - call_count += 1 - return StubAgent("beta", "reply from beta") - - selector = make_sequence_selector() - - builder = GroupChatBuilder(participant_factories=[create_alpha, create_beta], max_rounds=2, selection_func=selector) - - # Build first workflow - wf1 = builder.build() - assert call_count == 2 - - # Build second workflow - wf2 = builder.build() - assert call_count == 4 - - # Verify that the two workflows have different agent instances - assert wf1.executors["alpha"] is not wf2.executors["alpha"] - assert wf1.executors["beta"] is not wf2.executors["beta"] - - -async def test_group_chat_participant_factories_with_checkpointing(): - """Test checkpointing with participant_factories.""" - storage = InMemoryCheckpointStorage() - - def create_alpha() -> StubAgent: - return StubAgent("alpha", "reply from alpha") - - def create_beta() -> StubAgent: - return StubAgent("beta", "reply from beta") - - selector = make_sequence_selector() - - workflow = GroupChatBuilder( - participant_factories=[create_alpha, create_beta], - checkpoint_storage=storage, - max_rounds=2, - selection_func=selector, - ).build() - - outputs: list[WorkflowEvent] = [] - async for event in workflow.run("checkpoint test", stream=True): - if event.type == "output": - outputs.append(event) - - assert outputs, "Should have workflow output" - - checkpoints = await storage.list_checkpoints() - assert checkpoints, "Checkpoints should be created during workflow execution" - - -# endregion - # region Orchestrator Factory Tests @@ -928,8 +849,8 @@ def test_group_chat_builder_rejects_multiple_orchestrator_configurations(): def selector(state: GroupChatState) -> str: return list(state.participants.keys())[0] - def agent_factory() -> ChatAgent: - return cast(ChatAgent, StubManagerAgent()) + def agent_factory() -> Agent: + return cast(Agent, StubManagerAgent()) agent = StubAgent("test", "response") @@ -948,8 +869,8 @@ def test_group_chat_builder_requires_exactly_one_orchestrator_option(): def selector(state: GroupChatState) -> str: return list(state.participants.keys())[0] - def agent_factory() -> ChatAgent: - return cast(ChatAgent, StubManagerAgent()) + def agent_factory() -> Agent: + return cast(Agent, StubManagerAgent()) agent = StubAgent("test", "response") @@ -963,21 +884,21 @@ def test_group_chat_builder_requires_exactly_one_orchestrator_option(): async def test_group_chat_with_orchestrator_factory_returning_chat_agent(): - """Test workflow creation using orchestrator_factory that returns ChatAgent.""" + """Test workflow creation using orchestrator_factory that returns Agent.""" factory_call_count = 0 - class DynamicManagerAgent(ChatAgent): + class DynamicManagerAgent(Agent): """Manager agent that dynamically selects from available participants.""" def __init__(self) -> None: - super().__init__(chat_client=MockChatClient(), name="dynamic_manager", description="Dynamic manager") + super().__init__(client=MockChatClient(), name="dynamic_manager", description="Dynamic manager") self._call_count = 0 async def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> AgentResponse: if self._call_count == 0: @@ -990,7 +911,7 @@ async def test_group_chat_with_orchestrator_factory_returning_chat_agent(): } return AgentResponse( messages=[ - ChatMessage( + Message( role="assistant", text=( '{"terminate": false, "reason": "Selecting alpha", ' @@ -1010,7 +931,7 @@ async def test_group_chat_with_orchestrator_factory_returning_chat_agent(): } return AgentResponse( messages=[ - ChatMessage( + Message( role="assistant", text=( '{"terminate": true, "reason": "Task complete", ' @@ -1022,10 +943,10 @@ async def test_group_chat_with_orchestrator_factory_returning_chat_agent(): value=payload, ) - def agent_factory() -> ChatAgent: + def agent_factory() -> Agent: nonlocal factory_call_count factory_call_count += 1 - return cast(ChatAgent, DynamicManagerAgent()) + return cast(Agent, DynamicManagerAgent()) alpha = StubAgent("alpha", "reply from alpha") beta = StubAgent("beta", "reply from beta") @@ -1046,7 +967,7 @@ async def test_group_chat_with_orchestrator_factory_returning_chat_agent(): assert isinstance(final_messages, list) assert any( msg.text == "dynamic manager final" - for msg in cast(list[ChatMessage], final_messages) + for msg in cast(list[Message], final_messages) if msg.author_name == "dynamic_manager" ) @@ -1086,10 +1007,10 @@ async def test_group_chat_orchestrator_factory_reusable_builder(): """Test that the builder can be reused to build multiple workflows with orchestrator factory.""" factory_call_count = 0 - def agent_factory() -> ChatAgent: + def agent_factory() -> Agent: nonlocal factory_call_count factory_call_count += 1 - return cast(ChatAgent, StubManagerAgent()) + return cast(Agent, StubManagerAgent()) alpha = StubAgent("alpha", "reply from alpha") beta = StubAgent("beta", "reply from beta") @@ -1118,88 +1039,15 @@ def test_group_chat_orchestrator_factory_invalid_return_type(): with pytest.raises( TypeError, - match=r"Orchestrator factory must return ChatAgent or BaseGroupChatOrchestrator instance", + match=r"Orchestrator factory must return Agent or BaseGroupChatOrchestrator instance", ): GroupChatBuilder(participants=[alpha], orchestrator=invalid_factory).build() with pytest.raises( TypeError, - match=r"Orchestrator factory must return ChatAgent or BaseGroupChatOrchestrator instance", + match=r"Orchestrator factory must return Agent or BaseGroupChatOrchestrator instance", ): GroupChatBuilder(participants=[alpha], orchestrator_agent=invalid_factory).build() -def test_group_chat_with_both_participant_and_orchestrator_factories(): - """Test workflow creation using both participant_factories and orchestrator_factory.""" - participant_factory_call_count = 0 - agent_factory_call_count = 0 - - def create_alpha() -> StubAgent: - nonlocal participant_factory_call_count - participant_factory_call_count += 1 - return StubAgent("alpha", "reply from alpha") - - def create_beta() -> StubAgent: - nonlocal participant_factory_call_count - participant_factory_call_count += 1 - return StubAgent("beta", "reply from beta") - - def agent_factory() -> ChatAgent: - nonlocal agent_factory_call_count - agent_factory_call_count += 1 - return cast(ChatAgent, StubManagerAgent()) - - workflow = GroupChatBuilder( - participant_factories=[create_alpha, create_beta], - orchestrator_agent=agent_factory, - ).build() - - # All factories should be called during build - assert participant_factory_call_count == 2 - assert agent_factory_call_count == 1 - - # Verify all executors are present in the workflow - assert "alpha" in workflow.executors - assert "beta" in workflow.executors - assert "manager_agent" in workflow.executors - - -async def test_group_chat_factories_reusable_for_multiple_workflows(): - """Test that both factories are reused correctly for multiple workflow builds.""" - participant_factory_call_count = 0 - agent_factory_call_count = 0 - - def create_alpha() -> StubAgent: - nonlocal participant_factory_call_count - participant_factory_call_count += 1 - return StubAgent("alpha", "reply from alpha") - - def create_beta() -> StubAgent: - nonlocal participant_factory_call_count - participant_factory_call_count += 1 - return StubAgent("beta", "reply from beta") - - def agent_factory() -> ChatAgent: - nonlocal agent_factory_call_count - agent_factory_call_count += 1 - return cast(ChatAgent, StubManagerAgent()) - - builder = GroupChatBuilder(participant_factories=[create_alpha, create_beta], orchestrator_agent=agent_factory) - - # Build first workflow - wf1 = builder.build() - assert participant_factory_call_count == 2 - assert agent_factory_call_count == 1 - - # Build second workflow - wf2 = builder.build() - assert participant_factory_call_count == 4 - assert agent_factory_call_count == 2 - - # Verify that the workflows have different agent and orchestrator instances - assert wf1.executors["alpha"] is not wf2.executors["alpha"] - assert wf1.executors["beta"] is not wf2.executors["beta"] - assert wf1.executors["manager_agent"] is not wf2.executors["manager_agent"] - - # endregion diff --git a/python/packages/orchestrations/tests/test_handoff.py b/python/packages/orchestrations/tests/test_handoff.py index 7b382d3511..e8778a86ca 100644 --- a/python/packages/orchestrations/tests/test_handoff.py +++ b/python/packages/orchestrations/tests/test_handoff.py @@ -6,13 +6,12 @@ from unittest.mock import AsyncMock, MagicMock import pytest from agent_framework import ( - ChatAgent, - ChatMessage, + Agent, + BaseContextProvider, ChatResponse, ChatResponseUpdate, Content, - Context, - ContextProvider, + Message, ResponseStream, WorkflowEvent, resolve_agent_id, @@ -50,7 +49,7 @@ class MockChatClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], Bas def _inner_get_response( self, *, - messages: Sequence[ChatMessage], + messages: Sequence[Message], stream: bool, options: Mapping[str, Any], **kwargs: Any, @@ -60,7 +59,7 @@ class MockChatClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], Bas async def _get() -> ChatResponse: contents = _build_reply_contents(self._name, self._handoff_to, self._next_call_id()) - reply = ChatMessage( + reply = Message( role="assistant", contents=contents, ) @@ -105,7 +104,7 @@ def _build_reply_contents( return contents -class MockHandoffAgent(ChatAgent): +class MockHandoffAgent(Agent): """Mock agent that can hand off to another agent.""" def __init__( @@ -121,7 +120,7 @@ class MockHandoffAgent(ChatAgent): handoff_to: The name of the agent to hand off to, or None for no handoff. This is hardcoded for testing purposes so that the agent always attempts to hand off. """ - super().__init__(chat_client=MockChatClient(name=name, handoff_to=handoff_to), name=name, id=name) + super().__init__(client=MockChatClient(name=name, handoff_to=handoff_to), name=name, id=name) async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]: @@ -196,7 +195,7 @@ async def test_autonomous_mode_yields_output_without_user_request(): final_conversation = outputs[-1].data assert isinstance(final_conversation, list) - conversation_list = cast(list[ChatMessage], final_conversation) + conversation_list = cast(list[Message], final_conversation) assert any(msg.role == "assistant" and (msg.text or "").startswith("specialist reply") for msg in conversation_list) @@ -229,17 +228,15 @@ def test_build_fails_without_start_agent(): def test_build_fails_without_participants(): """Verify that build() raises ValueError when no participants are provided.""" - with pytest.raises( - ValueError, match=r"No participants provided\. Call \.participants\(\) or \.register_participants\(\) first." - ): - HandoffBuilder().build() + with pytest.raises(ValueError): + HandoffBuilder(participants=[]).build() async def test_handoff_async_termination_condition() -> None: """Test that async termination conditions work correctly.""" termination_call_count = 0 - async def async_termination(conv: list[ChatMessage]) -> bool: + async def async_termination(conv: list[Message]) -> bool: nonlocal termination_call_count termination_call_count += 1 user_count = sum(1 for msg in conv if msg.role == "user") @@ -260,7 +257,7 @@ async def test_handoff_async_termination_condition() -> None: events = await _drain( workflow.run( - stream=True, responses={requests[-1].request_id: [ChatMessage(role="user", text="Second user message")]} + stream=True, responses={requests[-1].request_id: [Message(role="user", text="Second user message")]} ) ) outputs = [ev for ev in events if ev.type == "output"] @@ -268,7 +265,7 @@ async def test_handoff_async_termination_condition() -> None: final_conversation = outputs[0].data assert isinstance(final_conversation, list) - final_conv_list = cast(list[ChatMessage], final_conversation) + final_conv_list = cast(list[Message], final_conversation) user_messages = [msg for msg in final_conv_list if msg.role == "user"] assert len(user_messages) == 2 assert termination_call_count > 0 @@ -283,7 +280,7 @@ async def test_tool_choice_preserved_from_agent_config(): if options: recorded_tool_choices.append(options.get("tool_choice")) return ChatResponse( - messages=[ChatMessage(role="assistant", text="Response")], + messages=[Message(role="assistant", text="Response")], response_id="test_response", ) @@ -291,8 +288,8 @@ async def test_tool_choice_preserved_from_agent_config(): mock_client.get_response = AsyncMock(side_effect=mock_get_response) # Create agent with specific tool_choice configuration via default_options - agent = ChatAgent( - chat_client=mock_client, + agent = Agent( + client=mock_client, name="test_agent", default_options={"tool_choice": {"mode": "required"}}, # type: ignore ) @@ -308,16 +305,18 @@ async def test_tool_choice_preserved_from_agent_config(): async def test_context_provider_preserved_during_handoff(): - """Verify that context_provider is preserved when cloning agents in handoff workflows.""" + """Verify that context_providers are preserved when cloning agents in handoff workflows.""" # Track whether context provider methods were called provider_calls: list[str] = [] - class TestContextProvider(ContextProvider): + class TestContextProvider(BaseContextProvider): """A test context provider that tracks its invocations.""" - async def invoking(self, messages: Sequence[ChatMessage], **kwargs: Any) -> Context: - provider_calls.append("invoking") - return Context(instructions="Test context from provider.") + def __init__(self) -> None: + super().__init__("test") + + async def before_run(self, **kwargs: Any) -> None: + provider_calls.append("before_run") # Create context provider context_provider = TestContextProvider() @@ -326,17 +325,17 @@ async def test_context_provider_preserved_during_handoff(): mock_client = MockChatClient(name="test_agent") # Create agent with context provider using proper constructor - agent = ChatAgent( - chat_client=mock_client, + agent = Agent( + client=mock_client, name="test_agent", id="test_agent", - context_provider=context_provider, + context_providers=[context_provider], ) # Verify the original agent has the context provider - assert agent.context_provider is context_provider, "Original agent should have context provider" + assert context_provider in agent.context_providers, "Original agent should have context provider" - # Build handoff workflow - this should clone the agent and preserve context_provider + # Build handoff workflow - this should clone the agent and preserve context_providers workflow = HandoffBuilder(participants=[agent]).with_start_agent(agent).build() # Run workflow with a simple message to trigger context provider @@ -349,162 +348,6 @@ async def test_context_provider_preserved_during_handoff(): ) -# region Participant Factory Tests - - -def test_handoff_builder_rejects_empty_participant_factories(): - """Test that HandoffBuilder rejects empty participant_factories dictionary.""" - # Empty factories are rejected immediately when calling participant_factories() - with pytest.raises(ValueError, match=r"participant_factories cannot be empty"): - HandoffBuilder().register_participants({}) - - with pytest.raises( - ValueError, match=r"No participants provided\. Call \.participants\(\) or \.register_participants\(\) first\." - ): - HandoffBuilder(participant_factories={}).build() - - -def test_handoff_builder_rejects_mixing_participants_and_factories(): - """Test that mixing participants and participant_factories in __init__ raises an error.""" - triage = MockHandoffAgent(name="triage") - with pytest.raises(ValueError, match="Cannot mix .participants"): - HandoffBuilder(participants=[triage], participant_factories={"triage": lambda: triage}) - - -def test_handoff_builder_rejects_mixing_participants_and_participant_factories_methods(): - """Test that mixing .participants() and .participant_factories() raises an error.""" - triage = MockHandoffAgent(name="triage") - - # Case 1: participants first, then participant_factories - with pytest.raises(ValueError, match="Cannot mix .participants"): - HandoffBuilder(participants=[triage]).register_participants({ - "specialist": lambda: MockHandoffAgent(name="specialist") - }) - - # Case 2: participant_factories first, then participants - with pytest.raises(ValueError, match="Cannot mix .participants"): - HandoffBuilder(participant_factories={"triage": lambda: triage}).participants([ - MockHandoffAgent(name="specialist") - ]) - - # Case 3: participants(), then participant_factories() - with pytest.raises(ValueError, match="Cannot mix .participants"): - HandoffBuilder().participants([triage]).register_participants({ - "specialist": lambda: MockHandoffAgent(name="specialist") - }) - - # Case 4: participant_factories(), then participants() - with pytest.raises(ValueError, match="Cannot mix .participants"): - HandoffBuilder().register_participants({"triage": lambda: triage}).participants([ - MockHandoffAgent(name="specialist") - ]) - - # Case 5: mix during initialization - with pytest.raises(ValueError, match="Cannot mix .participants"): - HandoffBuilder( - participants=[triage], participant_factories={"specialist": lambda: MockHandoffAgent(name="specialist")} - ) - - -def test_handoff_builder_rejects_multiple_calls_to_participant_factories(): - """Test that multiple calls to .participant_factories() raises an error.""" - with pytest.raises( - ValueError, match=r"register_participants\(\) has already been called on this builder instance." - ): - ( - HandoffBuilder() - .register_participants({"agent1": lambda: MockHandoffAgent(name="agent1")}) - .register_participants({"agent2": lambda: MockHandoffAgent(name="agent2")}) - ) - - -def test_handoff_builder_rejects_multiple_calls_to_participants(): - """Test that multiple calls to .participants() raises an error.""" - with pytest.raises(ValueError, match="participants have already been assigned"): - ( - HandoffBuilder() - .participants([MockHandoffAgent(name="agent1")]) - .participants([MockHandoffAgent(name="agent2")]) - ) - - -def test_handoff_builder_rejects_instance_coordinator_with_factories(): - """Test that using an agent instance for set_coordinator when using factories raises an error.""" - - def create_triage() -> MockHandoffAgent: - return MockHandoffAgent(name="triage") - - def create_specialist() -> MockHandoffAgent: - return MockHandoffAgent(name="specialist") - - # Create an agent instance - coordinator_instance = MockHandoffAgent(name="coordinator") - - with pytest.raises(ValueError, match=r"Call participants\(\.\.\.\) before with_start_agent\(\.\.\.\)"): - ( - HandoffBuilder( - participant_factories={"triage": create_triage, "specialist": create_specialist} - ).with_start_agent(coordinator_instance) # Instance, not factory name - ) - - -def test_handoff_builder_rejects_factory_name_coordinator_with_instances(): - """Test that using a factory name for set_coordinator when using instances raises an error.""" - triage = MockHandoffAgent(name="triage") - specialist = MockHandoffAgent(name="specialist") - - with pytest.raises(ValueError, match=r"Call register_participants\(...\) before with_start_agent\(...\)"): - ( - HandoffBuilder(participants=[triage, specialist]).with_start_agent( - "triage" - ) # String factory name, not instance - ) - - -def test_handoff_builder_rejects_mixed_types_in_add_handoff_source(): - """Test that add_handoff rejects factory name source with instance-based participants.""" - triage = MockHandoffAgent(name="triage") - specialist = MockHandoffAgent(name="specialist") - - with pytest.raises(TypeError, match="Cannot mix factory names \\(str\\) and SupportsAgentRun.*instances"): - ( - HandoffBuilder(participants=[triage, specialist]) - .with_start_agent(triage) - .add_handoff("triage", [specialist]) # String source with instance participants - ) - - -def test_handoff_builder_accepts_all_factory_names_in_add_handoff(): - """Test that add_handoff accepts all factory names when using participant_factories.""" - - def create_triage() -> MockHandoffAgent: - return MockHandoffAgent(name="triage") - - def create_specialist_a() -> MockHandoffAgent: - return MockHandoffAgent(name="specialist_a") - - def create_specialist_b() -> MockHandoffAgent: - return MockHandoffAgent(name="specialist_b") - - # This should work - all strings with participant_factories - builder = ( - HandoffBuilder( - participant_factories={ - "triage": create_triage, - "specialist_a": create_specialist_a, - "specialist_b": create_specialist_b, - } - ) - .with_start_agent("triage") - .add_handoff("triage", ["specialist_a", "specialist_b"]) - ) - - workflow = builder.build() - assert "triage" in workflow.executors - assert "specialist_a" in workflow.executors - assert "specialist_b" in workflow.executors - - def test_handoff_builder_accepts_all_instances_in_add_handoff(): """Test that add_handoff accepts all instances when using participants.""" triage = MockHandoffAgent(name="triage", handoff_to="specialist_a") @@ -522,260 +365,3 @@ def test_handoff_builder_accepts_all_instances_in_add_handoff(): assert "triage" in workflow.executors assert "specialist_a" in workflow.executors assert "specialist_b" in workflow.executors - - -async def test_handoff_with_participant_factories(): - """Test workflow creation using participant_factories.""" - call_count = 0 - - def create_triage() -> MockHandoffAgent: - nonlocal call_count - call_count += 1 - return MockHandoffAgent(name="triage", handoff_to="specialist") - - def create_specialist() -> MockHandoffAgent: - nonlocal call_count - call_count += 1 - return MockHandoffAgent(name="specialist") - - workflow = ( - HandoffBuilder( - participant_factories={"triage": create_triage, "specialist": create_specialist}, - termination_condition=lambda conv: sum(1 for m in conv if m.role == "user") >= 2, - ) - .with_start_agent("triage") - .build() - ) - - # Factories should be called during build - assert call_count == 2 - - events = await _drain(workflow.run("Need help", stream=True)) - requests = [ev for ev in events if ev.type == "request_info"] - assert requests - - # Follow-up message - events = await _drain( - workflow.run(stream=True, responses={requests[-1].request_id: [ChatMessage(role="user", text="More details")]}) - ) - outputs = [ev for ev in events if ev.type == "output"] - assert outputs - - -async def test_handoff_participant_factories_reusable_builder(): - """Test that the builder can be reused to build multiple workflows with factories.""" - call_count = 0 - - def create_triage() -> MockHandoffAgent: - nonlocal call_count - call_count += 1 - return MockHandoffAgent(name="triage", handoff_to="specialist") - - def create_specialist() -> MockHandoffAgent: - nonlocal call_count - call_count += 1 - return MockHandoffAgent(name="specialist") - - builder = HandoffBuilder( - participant_factories={"triage": create_triage, "specialist": create_specialist} - ).with_start_agent("triage") - - # Build first workflow - wf1 = builder.build() - assert call_count == 2 - - # Build second workflow - wf2 = builder.build() - assert call_count == 4 - - # Verify that the two workflows have different agent instances - assert wf1.executors["triage"] is not wf2.executors["triage"] - assert wf1.executors["specialist"] is not wf2.executors["specialist"] - - -async def test_handoff_with_participant_factories_and_add_handoff(): - """Test that .add_handoff() works correctly with participant_factories.""" - - def create_triage() -> MockHandoffAgent: - return MockHandoffAgent(name="triage", handoff_to="specialist_a") - - def create_specialist_a() -> MockHandoffAgent: - return MockHandoffAgent(name="specialist_a", handoff_to="specialist_b") - - def create_specialist_b() -> MockHandoffAgent: - return MockHandoffAgent(name="specialist_b") - - workflow = ( - HandoffBuilder( - participant_factories={ - "triage": create_triage, - "specialist_a": create_specialist_a, - "specialist_b": create_specialist_b, - }, - termination_condition=lambda conv: sum(1 for m in conv if m.role == "user") >= 3, - ) - .with_start_agent("triage") - .add_handoff("triage", ["specialist_a", "specialist_b"]) - .add_handoff("specialist_a", ["specialist_b"]) - .build() - ) - - # Start conversation - triage hands off to specialist_a - events = await _drain(workflow.run("Initial request", stream=True)) - requests = [ev for ev in events if ev.type == "request_info"] - assert requests - - # Verify specialist_a executor exists and was called - assert "specialist_a" in workflow.executors - - # Second user message - specialist_a hands off to specialist_b - events = await _drain( - workflow.run( - stream=True, responses={requests[-1].request_id: [ChatMessage(role="user", text="Need escalation")]} - ) - ) - requests = [ev for ev in events if ev.type == "request_info"] - assert requests - - # Verify specialist_b executor exists - assert "specialist_b" in workflow.executors - - -async def test_handoff_participant_factories_with_checkpointing(): - """Test checkpointing with participant_factories.""" - from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage - - storage = InMemoryCheckpointStorage() - - def create_triage() -> MockHandoffAgent: - return MockHandoffAgent(name="triage", handoff_to="specialist") - - def create_specialist() -> MockHandoffAgent: - return MockHandoffAgent(name="specialist") - - workflow = ( - HandoffBuilder( - participant_factories={"triage": create_triage, "specialist": create_specialist}, - checkpoint_storage=storage, - termination_condition=lambda conv: sum(1 for m in conv if m.role == "user") >= 2, - ) - .with_start_agent("triage") - .build() - ) - - # Run workflow and capture output - events = await _drain(workflow.run("checkpoint test", stream=True)) - requests = [ev for ev in events if ev.type == "request_info"] - assert requests - - events = await _drain( - workflow.run(stream=True, responses={requests[-1].request_id: [ChatMessage(role="user", text="follow up")]}) - ) - outputs = [ev for ev in events if ev.type == "output"] - assert outputs, "Should have workflow output after termination condition is met" - - # List checkpoints - just verify they were created - checkpoints = await storage.list_checkpoints() - assert checkpoints, "Checkpoints should be created during workflow execution" - - -def test_handoff_set_coordinator_with_factory_name(): - """Test that set_coordinator accepts factory name as string.""" - - def create_triage() -> MockHandoffAgent: - return MockHandoffAgent(name="triage") - - def create_specialist() -> MockHandoffAgent: - return MockHandoffAgent(name="specialist") - - builder = HandoffBuilder( - participant_factories={"triage": create_triage, "specialist": create_specialist} - ).with_start_agent("triage") - - workflow = builder.build() - assert "triage" in workflow.executors - - -def test_handoff_add_handoff_with_factory_names(): - """Test that add_handoff accepts factory names as strings.""" - - def create_triage() -> MockHandoffAgent: - return MockHandoffAgent(name="triage", handoff_to="specialist_a") - - def create_specialist_a() -> MockHandoffAgent: - return MockHandoffAgent(name="specialist_a") - - def create_specialist_b() -> MockHandoffAgent: - return MockHandoffAgent(name="specialist_b") - - builder = ( - HandoffBuilder( - participant_factories={ - "triage": create_triage, - "specialist_a": create_specialist_a, - "specialist_b": create_specialist_b, - } - ) - .with_start_agent("triage") - .add_handoff("triage", ["specialist_a", "specialist_b"]) - ) - - workflow = builder.build() - assert "triage" in workflow.executors - assert "specialist_a" in workflow.executors - assert "specialist_b" in workflow.executors - - -async def test_handoff_participant_factories_autonomous_mode(): - """Test autonomous mode with participant_factories.""" - - def create_triage() -> MockHandoffAgent: - return MockHandoffAgent(name="triage", handoff_to="specialist") - - def create_specialist() -> MockHandoffAgent: - return MockHandoffAgent(name="specialist") - - workflow = ( - HandoffBuilder(participant_factories={"triage": create_triage, "specialist": create_specialist}) - .with_start_agent("triage") - .with_autonomous_mode(agents=["specialist"], turn_limits={"specialist": 1}) - .build() - ) - - events = await _drain(workflow.run("Issue", stream=True)) - requests = [ev for ev in events if ev.type == "request_info"] - assert requests and len(requests) == 1 - assert requests[0].source_executor_id == "specialist" - - -def test_handoff_participant_factories_invalid_coordinator_name(): - """Test that set_coordinator raises error for non-existent factory name.""" - - def create_triage() -> MockHandoffAgent: - return MockHandoffAgent(name="triage") - - with pytest.raises( - ValueError, match="Start agent factory name 'nonexistent' is not in the participant_factories list" - ): - (HandoffBuilder(participant_factories={"triage": create_triage}).with_start_agent("nonexistent").build()) - - -def test_handoff_participant_factories_invalid_handoff_target(): - """Test that add_handoff raises error for non-existent target factory name.""" - - def create_triage() -> MockHandoffAgent: - return MockHandoffAgent(name="triage") - - def create_specialist() -> MockHandoffAgent: - return MockHandoffAgent(name="specialist") - - with pytest.raises(ValueError, match="Target factory name 'nonexistent' is not in the participant_factories list"): - ( - HandoffBuilder(participant_factories={"triage": create_triage, "specialist": create_specialist}) - .with_start_agent("triage") - .add_handoff("triage", ["nonexistent"]) - .build() - ) - - -# endregion Participant Factory Tests diff --git a/python/packages/orchestrations/tests/test_magentic.py b/python/packages/orchestrations/tests/test_magentic.py index 5846b56ae4..25c068b9fe 100644 --- a/python/packages/orchestrations/tests/test_magentic.py +++ b/python/packages/orchestrations/tests/test_magentic.py @@ -9,11 +9,11 @@ import pytest from agent_framework import ( AgentResponse, AgentResponseUpdate, - AgentThread, + AgentSession, BaseAgent, - ChatMessage, Content, Executor, + Message, SupportsAgentRun, Workflow, WorkflowCheckpoint, @@ -48,7 +48,7 @@ def test_magentic_context_reset_behavior(): participant_descriptions={"Alice": "Researcher"}, ) # seed context state - ctx.chat_history.append(ChatMessage("assistant", ["draft"])) + ctx.chat_history.append(Message("assistant", ["draft"])) ctx.stall_count = 2 prev_reset = ctx.reset_count @@ -61,8 +61,8 @@ def test_magentic_context_reset_behavior(): @dataclass class _SimpleLedger: - facts: ChatMessage - plan: ChatMessage + facts: Message + plan: Message class FakeManager(MagenticManagerBase): @@ -108,25 +108,25 @@ class FakeManager(MagenticManagerBase): plan_payload = cast(dict[str, Any] | None, ledger_dict.get("plan")) if facts_payload is not None and plan_payload is not None: try: - facts = ChatMessage.from_dict(facts_payload) - plan = ChatMessage.from_dict(plan_payload) + facts = Message.from_dict(facts_payload) + plan = Message.from_dict(plan_payload) self.task_ledger = _SimpleLedger(facts=facts, plan=plan) except Exception: # pragma: no cover - defensive pass - async def plan(self, magentic_context: MagenticContext) -> ChatMessage: - facts = ChatMessage("assistant", ["GIVEN OR VERIFIED FACTS\n- A\n"]) - plan = ChatMessage("assistant", ["- Do X\n- Do Y\n"]) + async def plan(self, magentic_context: MagenticContext) -> Message: + facts = Message("assistant", ["GIVEN OR VERIFIED FACTS\n- A\n"]) + plan = Message("assistant", ["- Do X\n- Do Y\n"]) self.task_ledger = _SimpleLedger(facts=facts, plan=plan) combined = f"Task: {magentic_context.task}\n\nFacts:\n{facts.text}\n\nPlan:\n{plan.text}" - return ChatMessage("assistant", [combined], author_name=self.name) + return Message("assistant", [combined], author_name=self.name) - async def replan(self, magentic_context: MagenticContext) -> ChatMessage: - facts = ChatMessage("assistant", ["GIVEN OR VERIFIED FACTS\n- A2\n"]) - plan = ChatMessage("assistant", ["- Do Z\n"]) + async def replan(self, magentic_context: MagenticContext) -> Message: + facts = Message("assistant", ["GIVEN OR VERIFIED FACTS\n- A2\n"]) + plan = Message("assistant", ["- Do Z\n"]) self.task_ledger = _SimpleLedger(facts=facts, plan=plan) combined = f"Task: {magentic_context.task}\n\nFacts:\n{facts.text}\n\nPlan:\n{plan.text}" - return ChatMessage("assistant", [combined], author_name=self.name) + return Message("assistant", [combined], author_name=self.name) async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: # At least two messages in chat history means request is satisfied for testing @@ -139,8 +139,8 @@ class FakeManager(MagenticManagerBase): instruction_or_question=MagenticProgressLedgerItem(reason="test", answer=self.instruction_text), ) - async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage: - return ChatMessage("assistant", [self.FINAL_ANSWER], author_name=self.name) + async def prepare_final_answer(self, magentic_context: MagenticContext) -> Message: + return Message("assistant", [self.FINAL_ANSWER], author_name=self.name) class StubAgent(BaseAgent): @@ -150,17 +150,17 @@ class StubAgent(BaseAgent): def run( # type: ignore[override] self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse] | AsyncIterable[AgentResponseUpdate]: if stream: return self._run_stream() async def _run() -> AgentResponse: - response = ChatMessage("assistant", [self._reply_text], author_name=self.name) + response = Message("assistant", [self._reply_text], author_name=self.name) return AgentResponse(messages=[response]) return _run() @@ -177,7 +177,7 @@ class DummyExec(Executor): @handler async def _noop( - self, message: GroupChatRequestMessage, ctx: WorkflowContext[ChatMessage] + self, message: GroupChatRequestMessage, ctx: WorkflowContext[Message] ) -> None: # pragma: no cover - not called pass @@ -190,13 +190,13 @@ async def test_magentic_builder_returns_workflow_and_runs() -> None: assert isinstance(workflow, Workflow) - outputs: list[ChatMessage] = [] + outputs: list[Message] = [] orchestrator_event_count = 0 async for event in workflow.run("compose summary", stream=True): if event.type == "output": msg = event.data if isinstance(msg, list): - outputs.extend(cast(list[ChatMessage], msg)) + outputs.extend(cast(list[Message], msg)) elif event.type == "magentic_orchestrator": orchestrator_event_count += 1 @@ -216,8 +216,8 @@ async def test_magentic_as_agent_does_not_accept_conversation() -> None: agent = workflow.as_agent(name="magentic-agent") conversation = [ - ChatMessage("system", ["Guidelines"], author_name="system"), - ChatMessage("user", ["Summarize the findings"], author_name="requester"), + Message("system", ["Guidelines"], author_name="system"), + Message("user", ["Summarize the findings"], author_name="requester"), ] with pytest.raises(ValueError, match="Magentic only support a single task message to start the workflow."): await agent.run(conversation) @@ -250,7 +250,7 @@ async def test_magentic_workflow_plan_review_approval_to_completion(): assert isinstance(req_event.data, MagenticPlanReviewRequest) completed = False - output: list[ChatMessage] | None = None + output: list[Message] | None = None async for ev in wf.run(stream=True, responses={req_event.request_id: req_event.data.approve()}): if ev.type == "status" and ev.state == WorkflowRunState.IDLE: completed = True @@ -262,7 +262,7 @@ async def test_magentic_workflow_plan_review_approval_to_completion(): assert completed assert output is not None assert isinstance(output, list) - assert all(isinstance(msg, ChatMessage) for msg in output) + assert all(isinstance(msg, Message) for msg in output) async def test_magentic_plan_review_with_revise(): @@ -273,7 +273,7 @@ async def test_magentic_plan_review_with_revise(): def __init__(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] super().__init__(*args, **kwargs) - async def replan(self, magentic_context: MagenticContext) -> ChatMessage: # type: ignore[override] + async def replan(self, magentic_context: MagenticContext) -> Message: # type: ignore[override] self.replan_count += 1 return await super().replan(magentic_context) @@ -340,7 +340,7 @@ async def test_magentic_orchestrator_round_limit_produces_partial_result(): assert isinstance(data, list) assert len(data) > 0 # type: ignore assert data[-1].role == "assistant" # type: ignore - assert all(isinstance(msg, ChatMessage) for msg in data) # type: ignore + assert all(isinstance(msg, Message) for msg in data) # type: ignore async def test_magentic_checkpoint_resume_round_trip(): @@ -362,10 +362,15 @@ 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] + loaded_checkpoint = await storage.load(resume_checkpoint.checkpoint_id) + assert loaded_checkpoint is not None + # Regression check: checkpoints with pending request_info must include executor state. + assert "_executor_state" in loaded_checkpoint.state + assert "magentic_orchestrator" in loaded_checkpoint.state["_executor_state"] manager2 = FakeManager() wf_resume = MagenticBuilder( @@ -378,7 +383,7 @@ async def test_magentic_checkpoint_resume_round_trip(): completed: WorkflowEvent | None = None req_event = None async for event in wf_resume.run( - resume_checkpoint.checkpoint_id, + checkpoint_id=resume_checkpoint.checkpoint_id, stream=True, ): if event.type == "request_info" and event.request_type is MagenticPlanReviewRequest: @@ -406,32 +411,32 @@ class StubManagerAgent(BaseAgent): def run( self, - messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, + messages: str | Message | Sequence[str | Message] | None = None, *, stream: bool = False, - thread: Any = None, + session: Any = None, **kwargs: Any, ) -> Awaitable[AgentResponse] | AsyncIterable[AgentResponseUpdate]: if stream: return self._run_stream() async def _run() -> AgentResponse: - return AgentResponse(messages=[ChatMessage("assistant", ["ok"])]) + return AgentResponse(messages=[Message("assistant", ["ok"])]) return _run() async def _run_stream(self) -> AsyncIterable[AgentResponseUpdate]: - yield AgentResponseUpdate(message_deltas=[ChatMessage("assistant", ["ok"])]) + yield AgentResponseUpdate(message_deltas=[Message("assistant", ["ok"])]) async def test_standard_manager_plan_and_replan_via_complete_monkeypatch(): mgr = StandardMagenticManager(StubManagerAgent()) - async def fake_complete_plan(messages: list[ChatMessage], **kwargs: Any) -> ChatMessage: + async def fake_complete_plan(messages: list[Message], **kwargs: Any) -> Message: # Return a different response depending on call order length if any("FACTS" in (m.text or "") for m in messages): - return ChatMessage("assistant", ["- step A\n- step B"]) - return ChatMessage("assistant", ["GIVEN OR VERIFIED FACTS\n- fact1"]) + return Message("assistant", ["- step A\n- step B"]) + return Message("assistant", ["GIVEN OR VERIFIED FACTS\n- fact1"]) # First, patch to produce facts then plan mgr._complete = fake_complete_plan # type: ignore[attr-defined] @@ -444,10 +449,10 @@ async def test_standard_manager_plan_and_replan_via_complete_monkeypatch(): assert any(t in combined.text for t in ("- step A", "- step B", "- step")) # Now replan with new outputs - async def fake_complete_replan(messages: list[ChatMessage], **kwargs: Any) -> ChatMessage: + async def fake_complete_replan(messages: list[Message], **kwargs: Any) -> Message: if any("Please briefly explain" in (m.text or "") for m in messages): - return ChatMessage("assistant", ["- new step"]) - return ChatMessage("assistant", ["GIVEN OR VERIFIED FACTS\n- updated"]) + return Message("assistant", ["- new step"]) + return Message("assistant", ["GIVEN OR VERIFIED FACTS\n- updated"]) mgr._complete = fake_complete_replan # type: ignore[attr-defined] combined2 = await mgr.replan(ctx.clone()) @@ -459,7 +464,7 @@ async def test_standard_manager_progress_ledger_success_and_error(): ctx = MagenticContext(task="task", participant_descriptions={"alice": "desc"}) # Success path: valid JSON - async def fake_complete_ok(messages: list[ChatMessage], **kwargs: Any) -> ChatMessage: + async def fake_complete_ok(messages: list[Message], **kwargs: Any) -> Message: json_text = ( '{"is_request_satisfied": {"reason": "r", "answer": false}, ' '"is_in_loop": {"reason": "r", "answer": false}, ' @@ -467,15 +472,15 @@ async def test_standard_manager_progress_ledger_success_and_error(): '"next_speaker": {"reason": "r", "answer": "alice"}, ' '"instruction_or_question": {"reason": "r", "answer": "do"}}' ) - return ChatMessage("assistant", [json_text]) + return Message("assistant", [json_text]) mgr._complete = fake_complete_ok # type: ignore[attr-defined] ledger = await mgr.create_progress_ledger(ctx.clone()) assert ledger.next_speaker.answer == "alice" # Error path: invalid JSON now raises to avoid emitting planner-oriented instructions to agents - async def fake_complete_bad(messages: list[ChatMessage], **kwargs: Any) -> ChatMessage: - return ChatMessage("assistant", ["not-json"]) + async def fake_complete_bad(messages: list[Message], **kwargs: Any) -> Message: + return Message("assistant", ["not-json"]) mgr._complete = fake_complete_bad # type: ignore[attr-defined] with pytest.raises(RuntimeError): @@ -487,11 +492,11 @@ class InvokeOnceManager(MagenticManagerBase): super().__init__(max_round_count=5, max_stall_count=3, max_reset_count=2) self._invoked = False - async def plan(self, magentic_context: MagenticContext) -> ChatMessage: - return ChatMessage("assistant", ["ledger"]) + async def plan(self, magentic_context: MagenticContext) -> Message: + return Message("assistant", ["ledger"]) - async def replan(self, magentic_context: MagenticContext) -> ChatMessage: - return ChatMessage("assistant", ["re-ledger"]) + async def replan(self, magentic_context: MagenticContext) -> Message: + return Message("assistant", ["re-ledger"]) async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: if not self._invoked: @@ -513,20 +518,20 @@ class InvokeOnceManager(MagenticManagerBase): instruction_or_question=MagenticProgressLedgerItem(reason="r", answer="done"), ) - async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage: - return ChatMessage("assistant", ["final"]) + async def prepare_final_answer(self, magentic_context: MagenticContext) -> Message: + return Message("assistant", ["final"]) class StubThreadAgent(BaseAgent): def __init__(self, name: str | None = None) -> None: super().__init__(name=name or "agentA") - def run(self, messages=None, *, stream: bool = False, thread=None, **kwargs): # type: ignore[override] + def run(self, messages=None, *, stream: bool = False, session=None, **kwargs): # type: ignore[override] if stream: return self._run_stream() async def _run(): - return AgentResponse(messages=[ChatMessage("assistant", ["thread-ok"], author_name=self.name)]) + return AgentResponse(messages=[Message("assistant", ["thread-ok"], author_name=self.name)]) return _run() @@ -543,18 +548,18 @@ class StubAssistantsClient: class StubAssistantsAgent(BaseAgent): - chat_client: object | None = None # allow assignment via Pydantic field + client: object | None = None # allow assignment via Pydantic field def __init__(self) -> None: super().__init__(name="agentA") - self.chat_client = StubAssistantsClient() # type name contains 'AssistantsClient' + self.client = StubAssistantsClient() # type name contains 'AssistantsClient' - def run(self, messages=None, *, stream: bool = False, thread=None, **kwargs): # type: ignore[override] + def run(self, messages=None, *, stream: bool = False, session=None, **kwargs): # type: ignore[override] if stream: return self._run_stream() async def _run(): - return AgentResponse(messages=[ChatMessage("assistant", ["assistants-ok"], author_name=self.name)]) + return AgentResponse(messages=[Message("assistant", ["assistants-ok"], author_name=self.name)]) return _run() @@ -566,8 +571,8 @@ class StubAssistantsAgent(BaseAgent): ) -async def _collect_agent_responses_setup(participant: SupportsAgentRun) -> list[ChatMessage]: - captured: list[ChatMessage] = [] +async def _collect_agent_responses_setup(participant: SupportsAgentRun) -> list[Message]: + captured: list[Message] = [] wf = MagenticBuilder(participants=[participant], intermediate_outputs=True, manager=InvokeOnceManager()).build() @@ -578,7 +583,7 @@ async def _collect_agent_responses_setup(participant: SupportsAgentRun) -> list[ # Capture streaming updates (type="output" with AgentResponseUpdate data) if ev.type == "output" and isinstance(ev.data, AgentResponseUpdate): captured.append( - ChatMessage( + Message( role=ev.data.role or "assistant", text=ev.data.text or "", author_name=ev.data.author_name, @@ -605,8 +610,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 +625,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 +658,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 +695,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( @@ -711,11 +718,11 @@ class NotProgressingManager(MagenticManagerBase): A manager that never marks progress being made, to test stall/reset limits. """ - async def plan(self, magentic_context: MagenticContext) -> ChatMessage: - return ChatMessage("assistant", ["ledger"]) + async def plan(self, magentic_context: MagenticContext) -> Message: + return Message("assistant", ["ledger"]) - async def replan(self, magentic_context: MagenticContext) -> ChatMessage: - return ChatMessage("assistant", ["re-ledger"]) + async def replan(self, magentic_context: MagenticContext) -> Message: + return Message("assistant", ["re-ledger"]) async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: return MagenticProgressLedger( @@ -726,8 +733,8 @@ class NotProgressingManager(MagenticManagerBase): instruction_or_question=MagenticProgressLedgerItem(reason="r", answer="done"), ) - async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage: - return ChatMessage("assistant", ["final"]) + async def prepare_final_answer(self, magentic_context: MagenticContext) -> Message: + return Message("assistant", ["final"]) async def test_magentic_stall_and_reset_reach_limits(): @@ -747,7 +754,7 @@ async def test_magentic_stall_and_reset_reach_limits(): output_event = next((e for e in events if e.type == "output"), None) assert output_event is not None assert isinstance(output_event.data, list) - assert all(isinstance(msg, ChatMessage) for msg in output_event.data) # type: ignore + assert all(isinstance(msg, Message) for msg in output_event.data) # type: ignore assert len(output_event.data) > 0 # type: ignore assert output_event.data[-1].text is not None # type: ignore assert output_event.data[-1].text == "Workflow terminated due to reaching maximum reset count." # type: ignore @@ -760,7 +767,7 @@ async def test_magentic_checkpoint_runtime_only() -> None: manager = FakeManager(max_round_count=10) wf = MagenticBuilder(participants=[DummyExec("agentA")], manager=manager).build() - baseline_output: ChatMessage | None = None + baseline_output: Message | None = None async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True): if ev.type == "output": baseline_output = ev.data # type: ignore[assignment] @@ -772,7 +779,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" @@ -794,7 +801,7 @@ async def test_magentic_checkpoint_runtime_overrides_buildtime() -> None: participants=[DummyExec("agentA")], checkpoint_storage=buildtime_storage, manager=manager ).build() - baseline_output: ChatMessage | None = None + baseline_output: Message | None = None async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True): if ev.type == "output": baseline_output = ev.data # type: ignore[assignment] @@ -806,8 +813,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" @@ -821,8 +828,8 @@ async def test_magentic_context_no_duplicate_on_reset(): ctx = MagenticContext(task="task", participant_descriptions={"Alice": "Researcher"}) # Add some history - ctx.chat_history.append(ChatMessage("assistant", ["response1"])) - ctx.chat_history.append(ChatMessage("assistant", ["response2"])) + ctx.chat_history.append(Message("assistant", ["response1"])) + ctx.chat_history.append(Message("assistant", ["response2"])) assert len(ctx.chat_history) == 2 # Reset @@ -832,7 +839,7 @@ async def test_magentic_context_no_duplicate_on_reset(): assert len(ctx.chat_history) == 0, "chat_history should be empty after reset" # Add new history - ctx.chat_history.append(ChatMessage("assistant", ["new_response"])) + ctx.chat_history.append(Message("assistant", ["new_response"])) assert len(ctx.chat_history) == 1, "Should have exactly 1 message after adding to reset context" @@ -844,8 +851,8 @@ async def test_magentic_checkpoint_restore_no_duplicate_history(): wf = MagenticBuilder(participants=[DummyExec("agentA")], checkpoint_storage=storage, manager=manager).build() # Run with conversation history to create initial checkpoint - conversation: list[ChatMessage] = [ - ChatMessage("user", ["task_msg"]), + conversation: list[Message] = [ + Message("user", ["task_msg"]), ] async for event in wf.run(conversation, stream=True): @@ -856,13 +863,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 @@ -890,121 +897,6 @@ async def test_magentic_checkpoint_restore_no_duplicate_history(): ) -# endregion - -# region Participant Factory Tests - - -def test_magentic_builder_rejects_empty_participant_factories(): - """Test that MagenticBuilder rejects empty participant_factories list.""" - with pytest.raises(ValueError, match=r"participant_factories cannot be empty"): - MagenticBuilder(participant_factories=[]) - - with pytest.raises( - ValueError, - match=r"Either participants or participant_factories must be provided\.", - ): - MagenticBuilder() - - -def test_magentic_builder_rejects_mixing_participants_and_factories(): - """Test that passing both participants and participant_factories to the constructor raises an error.""" - agent = StubAgent("agentA", "reply from agentA") - - with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"): - MagenticBuilder( - participants=[agent], - participant_factories=[lambda: StubAgent("agentB", "reply")], - ) - - -def test_magentic_builder_rejects_both_factories_and_participants(): - """Test that passing both participant_factories and participants raises an error.""" - with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"): - MagenticBuilder( - participant_factories=[lambda: StubAgent("agentA", "reply from agentA")], - participants=[StubAgent("agentB", "reply from agentB")], - ) - - -def test_magentic_builder_rejects_both_participants_and_factories(): - """Test that passing both participants and participant_factories raises an error.""" - with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"): - MagenticBuilder( - participants=[StubAgent("agentA", "reply from agentA")], - participant_factories=[lambda: StubAgent("agentB", "reply from agentB")], - ) - - -async def test_magentic_with_participant_factories(): - """Test workflow creation using participant_factories.""" - call_count = 0 - - def create_agent() -> StubAgent: - nonlocal call_count - call_count += 1 - return StubAgent("agentA", "reply from agentA") - - manager = FakeManager() - workflow = MagenticBuilder(participant_factories=[create_agent], manager=manager).build() - - # Factory should be called during build - assert call_count == 1 - - outputs: list[WorkflowEvent] = [] - async for event in workflow.run("test task", stream=True): - if event.type == "output": - outputs.append(event) - - assert len(outputs) == 1 - - -async def test_magentic_participant_factories_reusable_builder(): - """Test that the builder can be reused to build multiple workflows with factories.""" - call_count = 0 - - def create_agent() -> StubAgent: - nonlocal call_count - call_count += 1 - return StubAgent("agentA", "reply from agentA") - - builder = MagenticBuilder(participant_factories=[create_agent], manager=FakeManager()) - - # Build first workflow - wf1 = builder.build() - assert call_count == 1 - - # Build second workflow - wf2 = builder.build() - assert call_count == 2 - - # Verify that the two workflows have different agent instances - assert wf1.executors["agentA"] is not wf2.executors["agentA"] - - -async def test_magentic_participant_factories_with_checkpointing(): - """Test checkpointing with participant_factories.""" - storage = InMemoryCheckpointStorage() - - def create_agent() -> StubAgent: - return StubAgent("agentA", "reply from agentA") - - manager = FakeManager() - workflow = MagenticBuilder( - participant_factories=[create_agent], checkpoint_storage=storage, manager=manager - ).build() - - outputs: list[WorkflowEvent] = [] - async for event in workflow.run("checkpoint test", stream=True): - if event.type == "output": - outputs.append(event) - - assert outputs, "Should have workflow output" - - checkpoints = await storage.list_checkpoints() - assert checkpoints, "Checkpoints should be created during workflow execution" - - # endregion # region Manager Factory Tests @@ -1112,66 +1004,6 @@ async def test_magentic_manager_factory_reusable_builder(): assert orchestrator1 is not orchestrator2 -def test_magentic_with_both_participant_and_manager_factories(): - """Test workflow creation using both participant_factories and manager_factory.""" - participant_factory_call_count = 0 - manager_factory_call_count = 0 - - def create_agent() -> StubAgent: - nonlocal participant_factory_call_count - participant_factory_call_count += 1 - return StubAgent("agentA", "reply from agentA") - - def manager_factory() -> MagenticManagerBase: - nonlocal manager_factory_call_count - manager_factory_call_count += 1 - return FakeManager() - - workflow = MagenticBuilder(participant_factories=[create_agent], manager_factory=manager_factory).build() - - # All factories should be called during build - assert participant_factory_call_count == 1 - assert manager_factory_call_count == 1 - - # Verify executor is present in the workflow - assert "agentA" in workflow.executors - - -async def test_magentic_factories_reusable_for_multiple_workflows(): - """Test that both factories are reused correctly for multiple workflow builds.""" - participant_factory_call_count = 0 - manager_factory_call_count = 0 - - def create_agent() -> StubAgent: - nonlocal participant_factory_call_count - participant_factory_call_count += 1 - return StubAgent("agentA", "reply from agentA") - - def manager_factory() -> MagenticManagerBase: - nonlocal manager_factory_call_count - manager_factory_call_count += 1 - return FakeManager() - - builder = MagenticBuilder(participant_factories=[create_agent], manager_factory=manager_factory) - - # Build first workflow - wf1 = builder.build() - assert participant_factory_call_count == 1 - assert manager_factory_call_count == 1 - - # Build second workflow - wf2 = builder.build() - assert participant_factory_call_count == 2 - assert manager_factory_call_count == 2 - - # Verify that the workflows have different agent and orchestrator instances - assert wf1.executors["agentA"] is not wf2.executors["agentA"] - - orchestrator1 = next(e for e in wf1.executors.values() if isinstance(e, MagenticOrchestrator)) - orchestrator2 = next(e for e in wf2.executors.values() if isinstance(e, MagenticOrchestrator)) - assert orchestrator1 is not orchestrator2 - - def test_magentic_agent_factory_with_standard_manager_options(): """Test that agent_factory properly passes through standard manager options.""" factory_call_count = 0 @@ -1197,8 +1029,8 @@ def test_magentic_agent_factory_with_standard_manager_options(): from agent_framework_orchestrations._magentic import _MagenticTaskLedger # type: ignore custom_task_ledger = _MagenticTaskLedger( - facts=ChatMessage("assistant", ["Custom facts"]), - plan=ChatMessage("assistant", ["Custom plan"]), + facts=Message("assistant", ["Custom facts"]), + plan=Message("assistant", ["Custom plan"]), ) participant = StubAgent("agentA", "reply from agentA") diff --git a/python/packages/orchestrations/tests/test_orchestration_request_info.py b/python/packages/orchestrations/tests/test_orchestration_request_info.py index 88fcdf757e..7d0acbc945 100644 --- a/python/packages/orchestrations/tests/test_orchestration_request_info.py +++ b/python/packages/orchestrations/tests/test_orchestration_request_info.py @@ -10,8 +10,8 @@ import pytest from agent_framework import ( AgentResponse, AgentResponseUpdate, - AgentThread, - ChatMessage, + AgentSession, + Message, SupportsAgentRun, ) from agent_framework._workflows._agent_executor import AgentExecutorRequest, AgentExecutorResponse @@ -72,16 +72,16 @@ class TestAgentRequestInfoResponse: def test_create_response_with_messages(self): """Test creating an AgentRequestInfoResponse with messages.""" - messages = [ChatMessage(role="user", text="Additional info")] + messages = [Message(role="user", text="Additional info")] response = AgentRequestInfoResponse(messages=messages) assert response.messages == messages def test_from_messages_factory(self): - """Test creating response from ChatMessage list.""" + """Test creating response from Message list.""" messages = [ - ChatMessage(role="user", text="Message 1"), - ChatMessage(role="user", text="Message 2"), + Message(role="user", text="Message 1"), + Message(role="user", text="Message 2"), ] response = AgentRequestInfoResponse.from_messages(messages) @@ -113,7 +113,7 @@ class TestAgentRequestInfoExecutor: """Test that request_info handler calls ctx.request_info.""" executor = AgentRequestInfoExecutor(id="test_executor") - agent_response = AgentResponse(messages=[ChatMessage(role="assistant", text="Agent response")]) + agent_response = AgentResponse(messages=[Message(role="assistant", text="Agent response")]) agent_response = AgentExecutorResponse( executor_id="test_agent", agent_response=agent_response, @@ -131,7 +131,7 @@ class TestAgentRequestInfoExecutor: """Test response handler when user provides additional messages.""" executor = AgentRequestInfoExecutor(id="test_executor") - agent_response = AgentResponse(messages=[ChatMessage(role="assistant", text="Original")]) + agent_response = AgentResponse(messages=[Message(role="assistant", text="Original")]) original_request = AgentExecutorResponse( executor_id="test_agent", agent_response=agent_response, @@ -157,7 +157,7 @@ class TestAgentRequestInfoExecutor: """Test response handler when user approves (no additional messages).""" executor = AgentRequestInfoExecutor(id="test_executor") - agent_response = AgentResponse(messages=[ChatMessage(role="assistant", text="Original")]) + agent_response = AgentResponse(messages=[Message(role="assistant", text="Original")]) original_request = AgentExecutorResponse( executor_id="test_agent", agent_response=agent_response, @@ -200,23 +200,23 @@ class _TestAgent: async def run( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + thread: AgentSession | None = None, **kwargs: Any, ) -> AgentResponse | AsyncIterable[AgentResponseUpdate]: """Dummy run method.""" if stream: return self._run_stream_impl() - return AgentResponse(messages=[ChatMessage(role="assistant", text="Test response")]) + return AgentResponse(messages=[Message(role="assistant", text="Test response")]) async def _run_stream_impl(self) -> AsyncIterable[AgentResponseUpdate]: - yield AgentResponseUpdate(messages=[ChatMessage(role="assistant", text="Test response stream")]) + yield AgentResponseUpdate(messages=[Message(role="assistant", text="Test response stream")]) - def get_new_thread(self, **kwargs: Any) -> AgentThread: - """Creates a new conversation thread for the agent.""" - return AgentThread(**kwargs) + def create_session(self, **kwargs: Any) -> AgentSession: + """Creates a new conversation session for the agent.""" + return AgentSession(**kwargs) class TestAgentApprovalExecutor: diff --git a/python/packages/orchestrations/tests/test_sequential.py b/python/packages/orchestrations/tests/test_sequential.py index cb6f3b0872..67bcc1bb9e 100644 --- a/python/packages/orchestrations/tests/test_sequential.py +++ b/python/packages/orchestrations/tests/test_sequential.py @@ -8,11 +8,11 @@ from agent_framework import ( AgentExecutorResponse, AgentResponse, AgentResponseUpdate, - AgentThread, + AgentSession, BaseAgent, - ChatMessage, Content, Executor, + Message, TypeCompatibilityError, WorkflowContext, WorkflowRunState, @@ -27,17 +27,17 @@ class _EchoAgent(BaseAgent): def run( # type: ignore[override] self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse] | AsyncIterable[AgentResponseUpdate]: if stream: return self._run_stream() async def _run() -> AgentResponse: - return AgentResponse(messages=[ChatMessage("assistant", [f"{self.name} reply"])]) + return AgentResponse(messages=[Message("assistant", [f"{self.name} reply"])]) return _run() @@ -50,11 +50,11 @@ class _SummarizerExec(Executor): """Custom executor that summarizes by appending a short assistant message.""" @handler - async def summarize(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[list[ChatMessage]]) -> None: + async def summarize(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[list[Message]]) -> None: conversation = agent_response.full_conversation or [] user_texts = [m.text for m in conversation if m.role == "user"] agents = [m.author_name or m.role for m in conversation if m.role == "assistant"] - summary = ChatMessage("assistant", [f"Summary of users:{len(user_texts)} agents:{len(agents)}"]) + summary = Message("assistant", [f"Summary of users:{len(user_texts)} agents:{len(agents)}"]) await ctx.send_message(list(conversation) + [summary]) @@ -62,7 +62,7 @@ class _InvalidExecutor(Executor): """Invalid executor that does not have a handler that accepts a list of chat messages""" @handler - async def summarize(self, conversation: list[str], ctx: WorkflowContext[list[ChatMessage]]) -> None: + async def summarize(self, conversation: list[str], ctx: WorkflowContext[list[Message]]) -> None: pass @@ -71,22 +71,6 @@ def test_sequential_builder_rejects_empty_participants() -> None: SequentialBuilder(participants=[]) -def test_sequential_builder_rejects_empty_participant_factories() -> None: - with pytest.raises(ValueError): - SequentialBuilder(participant_factories=[]) - - -def test_sequential_builder_rejects_mixing_participants_and_factories() -> None: - """Test that passing both participants and participant_factories to the constructor raises an error.""" - a1 = _EchoAgent(id="agent1", name="A1") - - with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"): - SequentialBuilder( - participants=[a1], - participant_factories=[lambda: _EchoAgent(id="agent2", name="A2")], - ) - - def test_sequential_builder_validation_rejects_invalid_executor() -> None: """Test that adding an invalid executor to the builder raises an error.""" with pytest.raises(TypeCompatibilityError): @@ -100,7 +84,7 @@ async def test_sequential_agents_append_to_context() -> None: wf = SequentialBuilder(participants=[a1, a2]).build() completed = False - output: list[ChatMessage] | None = None + output: list[Message] | None = None async for ev in wf.run("hello sequential", stream=True): if ev.type == "status" and ev.state == WorkflowRunState.IDLE: completed = True @@ -112,7 +96,7 @@ async def test_sequential_agents_append_to_context() -> None: assert completed assert output is not None assert isinstance(output, list) - msgs: list[ChatMessage] = output + msgs: list[Message] = output assert len(msgs) == 3 assert msgs[0].role == "user" and "hello sequential" in msgs[0].text assert msgs[1].role == "assistant" and (msgs[1].author_name == "A1" or True) @@ -121,37 +105,6 @@ async def test_sequential_agents_append_to_context() -> None: assert "A2 reply" in msgs[2].text -async def test_sequential_register_participants_with_agent_factories() -> None: - """Test that register_participants works with agent factories.""" - - def create_agent1() -> _EchoAgent: - return _EchoAgent(id="agent1", name="A1") - - def create_agent2() -> _EchoAgent: - return _EchoAgent(id="agent2", name="A2") - - wf = SequentialBuilder(participant_factories=[create_agent1, create_agent2]).build() - - completed = False - output: list[ChatMessage] | None = None - async for ev in wf.run("hello factories", stream=True): - if ev.type == "status" and ev.state == WorkflowRunState.IDLE: - completed = True - elif ev.type == "output": - output = ev.data - if completed and output is not None: - break - - assert completed - assert output is not None - assert isinstance(output, list) - msgs: list[ChatMessage] = output - assert len(msgs) == 3 - assert msgs[0].role == "user" and "hello factories" in msgs[0].text - assert msgs[1].role == "assistant" and "A1 reply" in msgs[1].text - assert msgs[2].role == "assistant" and "A2 reply" in msgs[2].text - - async def test_sequential_with_custom_executor_summary() -> None: a1 = _EchoAgent(id="agent1", name="A1") summarizer = _SummarizerExec(id="summarizer") @@ -159,7 +112,7 @@ async def test_sequential_with_custom_executor_summary() -> None: wf = SequentialBuilder(participants=[a1, summarizer]).build() completed = False - output: list[ChatMessage] | None = None + output: list[Message] | None = None async for ev in wf.run("topic X", stream=True): if ev.type == "status" and ev.state == WorkflowRunState.IDLE: completed = True @@ -170,7 +123,7 @@ async def test_sequential_with_custom_executor_summary() -> None: assert completed assert output is not None - msgs: list[ChatMessage] = output + msgs: list[Message] = output # Expect: [user, A1 reply, summary] assert len(msgs) == 3 assert msgs[0].role == "user" @@ -178,44 +131,13 @@ async def test_sequential_with_custom_executor_summary() -> None: assert msgs[2].role == "assistant" and msgs[2].text.startswith("Summary of users:") -async def test_sequential_register_participants_mixed_agents_and_executors() -> None: - """Test register_participants with both agent and executor factories.""" - - def create_agent() -> _EchoAgent: - return _EchoAgent(id="agent1", name="A1") - - def create_summarizer() -> _SummarizerExec: - return _SummarizerExec(id="summarizer") - - wf = SequentialBuilder(participant_factories=[create_agent, create_summarizer]).build() - - completed = False - output: list[ChatMessage] | None = None - async for ev in wf.run("topic Y", stream=True): - if ev.type == "status" and ev.state == WorkflowRunState.IDLE: - completed = True - elif ev.type == "output": - output = ev.data - if completed and output is not None: - break - - assert completed - assert output is not None - msgs: list[ChatMessage] = output - # Expect: [user, A1 reply, summary] - assert len(msgs) == 3 - assert msgs[0].role == "user" and "topic Y" in msgs[0].text - assert msgs[1].role == "assistant" and "A1 reply" in msgs[1].text - assert msgs[2].role == "assistant" and msgs[2].text.startswith("Summary of users:") - - async def test_sequential_checkpoint_resume_round_trip() -> None: storage = InMemoryCheckpointStorage() initial_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2")) wf = SequentialBuilder(participants=list(initial_agents), checkpoint_storage=storage).build() - baseline_output: list[ChatMessage] | None = None + baseline_output: list[Message] | None = None async for ev in wf.run("checkpoint sequential", stream=True): if ev.type == "output": baseline_output = ev.data # type: ignore[assignment] @@ -224,19 +146,15 @@ 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() - resumed_output: list[ChatMessage] | None = None + resumed_output: list[Message] | None = None async for ev in wf_resume.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True): if ev.type == "output": resumed_output = ev.data # type: ignore[assignment] @@ -258,7 +176,7 @@ async def test_sequential_checkpoint_runtime_only() -> None: agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2")) wf = SequentialBuilder(participants=list(agents)).build() - baseline_output: list[ChatMessage] | None = None + baseline_output: list[Message] | None = None async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True): if ev.type == "output": baseline_output = ev.data # type: ignore[assignment] @@ -267,19 +185,15 @@ 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() - resumed_output: list[ChatMessage] | None = None + resumed_output: list[Message] | None = None async for ev in wf_resume.run( checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage, stream=True ): @@ -309,7 +223,7 @@ async def test_sequential_checkpoint_runtime_overrides_buildtime() -> None: agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2")) wf = SequentialBuilder(participants=list(agents), checkpoint_storage=buildtime_storage).build() - baseline_output: list[ChatMessage] | None = None + baseline_output: list[Message] | None = None async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True): if ev.type == "output": baseline_output = ev.data # type: ignore[assignment] @@ -318,99 +232,13 @@ 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" -async def test_sequential_register_participants_with_checkpointing() -> None: - """Test that checkpointing works with register_participants.""" - storage = InMemoryCheckpointStorage() - - def create_agent1() -> _EchoAgent: - return _EchoAgent(id="agent1", name="A1") - - def create_agent2() -> _EchoAgent: - return _EchoAgent(id="agent2", name="A2") - - wf = SequentialBuilder(participant_factories=[create_agent1, create_agent2], checkpoint_storage=storage).build() - - baseline_output: list[ChatMessage] | None = None - async for ev in wf.run("checkpoint with factories", stream=True): - if ev.type == "output": - baseline_output = ev.data - if ev.type == "status" and ev.state == WorkflowRunState.IDLE: - break - - 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], - ) - - wf_resume = SequentialBuilder( - participant_factories=[create_agent1, create_agent2], checkpoint_storage=storage - ).build() - - resumed_output: list[ChatMessage] | None = None - async for ev in wf_resume.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True): - if ev.type == "output": - resumed_output = ev.data - if ev.type == "status" and ev.state in ( - WorkflowRunState.IDLE, - WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, - ): - break - - assert resumed_output is not None - assert [m.role for m in resumed_output] == [m.role for m in baseline_output] - assert [m.text for m in resumed_output] == [m.text for m in baseline_output] - - -async def test_sequential_register_participants_factories_called_on_build() -> None: - """Test that factories are called during build(), not during register_participants().""" - call_count = 0 - - def create_agent() -> _EchoAgent: - nonlocal call_count - call_count += 1 - return _EchoAgent(id=f"agent{call_count}", name=f"A{call_count}") - - builder = SequentialBuilder(participant_factories=[create_agent, create_agent]) - - # Factories should not be called yet - assert call_count == 0 - - wf = builder.build() - - # Now factories should have been called - assert call_count == 2 - - # Run the workflow to ensure it works - completed = False - output: list[ChatMessage] | None = None - async for ev in wf.run("test factories timing", stream=True): - if ev.type == "status" and ev.state == WorkflowRunState.IDLE: - completed = True - elif ev.type == "output": - output = ev.data # type: ignore[assignment] - if completed and output is not None: - break - - assert completed - assert output is not None - msgs: list[ChatMessage] = output - # Should have user message + 2 agent replies - assert len(msgs) == 3 - - async def test_sequential_builder_reusable_after_build_with_participants() -> None: """Test that the builder can be reused to build multiple identical workflows with participants().""" a1 = _EchoAgent(id="agent1", name="A1") @@ -423,30 +251,3 @@ async def test_sequential_builder_reusable_after_build_with_participants() -> No assert builder._participants[0] is a1 # type: ignore assert builder._participants[1] is a2 # type: ignore - assert builder._participant_factories == [] # type: ignore - - -async def test_sequential_builder_reusable_after_build_with_factories() -> None: - """Test that the builder can be reused to build multiple workflows with register_participants().""" - call_count = 0 - - def create_agent1() -> _EchoAgent: - nonlocal call_count - call_count += 1 - return _EchoAgent(id="agent1", name="A1") - - def create_agent2() -> _EchoAgent: - nonlocal call_count - call_count += 1 - return _EchoAgent(id="agent2", name="A2") - - builder = SequentialBuilder(participant_factories=[create_agent1, create_agent2]) - - # Build first workflow - factories should be called - builder.build() - - assert call_count == 2 - assert builder._participants == [] # type: ignore - assert len(builder._participant_factories) == 2 # type: ignore - assert builder._participant_factories[0] is create_agent1 # type: ignore - assert builder._participant_factories[1] is create_agent2 # type: ignore diff --git a/python/packages/purview/AGENTS.md b/python/packages/purview/AGENTS.md index 3d09982e70..30be4ffbf7 100644 --- a/python/packages/purview/AGENTS.md +++ b/python/packages/purview/AGENTS.md @@ -32,7 +32,7 @@ from agent_framework.microsoft import PurviewPolicyMiddleware, PurviewSettings settings = PurviewSettings(...) middleware = PurviewPolicyMiddleware(settings=settings) -agent = ChatAgent(..., middleware=[middleware]) +agent = Agent(..., middleware=[middleware]) ``` ## Import Path diff --git a/python/packages/purview/README.md b/python/packages/purview/README.md index b016f00c8b..f23da59457 100644 --- a/python/packages/purview/README.md +++ b/python/packages/purview/README.md @@ -8,7 +8,7 @@ - Middleware-based policy enforcement (agent-level and chat-client level) - Blocks or allows content at both ingress (prompt) and egress (response) -- Works with any `ChatAgent` / agent orchestration using the standard Agent Framework middleware pipeline +- Works with any `Agent` / agent orchestration using the standard Agent Framework middleware pipeline - Supports both synchronous `TokenCredential` and `AsyncTokenCredential` from `azure-identity` - Configuration via `PurviewSettings` / `PurviewAppLocation` - Built-in caching with configurable TTL and size limits for protection scopes in `PurviewSettings` @@ -53,26 +53,26 @@ Add Purview when you need to: ```python import asyncio -from agent_framework import ChatAgent, ChatMessage, Role +from agent_framework import Agent, Message, Role from agent_framework.azure import AzureOpenAIChatClient from agent_framework.microsoft import PurviewPolicyMiddleware, PurviewSettings from azure.identity import InteractiveBrowserCredential async def main(): - chat_client = AzureOpenAIChatClient() # uses environment for endpoint + deployment + client = AzureOpenAIChatClient() # uses environment for endpoint + deployment purview_middleware = PurviewPolicyMiddleware( credential=InteractiveBrowserCredential(), settings=PurviewSettings(app_name="My Sample App") ) - agent = ChatAgent( - chat_client=chat_client, + agent = Agent( + client=client, instructions="You are a helpful assistant.", middleware=[purview_middleware] ) - response = await agent.run(ChatMessage("user", ["Summarize zero trust in one sentence."])) + response = await agent.run(Message("user", ["Summarize zero trust in one sentence."])) print(response) asyncio.run(main()) @@ -218,7 +218,7 @@ settings = PurviewSettings( Use the agent middleware when you already have / want the full agent pipeline: ```python -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.azure import AzureOpenAIChatClient from agent_framework.microsoft import PurviewPolicyMiddleware, PurviewSettings from azure.identity import DefaultAzureCredential @@ -226,8 +226,8 @@ from azure.identity import DefaultAzureCredential credential = DefaultAzureCredential() client = AzureOpenAIChatClient() -agent = ChatAgent( - chat_client=client, +agent = Agent( + client=client, instructions="You are helpful.", middleware=[PurviewPolicyMiddleware(credential, PurviewSettings(app_name="My App"))] ) @@ -237,14 +237,14 @@ Use the chat middleware when you attach directly to a chat client (e.g. minimal ```python import os -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.azure import AzureOpenAIChatClient from agent_framework.microsoft import PurviewChatPolicyMiddleware, PurviewSettings from azure.identity import DefaultAzureCredential credential = DefaultAzureCredential() -chat_client = AzureOpenAIChatClient( +client = AzureOpenAIChatClient( deployment_name=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], credential=credential, @@ -253,7 +253,7 @@ chat_client = AzureOpenAIChatClient( ], ) -agent = ChatAgent(chat_client=chat_client, instructions="You are helpful.") +agent = Agent(client=client, instructions="You are helpful.") ``` The policy logic is identical; the difference is only the hook point in the pipeline. @@ -272,7 +272,7 @@ The policy logic is identical; the difference is only the hook point in the pipe 3. **After successful agent execution** (`response phase`): the produced messages are evaluated using the same user_id from the prompt phase. 4. **If blocked**: result messages are replaced with a blocking notice. -The user identifier is discovered from `ChatMessage.additional_properties['user_id']` during the prompt phase and reused for the response phase, ensuring both evaluations map consistently to the same user. If no user_id is present, policy evaluation is skipped entirely. +The user identifier is discovered from `Message.additional_properties['user_id']` during the prompt phase and reused for the response phase, ensuring both evaluations map consistently to the same user. If no user_id is present, policy evaluation is skipped entirely. You can customize the blocking messages using the `blocked_prompt_message` and `blocked_response_message` fields in `PurviewSettings`. For more advanced scenarios, you can wrap the middleware or post-process `context.result` in later middleware. @@ -315,7 +315,7 @@ except (PurviewAuthenticationError, PurviewRateLimitError, PurviewRequestError, --- ## Notes -- **User Identification**: Provide a `user_id` per request (e.g. in `ChatMessage(..., additional_properties={"user_id": ""})`) for per-user policy scoping. If no user_id is provided, policy evaluation is skipped entirely. +- **User Identification**: Provide a `user_id` per request (e.g. in `Message(..., additional_properties={"user_id": ""})`) for per-user policy scoping. If no user_id is provided, policy evaluation is skipped entirely. - **Blocking Messages**: Can be customized via `blocked_prompt_message` and `blocked_response_message` in `PurviewSettings`. By default, they are "Prompt blocked by policy" and "Response blocked by policy" respectively. - **Streaming Responses**: Post-response policy evaluation presently applies only to non-streaming chat responses. - **Error Handling**: Use `ignore_exceptions` and `ignore_payment_required` settings for graceful degradation. When enabled, errors are logged but don't fail the request. 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 42f8b37df6..f0165704d8 100644 --- a/python/packages/purview/agent_framework_purview/_middleware.py +++ b/python/packages/purview/agent_framework_purview/_middleware.py @@ -26,13 +26,11 @@ class PurviewPolicyMiddleware(AgentMiddleware): .. code-block:: python from agent_framework.microsoft import PurviewPolicyMiddleware, PurviewSettings - from agent_framework import ChatAgent + from agent_framework import Agent credential = ... # TokenCredential or AsyncTokenCredential settings = PurviewSettings(app_name="My App") - agent = ChatAgent( - chat_client=client, instructions="...", middleware=[PurviewPolicyMiddleware(credential, settings)] - ) + agent = Agent(client=client, instructions="...", middleware=[PurviewPolicyMiddleware(credential, settings)]) """ def __init__( @@ -45,62 +43,95 @@ class PurviewPolicyMiddleware(AgentMiddleware): self._processor = ScopedContentProcessor(self._client, settings, cache_provider) self._settings = settings + @staticmethod + def _get_agent_session_id(context: AgentContext) -> str | None: + """Resolve a session/conversation id from the agent run context. + + Resolution order: + 1. session.service_session_id + 2. First message whose additional_properties contains 'conversation_id' + 3. None: the downstream processor will generate a new UUID + """ + if context.session and context.session.service_session_id: + return context.session.service_session_id + + for message in context.messages: + conversation_id = message.additional_properties.get("conversation_id") + if conversation_id is not None: + return str(conversation_id) + + return None + async def process( self, context: AgentContext, - call_next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: # type: ignore[override] resolved_user_id: str | None = None try: # Pre (prompt) check + session_id = self._get_agent_session_id(context) should_block_prompt, resolved_user_id = await self._processor.process_messages( - context.messages, Activity.UPLOAD_TEXT + context.messages, Activity.UPLOAD_TEXT, session_id=session_id ) if should_block_prompt: - from agent_framework import AgentResponse, ChatMessage + from agent_framework import AgentResponse, Message context.result = AgentResponse( - messages=[ChatMessage(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(context) + await call_next() try: # Post (response) check only if we have a normal AgentResponse # Use the same user_id from the request for the response evaluation + session_id_response = self._get_agent_session_id(context) + if session_id_response is None: + session_id_response = session_id if context.result and not context.stream: should_block_response, _ = await self._processor.process_messages( context.result.messages, # type: ignore[union-attr] Activity.UPLOAD_TEXT, + session_id=session_id, user_id=resolved_user_id, ) if should_block_response: - from agent_framework import AgentResponse, ChatMessage + from agent_framework import AgentResponse, Message context.result = AgentResponse( - messages=[ChatMessage(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 @@ -140,54 +171,63 @@ class PurviewChatPolicyMiddleware(ChatMiddleware): async def process( self, context: ChatContext, - call_next: Callable[[ChatContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: # type: ignore[override] resolved_user_id: str | None = None try: + session_id = context.options.get("conversation_id") if context.options else None should_block_prompt, resolved_user_id = await self._processor.process_messages( - context.messages, Activity.UPLOAD_TEXT + context.messages, Activity.UPLOAD_TEXT, session_id=session_id ) if should_block_prompt: - from agent_framework import ChatMessage, ChatResponse + from agent_framework import ChatResponse, Message - blocked_message = ChatMessage(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(context) + await call_next() try: # Post (response) evaluation only if non-streaming and we have messages result shape # Use the same user_id from the request for the response evaluation + session_id_response = context.options.get("conversation_id") if context.options else None + if session_id_response is None: + session_id_response = session_id if context.result and not context.stream: result_obj = context.result messages = getattr(result_obj, "messages", None) if messages: should_block_response, _ = await self._processor.process_messages( - messages, Activity.UPLOAD_TEXT, user_id=resolved_user_id + messages, Activity.UPLOAD_TEXT, session_id=session_id_response, user_id=resolved_user_id ) if should_block_response: - from agent_framework import ChatMessage, ChatResponse + from agent_framework import ChatResponse, Message - blocked_message = ChatMessage(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/_models.py b/python/packages/purview/agent_framework_purview/_models.py index 4e14147ac5..0e4985689e 100644 --- a/python/packages/purview/agent_framework_purview/_models.py +++ b/python/packages/purview/agent_framework_purview/_models.py @@ -177,7 +177,7 @@ def translate_activity(activity: Activity) -> ProtectionScopeActivities: # Simple value models # -------------------------------------------------------------------------------------- -TAliasSerializable = TypeVar("TAliasSerializable", bound="_AliasSerializable") +AliasSerializableT = TypeVar("AliasSerializableT", bound="_AliasSerializable") class _AliasSerializable(SerializationMixin): @@ -232,7 +232,7 @@ class _AliasSerializable(SerializationMixin): return json.dumps(self.model_dump(by_alias=by_alias, exclude_none=exclude_none, **kwargs)) @classmethod - def model_validate(cls: type[TAliasSerializable], value: MutableMapping[str, Any]) -> TAliasSerializable: # type: ignore[name-defined] + def model_validate(cls: type[AliasSerializableT], value: MutableMapping[str, Any]) -> AliasSerializableT: # type: ignore[name-defined] return cls(**value) # ------------------------------------------------------------------ diff --git a/python/packages/purview/agent_framework_purview/_processor.py b/python/packages/purview/agent_framework_purview/_processor.py index fb115783f5..bc8cd045c0 100644 --- a/python/packages/purview/agent_framework_purview/_processor.py +++ b/python/packages/purview/agent_framework_purview/_processor.py @@ -1,11 +1,12 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import time import uuid from collections.abc import Iterable, MutableMapping from typing import Any -from agent_framework import ChatMessage +from agent_framework import Message from agent_framework._logging import get_logger from ._cache import CacheProvider, InMemoryCacheProvider, create_protection_scopes_cache_key @@ -56,19 +57,27 @@ 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() async def process_messages( - self, messages: Iterable[ChatMessage], activity: Activity, user_id: str | None = None + self, + messages: Iterable[Message], + activity: Activity, + session_id: str | None = None, + user_id: str | None = None, ) -> tuple[bool, str | None]: """Process messages for policy evaluation. Args: messages: The messages to process activity: The activity type (e.g., UPLOAD_TEXT) + session_id: Optional session/conversation id. Else, a new GUID is generated. user_id: Optional user_id to use for all messages. If provided, this is the fallback. Returns: @@ -76,7 +85,7 @@ class ScopedContentProcessor: The resolved_user_id can be stored and passed back when processing the response to ensure the same user context is maintained throughout the request/response cycle. """ - pc_requests, resolved_user_id = await self._map_messages(messages, activity, user_id) + pc_requests, resolved_user_id = await self._map_messages(messages, activity, session_id, user_id) should_block = False for req in pc_requests: resp = await self._process_with_scopes(req) @@ -90,13 +99,18 @@ class ScopedContentProcessor: return should_block, resolved_user_id async def _map_messages( - self, messages: Iterable[ChatMessage], activity: Activity, provided_user_id: str | None = None + self, + messages: Iterable[Message], + activity: Activity, + session_id: str | None = None, + provided_user_id: str | None = None, ) -> tuple[list[ProcessContentRequest], str | None]: """Map messages to ProcessContentRequests. Args: messages: The messages to map activity: The activity type + session_id: Optional session/conversation id to use for correlation provided_user_id: Optional user_id to use. If provided, this is the fallback. Returns: @@ -105,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") @@ -137,19 +151,22 @@ class ScopedContentProcessor: for m in messages: message_id = m.message_id or str(uuid.uuid4()) content = PurviewTextContent(data=m.text or "") + correlation_id = (session_id or str(uuid.uuid4())) + "@AF" meta = ProcessConversationMetadata( identifier=message_id, content=content, name=f"Agent Framework Message {message_id}", is_truncated=False, - correlation_id=str(uuid.uuid4()), + correlation_id=correlation_id, + sequence_number=time.time_ns(), ) 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( @@ -160,11 +177,13 @@ class ScopedContentProcessor: raise ValueError("App location not provided or inferable") protected_app = ProtectedAppMetadata( - name=self._settings.app_name, - version="1.0", + 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="1.0") + 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" @@ -215,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 529b1399aa..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,7 +38,7 @@ class PurviewAppLocation(BaseModel): return {"@odata.type": dt, "value": self.location_value} -class PurviewSettings(AFBaseSettings): +class PurviewSettings(TypedDict, total=False): """Settings for Purview integration mirroring .NET PurviewSettings. Attributes: @@ -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/pyproject.toml b/python/packages/purview/pyproject.toml index a1e6456cf3..ae5817f641 100644 --- a/python/packages/purview/pyproject.toml +++ b/python/packages/purview/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://github.com/microsoft/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -24,7 +24,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "azure-core>=1.30.0", "httpx>=0.27.0", ] diff --git a/python/packages/purview/tests/conftest.py b/python/packages/purview/tests/purview/conftest.py similarity index 100% rename from python/packages/purview/tests/conftest.py rename to python/packages/purview/tests/purview/conftest.py diff --git a/python/packages/purview/tests/test_cache.py b/python/packages/purview/tests/purview/test_cache.py similarity index 100% rename from python/packages/purview/tests/test_cache.py rename to python/packages/purview/tests/purview/test_cache.py diff --git a/python/packages/purview/tests/test_chat_middleware.py b/python/packages/purview/tests/purview/test_chat_middleware.py similarity index 66% rename from python/packages/purview/tests/test_chat_middleware.py rename to python/packages/purview/tests/purview/test_chat_middleware.py index d42c5a85a9..e740cdabc6 100644 --- a/python/packages/purview/tests/test_chat_middleware.py +++ b/python/packages/purview/tests/purview/test_chat_middleware.py @@ -5,10 +5,11 @@ from dataclasses import dataclass from unittest.mock import AsyncMock, MagicMock, patch import pytest -from agent_framework import ChatContext, ChatMessage, MiddlewareTermination +from agent_framework import ChatContext, Message, MiddlewareTermination from azure.core.credentials import AccessToken from agent_framework_purview import PurviewChatPolicyMiddleware, PurviewSettings +from agent_framework_purview._models import Activity @dataclass @@ -33,12 +34,10 @@ class TestPurviewChatPolicyMiddleware: @pytest.fixture def chat_context(self) -> ChatContext: - chat_client = DummyChatClient() + client = DummyChatClient() chat_options = MagicMock() chat_options.model = "test-model" - return ChatContext( - chat_client=chat_client, messages=[ChatMessage(role="user", text="Hello")], options=chat_options - ) + return ChatContext(client=client, messages=[Message(role="user", text="Hello")], options=chat_options) async def test_initialization(self, middleware: PurviewChatPolicyMiddleware) -> None: assert middleware._client is not None @@ -50,15 +49,15 @@ class TestPurviewChatPolicyMiddleware: with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: next_called = False - async def mock_next(ctx: ChatContext) -> None: + async def mock_next() -> None: nonlocal next_called next_called = True class Result: def __init__(self): - self.messages = [ChatMessage(role="assistant", text="Hi there")] + self.messages = [Message(role="assistant", text="Hi there")] - ctx.result = Result() + chat_context.result = Result() await middleware.process(chat_context, mock_next) assert next_called @@ -68,7 +67,7 @@ class TestPurviewChatPolicyMiddleware: async def test_blocks_prompt(self, middleware: PurviewChatPolicyMiddleware, chat_context: ChatContext) -> None: with patch.object(middleware._processor, "process_messages", return_value=(True, "user-123")): - async def mock_next(ctx: ChatContext) -> None: # should not run + async def mock_next() -> None: # should not run raise AssertionError("next should not be called when prompt blocked") with pytest.raises(MiddlewareTermination): @@ -82,19 +81,19 @@ class TestPurviewChatPolicyMiddleware: async def test_blocks_response(self, middleware: PurviewChatPolicyMiddleware, chat_context: ChatContext) -> None: call_state = {"count": 0} - async def side_effect(messages, activity, user_id=None): + async def side_effect(messages, activity, session_id=None, user_id=None): call_state["count"] += 1 should_block = call_state["count"] == 2 return (should_block, "user-123") with patch.object(middleware._processor, "process_messages", side_effect=side_effect): - async def mock_next(ctx: ChatContext) -> None: + async def mock_next() -> None: class Result: def __init__(self): - self.messages = [ChatMessage(role="assistant", text="Sensitive output")] # pragma: no cover + self.messages = [Message(role="assistant", text="Sensitive output")] # pragma: no cover - ctx.result = Result() + chat_context.result = Result() await middleware.process(chat_context, mock_next) assert call_state["count"] == 2 @@ -104,19 +103,19 @@ class TestPurviewChatPolicyMiddleware: assert "blocked" in first_msg.text.lower() async def test_streaming_skips_post_check(self, middleware: PurviewChatPolicyMiddleware) -> None: - chat_client = DummyChatClient() + client = DummyChatClient() chat_options = MagicMock() chat_options.model = "test-model" streaming_context = ChatContext( - chat_client=chat_client, - messages=[ChatMessage(role="user", text="Hello")], + client=client, + messages=[Message(role="user", text="Hello")], options=chat_options, stream=True, ) with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: - async def mock_next(ctx: ChatContext) -> None: - ctx.result = MagicMock() + async def mock_next() -> None: + streaming_context.result = MagicMock() await middleware.process(streaming_context, mock_next) assert mock_proc.call_count == 1 @@ -126,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 @@ -139,10 +138,10 @@ class TestPurviewChatPolicyMiddleware: with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages): - async def mock_next(ctx: ChatContext) -> None: + async def mock_next() -> None: result = MagicMock() - result.messages = [ChatMessage(role="assistant", text="Response")] - ctx.result = result + result.messages = [Message(role="assistant", text="Response")] + chat_context.result = result await middleware.process(chat_context, mock_next) @@ -157,16 +156,16 @@ class TestPurviewChatPolicyMiddleware: """Test that the same user_id from pre-check is used in post-check.""" captured_user_ids = [] - async def mock_process_messages(messages, activity, user_id=None): + async def mock_process_messages(messages, activity, session_id=None, user_id=None): captured_user_ids.append(user_id) return (False, "resolved-user-123") with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages): - async def mock_next(ctx: ChatContext) -> None: + async def mock_next() -> None: result = MagicMock() - result.messages = [ChatMessage(role="assistant", text="Response")] - ctx.result = result + result.messages = [Message(role="assistant", text="Response")] + chat_context.result = result await middleware.process(chat_context, mock_next) @@ -185,19 +184,17 @@ class TestPurviewChatPolicyMiddleware: settings = PurviewSettings(app_name="Test App", ignore_payment_required=False) middleware = PurviewChatPolicyMiddleware(mock_credential, settings) - chat_client = DummyChatClient() + client = DummyChatClient() chat_options = MagicMock() chat_options.model = "test-model" - context = ChatContext( - chat_client=chat_client, messages=[ChatMessage(role="user", text="Hello")], options=chat_options - ) + context = ChatContext(client=client, messages=[Message(role="user", text="Hello")], options=chat_options) async def mock_process_messages(*args, **kwargs): raise PurviewPaymentRequiredError("Payment required") with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages): - async def mock_next(ctx: ChatContext) -> None: + async def mock_next() -> None: raise AssertionError("next should not be called") # Should raise the exception @@ -211,12 +208,10 @@ class TestPurviewChatPolicyMiddleware: settings = PurviewSettings(app_name="Test App", ignore_payment_required=False) middleware = PurviewChatPolicyMiddleware(mock_credential, settings) - chat_client = DummyChatClient() + client = DummyChatClient() chat_options = MagicMock() chat_options.model = "test-model" - context = ChatContext( - chat_client=chat_client, messages=[ChatMessage(role="user", text="Hello")], options=chat_options - ) + context = ChatContext(client=client, messages=[Message(role="user", text="Hello")], options=chat_options) call_count = 0 @@ -229,10 +224,10 @@ class TestPurviewChatPolicyMiddleware: with patch.object(middleware._processor, "process_messages", side_effect=side_effect): - async def mock_next(ctx: ChatContext) -> None: + async def mock_next() -> None: result = MagicMock() - result.messages = [ChatMessage(role="assistant", text="OK")] - ctx.result = result + result.messages = [Message(role="assistant", text="OK")] + context.result = result with pytest.raises(PurviewPaymentRequiredError): await middleware.process(context, mock_next) @@ -244,21 +239,19 @@ class TestPurviewChatPolicyMiddleware: settings = PurviewSettings(app_name="Test App", ignore_payment_required=True) middleware = PurviewChatPolicyMiddleware(mock_credential, settings) - chat_client = DummyChatClient() + client = DummyChatClient() chat_options = MagicMock() chat_options.model = "test-model" - context = ChatContext( - chat_client=chat_client, messages=[ChatMessage(role="user", text="Hello")], options=chat_options - ) + context = ChatContext(client=client, messages=[Message(role="user", text="Hello")], options=chat_options) async def mock_process_messages(*args, **kwargs): raise PurviewPaymentRequiredError("Payment required") with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages): - async def mock_next(ctx: ChatContext) -> None: + async def mock_next() -> None: result = MagicMock() - result.messages = [ChatMessage(role="assistant", text="Response")] + result.messages = [Message(role="assistant", text="Response")] context.result = result # Should not raise, just log @@ -272,9 +265,9 @@ class TestPurviewChatPolicyMiddleware: """Test middleware handles result that doesn't have messages attribute.""" with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")): - async def mock_next(ctx: ChatContext) -> None: + async def mock_next() -> None: # Set result to something without messages attribute - ctx.result = "Some string result" + chat_context.result = "Some string result" await middleware.process(chat_context, mock_next) @@ -286,21 +279,19 @@ class TestPurviewChatPolicyMiddleware: settings = PurviewSettings(app_name="Test App", ignore_exceptions=True) middleware = PurviewChatPolicyMiddleware(mock_credential, settings) - chat_client = DummyChatClient() + client = DummyChatClient() chat_options = MagicMock() chat_options.model = "test-model" - context = ChatContext( - chat_client=chat_client, messages=[ChatMessage(role="user", text="Hello")], options=chat_options - ) + context = ChatContext(client=client, messages=[Message(role="user", text="Hello")], options=chat_options) async def mock_process_messages(*args, **kwargs): raise ValueError("Some error") with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages): - async def mock_next(ctx: ChatContext) -> None: + async def mock_next() -> None: result = MagicMock() - result.messages = [ChatMessage(role="assistant", text="Response")] + result.messages = [Message(role="assistant", text="Response")] context.result = result # Should not raise, just log @@ -315,16 +306,14 @@ class TestPurviewChatPolicyMiddleware: settings = PurviewSettings(app_name="Test App", ignore_exceptions=False) middleware = PurviewChatPolicyMiddleware(mock_credential, settings) - chat_client = DummyChatClient() + client = DummyChatClient() chat_options = MagicMock() chat_options.model = "test-model" - context = ChatContext( - chat_client=chat_client, messages=[ChatMessage(role="user", text="Hello")], options=chat_options - ) + context = ChatContext(client=client, messages=[Message(role="user", text="Hello")], options=chat_options) with patch.object(middleware._processor, "process_messages", side_effect=ValueError("boom")): - async def mock_next(_: ChatContext) -> None: + async def mock_next() -> None: raise AssertionError("next should not be called") with pytest.raises(ValueError, match="boom"): @@ -337,12 +326,10 @@ class TestPurviewChatPolicyMiddleware: settings = PurviewSettings(app_name="Test App", ignore_exceptions=False) middleware = PurviewChatPolicyMiddleware(mock_credential, settings) - chat_client = DummyChatClient() + client = DummyChatClient() chat_options = MagicMock() chat_options.model = "test-model" - context = ChatContext( - chat_client=chat_client, messages=[ChatMessage(role="user", text="Hello")], options=chat_options - ) + context = ChatContext(client=client, messages=[Message(role="user", text="Hello")], options=chat_options) call_count = 0 @@ -355,10 +342,74 @@ class TestPurviewChatPolicyMiddleware: with patch.object(middleware._processor, "process_messages", side_effect=side_effect): - async def mock_next(ctx: ChatContext) -> None: + async def mock_next() -> None: result = MagicMock() - result.messages = [ChatMessage(role="assistant", text="OK")] - ctx.result = result + result.messages = [Message(role="assistant", text="OK")] + context.result = result with pytest.raises(ValueError, match="post"): await middleware.process(context, mock_next) + + async def test_chat_middleware_uses_conversation_id_from_options( + self, middleware: PurviewChatPolicyMiddleware + ) -> None: + """Test that session_id is extracted from context.options['conversation_id'].""" + chat_client = DummyChatClient() + messages = [Message(role="user", text="Hello")] + options = {"conversation_id": "conv-123", "model": "test-model"} + context = ChatContext(client=chat_client, messages=messages, options=options) + + with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: + + async def mock_next() -> None: + result = MagicMock() + result.messages = [Message(role="assistant", text="Hi")] + context.result = result + + await middleware.process(context, mock_next) + + # Verify session_id is passed to both pre-check and post-check + assert mock_proc.call_count == 2 + mock_proc.assert_any_call(messages, Activity.UPLOAD_TEXT, session_id="conv-123") + + async def test_chat_middleware_passes_none_session_id_when_options_missing( + self, middleware: PurviewChatPolicyMiddleware + ) -> None: + """Test that session_id is None when options don't contain conversation_id.""" + chat_client = DummyChatClient() + messages = [Message(role="user", text="Hello")] + context = ChatContext(client=chat_client, messages=messages, options=None) + + with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: + + async def mock_next() -> None: + result = MagicMock() + result.messages = [Message(role="assistant", text="Hi")] + context.result = result + + await middleware.process(context, mock_next) + + # Verify session_id=None is passed + mock_proc.assert_any_call(messages, Activity.UPLOAD_TEXT, session_id=None) + + async def test_chat_middleware_session_id_used_in_post_check(self, middleware: PurviewChatPolicyMiddleware) -> None: + """Test that session_id is passed to post-check process_messages call.""" + chat_client = DummyChatClient() + messages = [Message(role="user", text="Hello")] + options = {"conversation_id": "conv-999"} + context = ChatContext(client=chat_client, messages=messages, options=options) + + with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: + + async def mock_next() -> None: + result = MagicMock() + result.messages = [Message(role="assistant", text="Response")] + context.result = result + + await middleware.process(context, mock_next) + + # Verify both calls include session_id + assert mock_proc.call_count == 2 + # Check post-check call includes session_id + post_check_call = mock_proc.call_args_list[1] + assert post_check_call[1]["session_id"] == "conv-999" diff --git a/python/packages/purview/tests/test_exceptions.py b/python/packages/purview/tests/purview/test_exceptions.py similarity index 100% rename from python/packages/purview/tests/test_exceptions.py rename to python/packages/purview/tests/purview/test_exceptions.py diff --git a/python/packages/purview/tests/test_middleware.py b/python/packages/purview/tests/purview/test_middleware.py similarity index 59% rename from python/packages/purview/tests/test_middleware.py rename to python/packages/purview/tests/purview/test_middleware.py index b0aadd8cd5..3a34c48344 100644 --- a/python/packages/purview/tests/test_middleware.py +++ b/python/packages/purview/tests/purview/test_middleware.py @@ -5,10 +5,11 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from agent_framework import AgentContext, AgentResponse, ChatMessage, MiddlewareTermination +from agent_framework import AgentContext, AgentResponse, AgentSession, Message, MiddlewareTermination from azure.core.credentials import AccessToken from agent_framework_purview import PurviewPolicyMiddleware, PurviewSettings +from agent_framework_purview._models import Activity class TestPurviewPolicyMiddleware: @@ -49,15 +50,15 @@ class TestPurviewPolicyMiddleware: self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock ) -> None: """Test middleware allows prompt that passes policy check.""" - context = AgentContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Hello, how are you?")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello, how are you?")]) with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")): next_called = False - async def mock_next(ctx: AgentContext) -> None: + async def mock_next() -> None: nonlocal next_called next_called = True - ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="I'm good, thanks!")]) + context.result = AgentResponse(messages=[Message(role="assistant", text="I'm good, thanks!")]) await middleware.process(context, mock_next) @@ -68,12 +69,12 @@ class TestPurviewPolicyMiddleware: self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock ) -> None: """Test middleware blocks prompt that violates policy.""" - context = AgentContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Sensitive information")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Sensitive information")]) with patch.object(middleware._processor, "process_messages", return_value=(True, "user-123")): next_called = False - async def mock_next(ctx: AgentContext) -> None: + async def mock_next() -> None: nonlocal next_called next_called = True @@ -88,11 +89,11 @@ class TestPurviewPolicyMiddleware: async def test_middleware_checks_response(self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock) -> None: """Test middleware checks agent response for policy violations.""" - context = AgentContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Hello")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")]) call_count = 0 - async def mock_process_messages(messages, activity, user_id=None): + async def mock_process_messages(messages, activity, session_id=None, user_id=None): nonlocal call_count call_count += 1 should_block = call_count != 1 @@ -100,9 +101,9 @@ class TestPurviewPolicyMiddleware: with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages): - async def mock_next(ctx: AgentContext) -> None: - ctx.result = AgentResponse( - messages=[ChatMessage(role="assistant", text="Here's some sensitive information")] + async def mock_next() -> None: + context.result = AgentResponse( + messages=[Message(role="assistant", text="Here's some sensitive information")] ) await middleware.process(context, mock_next) @@ -118,14 +119,14 @@ 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=[ChatMessage(role="user", text="Hello")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")]) with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")): - async def mock_next(ctx: AgentContext) -> None: - ctx.result = "Some non-standard result" + async def mock_next() -> None: + context.result = "Some non-standard result" await middleware.process(context, mock_next) @@ -137,12 +138,12 @@ class TestPurviewPolicyMiddleware: """Test middleware passes correct activity type to processor.""" from agent_framework_purview._models import Activity - context = AgentContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Test")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Test")]) with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_process: - async def mock_next(ctx: AgentContext) -> None: - ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="Response")]) + async def mock_next() -> None: + context.result = AgentResponse(messages=[Message(role="assistant", text="Response")]) await middleware.process(context, mock_next) @@ -154,13 +155,13 @@ class TestPurviewPolicyMiddleware: self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock ) -> None: """Test that streaming results skip post-check evaluation.""" - context = AgentContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Hello")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")]) context.stream = True with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: - async def mock_next(ctx: AgentContext) -> None: - ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="streaming")]) + async def mock_next() -> None: + context.result = AgentResponse(messages=[Message(role="assistant", text="streaming")]) await middleware.process(context, mock_next) @@ -172,7 +173,7 @@ class TestPurviewPolicyMiddleware: """Test that 402 in pre-check is raised when ignore_payment_required=False.""" from agent_framework_purview._exceptions import PurviewPaymentRequiredError - context = AgentContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Hello")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")]) with patch.object( middleware._processor, @@ -180,7 +181,7 @@ class TestPurviewPolicyMiddleware: side_effect=PurviewPaymentRequiredError("Payment required"), ): - async def mock_next(_: AgentContext) -> None: + async def mock_next() -> None: raise AssertionError("next should not be called") with pytest.raises(PurviewPaymentRequiredError): @@ -192,7 +193,7 @@ class TestPurviewPolicyMiddleware: """Test that 402 in post-check is raised when ignore_payment_required=False.""" from agent_framework_purview._exceptions import PurviewPaymentRequiredError - context = AgentContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Hello")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")]) call_count = 0 @@ -205,8 +206,8 @@ class TestPurviewPolicyMiddleware: with patch.object(middleware._processor, "process_messages", side_effect=side_effect): - async def mock_next(ctx: AgentContext) -> None: - ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="OK")]) + async def mock_next() -> None: + context.result = AgentResponse(messages=[Message(role="assistant", text="OK")]) with pytest.raises(PurviewPaymentRequiredError): await middleware.process(context, mock_next) @@ -215,9 +216,9 @@ 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=[ChatMessage(role="user", text="Hello")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")]) call_count = 0 @@ -230,8 +231,8 @@ class TestPurviewPolicyMiddleware: with patch.object(middleware._processor, "process_messages", side_effect=side_effect): - async def mock_next(ctx: AgentContext) -> None: - ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="OK")]) + async def mock_next() -> None: + context.result = AgentResponse(messages=[Message(role="assistant", text="OK")]) with pytest.raises(ValueError, match="Post-check blew up"): await middleware.process(context, mock_next) @@ -241,16 +242,16 @@ 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=[ChatMessage(role="user", text="Test")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Test")]) with patch.object( middleware._processor, "process_messages", side_effect=Exception("Pre-check error") ) as mock_process: - async def mock_next(ctx: AgentContext) -> None: - ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="Response")]) + async def mock_next() -> None: + context.result = AgentResponse(messages=[Message(role="assistant", text="Response")]) await middleware.process(context, mock_next) @@ -264,9 +265,9 @@ 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=[ChatMessage(role="user", text="Test")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Test")]) call_count = 0 @@ -279,8 +280,8 @@ class TestPurviewPolicyMiddleware: with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages): - async def mock_next(ctx: AgentContext) -> None: - ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="Response")]) + async def mock_next() -> None: + context.result = AgentResponse(messages=[Message(role="assistant", text="Response")]) await middleware.process(context, mock_next) @@ -297,7 +298,7 @@ class TestPurviewPolicyMiddleware: mock_agent = MagicMock() mock_agent.name = "test-agent" - context = AgentContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Test")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Test")]) # Mock processor to raise an exception async def mock_process_messages(*args, **kwargs): @@ -305,8 +306,8 @@ class TestPurviewPolicyMiddleware: with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages): - async def mock_next(ctx): - ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="Response")]) + async def mock_next(): + context.result = AgentResponse(messages=[Message(role="assistant", text="Response")]) # Should not raise, just log await middleware.process(context, mock_next) @@ -321,7 +322,7 @@ class TestPurviewPolicyMiddleware: mock_agent = MagicMock() mock_agent.name = "test-agent" - context = AgentContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Test")]) + context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Test")]) # Mock processor to raise an exception async def mock_process_messages(*args, **kwargs): @@ -329,9 +330,99 @@ class TestPurviewPolicyMiddleware: with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages): - async def mock_next(ctx): + async def mock_next(): pass # Should raise the exception with pytest.raises(ValueError, match="Test error"): await middleware.process(context, mock_next) + + async def test_middleware_uses_session_service_session_id_as_session_id( + self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock + ) -> None: + """Test that session_id is extracted from session.service_session_id.""" + session = AgentSession(service_session_id="thread-123") + context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")], session=session) + + with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: + + async def mock_next() -> None: + context.result = AgentResponse(messages=[Message(role="assistant", text="Hi")]) + + await middleware.process(context, mock_next) + + # Verify session_id is passed to both pre-check and post-check + assert mock_proc.call_count == 2 + mock_proc.assert_any_call(context.messages, Activity.UPLOAD_TEXT, session_id="thread-123") + + async def test_middleware_uses_message_conversation_id_as_session_id( + self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock + ) -> None: + """Test that session_id is extracted from message.additional_properties['conversation_id'].""" + messages = [Message(role="user", text="Hello", additional_properties={"conversation_id": "conv-456"})] + context = AgentContext(agent=mock_agent, messages=messages) + + with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: + + async def mock_next() -> None: + context.result = AgentResponse(messages=[Message(role="assistant", text="Hi")]) + + await middleware.process(context, mock_next) + + # Verify session_id is passed to both pre-check and post-check + assert mock_proc.call_count == 2 + mock_proc.assert_any_call(messages, Activity.UPLOAD_TEXT, session_id="conv-456") + + async def test_middleware_session_id_takes_precedence_over_message_conversation_id( + self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock + ) -> None: + """Test that session.service_session_id takes precedence over message conversation_id.""" + session = AgentSession(service_session_id="thread-789") + messages = [Message(role="user", text="Hello", additional_properties={"conversation_id": "conv-456"})] + context = AgentContext(agent=mock_agent, messages=messages, session=session) + + with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: + + async def mock_next() -> None: + context.result = AgentResponse(messages=[Message(role="assistant", text="Hi")]) + + await middleware.process(context, mock_next) + + # Verify session ID is used, not message conversation_id + mock_proc.assert_any_call(messages, Activity.UPLOAD_TEXT, session_id="thread-789") + + async def test_middleware_passes_none_session_id_when_not_available( + self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock + ) -> None: + """Test that session_id is None when no session or conversation_id is available.""" + context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")]) + + with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: + + async def mock_next() -> None: + context.result = AgentResponse(messages=[Message(role="assistant", text="Hi")]) + + await middleware.process(context, mock_next) + + # Verify session_id=None is passed + mock_proc.assert_any_call(context.messages, Activity.UPLOAD_TEXT, session_id=None) + + async def test_middleware_session_id_used_in_post_check( + self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock + ) -> None: + """Test that session_id is passed to post-check process_messages call.""" + session = AgentSession(service_session_id="thread-999") + context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")], session=session) + + with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: + + async def mock_next() -> None: + context.result = AgentResponse(messages=[Message(role="assistant", text="Response")]) + + await middleware.process(context, mock_next) + + # Verify both calls include session_id + assert mock_proc.call_count == 2 + # Check post-check call includes session_id + post_check_call = mock_proc.call_args_list[1] + assert post_check_call[1]["session_id"] == "thread-999" diff --git a/python/packages/purview/tests/test_processor.py b/python/packages/purview/tests/purview/test_processor.py similarity index 95% rename from python/packages/purview/tests/test_processor.py rename to python/packages/purview/tests/purview/test_processor.py index f122c6e059..7e70072508 100644 --- a/python/packages/purview/tests/test_processor.py +++ b/python/packages/purview/tests/purview/test_processor.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from agent_framework import ChatMessage +from agent_framework import Message from agent_framework_purview import PurviewAppLocation, PurviewLocationType, PurviewSettings from agent_framework_purview._models import ( @@ -83,8 +83,8 @@ class TestScopedContentProcessor: async def test_process_messages_with_defaults(self, processor: ScopedContentProcessor) -> None: """Test process_messages with settings that have defaults.""" messages = [ - ChatMessage(role="user", text="Hello"), - ChatMessage(role="assistant", text="Hi there"), + Message(role="user", text="Hello"), + Message(role="assistant", text="Hi there"), ] with patch.object(processor, "_map_messages", return_value=([], None)) as mock_map: @@ -92,13 +92,13 @@ class TestScopedContentProcessor: assert should_block is False assert user_id is None - mock_map.assert_called_once_with(messages, Activity.UPLOAD_TEXT, None) + mock_map.assert_called_once_with(messages, Activity.UPLOAD_TEXT, None, None) async def test_process_messages_blocks_content( self, processor: ScopedContentProcessor, process_content_request_factory ) -> None: """Test process_messages returns True when content should be blocked.""" - messages = [ChatMessage(role="user", text="Sensitive content")] + messages = [Message(role="user", text="Sensitive content")] mock_request = process_content_request_factory("Sensitive content") @@ -120,7 +120,7 @@ class TestScopedContentProcessor: ) -> None: """Test _map_messages creates ProcessContentRequest objects.""" messages = [ - ChatMessage( + Message( role="user", text="Test message", message_id="msg-123", @@ -139,7 +139,7 @@ class TestScopedContentProcessor: """Test _map_messages gets token info when settings lack some defaults.""" settings = PurviewSettings(app_name="Test App", tenant_id="12345678-1234-1234-1234-123456789012") processor = ScopedContentProcessor(mock_client, settings) - messages = [ChatMessage(role="user", text="Test", message_id="msg-123")] + messages = [Message(role="user", text="Test", message_id="msg-123")] requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT) @@ -156,7 +156,7 @@ class TestScopedContentProcessor: return_value={"user_id": "test-user", "client_id": "test-client"} ) - messages = [ChatMessage(role="user", text="Test", message_id="msg-123")] + messages = [Message(role="user", text="Test", message_id="msg-123")] with pytest.raises(ValueError, match="Tenant id required"): await processor._map_messages(messages, Activity.UPLOAD_TEXT) @@ -331,7 +331,7 @@ class TestScopedContentProcessor: processor = ScopedContentProcessor(mock_client, settings) messages = [ - ChatMessage( + Message( role="user", text="Test message", additional_properties={"user_id": "22345678-1234-1234-1234-123456789012"}, @@ -355,7 +355,7 @@ class TestScopedContentProcessor: ) processor = ScopedContentProcessor(mock_client, settings) - messages = [ChatMessage(role="user", text="Test message")] + messages = [Message(role="user", text="Test message")] requests, user_id = await processor._map_messages( messages, Activity.UPLOAD_TEXT, provided_user_id="32345678-1234-1234-1234-123456789012" @@ -376,7 +376,7 @@ class TestScopedContentProcessor: ) processor = ScopedContentProcessor(mock_client, settings) - messages = [ChatMessage(role="user", text="Test message")] + messages = [Message(role="user", text="Test message")] requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT) @@ -479,7 +479,7 @@ class TestUserIdResolution: settings = PurviewSettings(app_name="Test App") # No tenant_id or app_location processor = ScopedContentProcessor(mock_client, settings) - messages = [ChatMessage(role="user", text="Test")] + messages = [Message(role="user", text="Test")] requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT) @@ -493,7 +493,7 @@ class TestUserIdResolution: processor = ScopedContentProcessor(mock_client, settings) messages = [ - ChatMessage( + Message( role="user", text="Test", additional_properties={"user_id": "22222222-2222-2222-2222-222222222222"}, @@ -513,7 +513,7 @@ class TestUserIdResolution: processor = ScopedContentProcessor(mock_client, settings) messages = [ - ChatMessage( + Message( role="user", text="Test", author_name="33333333-3333-3333-3333-333333333333", @@ -531,7 +531,7 @@ class TestUserIdResolution: processor = ScopedContentProcessor(mock_client, settings) messages = [ - ChatMessage( + Message( role="user", text="Test", author_name="John Doe", # Not a GUID @@ -550,7 +550,7 @@ class TestUserIdResolution: """Test provided_user_id parameter is used as last resort.""" processor = ScopedContentProcessor(mock_client, settings) - messages = [ChatMessage(role="user", text="Test")] + messages = [Message(role="user", text="Test")] requests, user_id = await processor._map_messages( messages, Activity.UPLOAD_TEXT, provided_user_id="44444444-4444-4444-4444-444444444444" @@ -562,7 +562,7 @@ class TestUserIdResolution: """Test invalid provided_user_id is ignored.""" processor = ScopedContentProcessor(mock_client, settings) - messages = [ChatMessage(role="user", text="Test")] + messages = [Message(role="user", text="Test")] requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT, provided_user_id="not-a-guid") @@ -574,11 +574,11 @@ class TestUserIdResolution: processor = ScopedContentProcessor(mock_client, settings) messages = [ - ChatMessage( + Message( role="user", text="First", additional_properties={"user_id": "55555555-5555-5555-5555-555555555555"} ), - ChatMessage(role="assistant", text="Response"), - ChatMessage(role="user", text="Second"), + Message(role="assistant", text="Response"), + Message(role="user", text="Second"), ] requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT) @@ -594,13 +594,13 @@ class TestUserIdResolution: processor = ScopedContentProcessor(mock_client, settings) messages = [ - ChatMessage(role="user", text="First", author_name="Not a GUID"), - ChatMessage( + Message(role="user", text="First", author_name="Not a GUID"), + Message( role="assistant", text="Response", additional_properties={"user_id": "66666666-6666-6666-6666-666666666666"}, ), - ChatMessage( + Message( role="user", text="Third", additional_properties={"user_id": "77777777-7777-7777-7777-777777777777"} ), ] @@ -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, ) @@ -654,7 +653,7 @@ class TestScopedContentProcessorCaching: scope_identifier="scope-123", scopes=[] ) - messages = [ChatMessage(role="user", text="Test")] + messages = [Message(role="user", text="Test")] await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012") @@ -676,7 +675,7 @@ class TestScopedContentProcessorCaching: mock_client.get_protection_scopes.side_effect = PurviewPaymentRequiredError("Payment required") - messages = [ChatMessage(role="user", text="Test")] + messages = [Message(role="user", text="Test")] with pytest.raises(PurviewPaymentRequiredError): await processor.process_messages( diff --git a/python/packages/purview/tests/test_purview_client.py b/python/packages/purview/tests/purview/test_purview_client.py similarity index 99% rename from python/packages/purview/tests/test_purview_client.py rename to python/packages/purview/tests/purview/test_purview_client.py index b740b3f09c..a61724d3f0 100644 --- a/python/packages/purview/tests/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/test_purview_models.py b/python/packages/purview/tests/purview/test_purview_models.py similarity index 100% rename from python/packages/purview/tests/test_purview_models.py rename to python/packages/purview/tests/purview/test_purview_models.py diff --git a/python/packages/purview/tests/test_settings.py b/python/packages/purview/tests/purview/test_settings.py similarity index 84% rename from python/packages/purview/tests/test_settings.py rename to python/packages/purview/tests/purview/test_settings.py index 9abc27aed1..42e03a2be3 100644 --- a/python/packages/purview/tests/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/packages/redis/AGENTS.md b/python/packages/redis/AGENTS.md index 60acfc1f77..3b575e5029 100644 --- a/python/packages/redis/AGENTS.md +++ b/python/packages/redis/AGENTS.md @@ -13,7 +13,7 @@ Redis-based storage for agent threads and context. from agent_framework.redis import RedisChatMessageStore store = RedisChatMessageStore(redis_url="redis://localhost:6379") -agent = ChatAgent(..., chat_message_store_factory=lambda: store) +agent = Agent(..., chat_message_store_factory=lambda: store) ``` ## Import Path diff --git a/python/packages/redis/README.md b/python/packages/redis/README.md index 3c6c5bb18c..02ba6f7548 100644 --- a/python/packages/redis/README.md +++ b/python/packages/redis/README.md @@ -14,7 +14,7 @@ The `RedisProvider` enables persistent context & memory capabilities for your ag #### Basic Usage Examples -Review the set of [getting started examples](../../samples/getting_started/context_providers/redis/README.md) for using the Redis context provider. +Review the set of [getting started examples](../../samples/02-agents/context_providers/redis/README.md) for using the Redis context provider. ### Redis Chat Message Store @@ -30,10 +30,10 @@ The `RedisChatMessageStore` provides persistent conversation storage using Redis #### Basic Usage Examples -See the complete [Redis chat message store examples](../../samples/getting_started/threads/redis_chat_message_store_thread.py) including: +See the complete [Redis history provider examples](../../samples/02-agents/conversations/redis_chat_message_store_session.py) including: - User session management - Conversation persistence across restarts -- Thread serialization and deserialization +- Session serialization and deserialization - Automatic message trimming - Error handling patterns diff --git a/python/packages/redis/agent_framework_redis/__init__.py b/python/packages/redis/agent_framework_redis/__init__.py index fd63a69cae..fefbe99e6d 100644 --- a/python/packages/redis/agent_framework_redis/__init__.py +++ b/python/packages/redis/agent_framework_redis/__init__.py @@ -1,8 +1,8 @@ # Copyright (c) Microsoft. All rights reserved. import importlib.metadata -from ._chat_message_store import RedisChatMessageStore -from ._provider import RedisProvider +from ._context_provider import RedisContextProvider +from ._history_provider import RedisHistoryProvider try: __version__ = importlib.metadata.version(__name__) @@ -10,7 +10,7 @@ except importlib.metadata.PackageNotFoundError: __version__ = "0.0.0" # Fallback for development mode __all__ = [ - "RedisChatMessageStore", - "RedisProvider", + "RedisContextProvider", + "RedisHistoryProvider", "__version__", ] diff --git a/python/packages/redis/agent_framework_redis/_chat_message_store.py b/python/packages/redis/agent_framework_redis/_chat_message_store.py deleted file mode 100644 index 4b50c63571..0000000000 --- a/python/packages/redis/agent_framework_redis/_chat_message_store.py +++ /dev/null @@ -1,595 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -from collections.abc import Sequence -from typing import Any -from uuid import uuid4 - -import redis.asyncio as redis -from agent_framework import ChatMessage -from agent_framework._serialization import SerializationMixin -from redis.credentials import CredentialProvider - - -class RedisStoreState(SerializationMixin): - """State model for serializing and deserializing Redis chat message store data.""" - - def __init__( - self, - thread_id: str, - redis_url: str | None = None, - key_prefix: str = "chat_messages", - max_messages: int | None = None, - ) -> None: - """State model for serializing and deserializing Redis chat message store data.""" - self.thread_id = thread_id - self.redis_url = redis_url - self.key_prefix = key_prefix - self.max_messages = max_messages - - -class RedisChatMessageStore: - """Redis-backed implementation of ChatMessageStoreProtocol using Redis Lists. - - This implementation provides persistent, thread-safe chat message storage using Redis Lists. - Messages are stored as JSON-serialized strings in chronological order, with each conversation - thread isolated by a unique Redis key. - - Key Features: - ============ - - **Persistent Storage**: Messages survive application restarts and crashes - - **Thread Isolation**: Each conversation thread has its own Redis key namespace - - **Auto Message Limits**: Configurable automatic trimming of old messages using LTRIM - - **Performance Optimized**: Uses native Redis operations for efficiency - - **State Serialization**: Full compatibility with Agent Framework thread serialization - - **Initial Message Support**: Pre-load conversations with existing message history - - **Production Ready**: Atomic operations, error handling, connection pooling - - Redis Operations: - - RPUSH: Add messages to the end of the list (chronological order) - - LRANGE: Retrieve messages in chronological order - - LTRIM: Maintain message limits by trimming old messages - - DELETE: Clear all messages for a thread - """ - - def __init__( - self, - redis_url: str | None = None, - credential_provider: CredentialProvider | None = None, - host: str | None = None, - port: int = 6380, - ssl: bool = True, - username: str | None = None, - thread_id: str | None = None, - key_prefix: str = "chat_messages", - max_messages: int | None = None, - messages: Sequence[ChatMessage] | None = None, - ) -> None: - """Initialize the Redis chat message store. - - Creates a Redis-backed chat message store for a specific conversation thread. - Supports both traditional URL-based authentication and Azure Managed Redis - with credential provider. - - Args: - redis_url: Redis connection URL (e.g., "redis://localhost:6379"). - Used for traditional authentication. Mutually exclusive with credential_provider. - credential_provider: Redis credential provider (redis.credentials.CredentialProvider) for - Azure AD authentication. Requires host parameter. Mutually exclusive with redis_url. - host: Redis host name (e.g., "myredis.redis.cache.windows.net"). - Required when using credential_provider. - port: Redis port number. Defaults to 6380 (Azure Redis SSL port). - ssl: Enable SSL/TLS connection. Defaults to True. - username: Redis username. Defaults to None. - thread_id: Unique identifier for this conversation thread. - If not provided, a UUID will be auto-generated. - This becomes part of the Redis key: {key_prefix}:{thread_id} - key_prefix: Prefix for Redis keys to namespace different applications. - Defaults to 'chat_messages'. Useful for multi-tenant scenarios. - max_messages: Maximum number of messages to retain in Redis. - When exceeded, oldest messages are automatically trimmed using LTRIM. - None means unlimited storage. - messages: Initial messages to pre-populate the conversation. - These are added to Redis on first access if the Redis key is empty. - Useful for resuming conversations or seeding with context. - - Raises: - ValueError: If neither redis_url nor credential_provider is provided. - ValueError: If both redis_url and credential_provider are provided. - ValueError: If credential_provider is used without host parameter. - - Examples: - Traditional connection: - store = RedisChatMessageStore( - redis_url="redis://localhost:6379", - thread_id="conversation_123" - ) - - Azure Managed Redis with credential provider: - from redis.credentials import CredentialProvider - from azure.identity.aio import DefaultAzureCredential - - store = RedisChatMessageStore( - credential_provider=CredentialProvider(DefaultAzureCredential()), - host="myredis.redis.cache.windows.net", - thread_id="conversation_123" - ) - """ - # Validate connection parameters - if redis_url is None and credential_provider is None: - raise ValueError("Either redis_url or credential_provider must be provided") - - if redis_url is not None and credential_provider is not None: - raise ValueError("redis_url and credential_provider are mutually exclusive") - - if credential_provider is not None and host is None: - raise ValueError("host is required when using credential_provider") - - # Store configuration - self.thread_id = thread_id or f"thread_{uuid4()}" - self.key_prefix = key_prefix - self.max_messages = max_messages - - # Initialize Redis client based on authentication method - if credential_provider is not None and host is not None: - # Azure AD authentication with credential provider - self.redis_url = None # Not using URL-based auth - self._redis_client = redis.Redis( - host=host, - port=port, - ssl=ssl, - username=username, - credential_provider=credential_provider, - decode_responses=True, - ) - else: - # Traditional URL-based authentication - self.redis_url = redis_url - self._redis_client = redis.from_url(redis_url, decode_responses=True) # type: ignore[no-untyped-call] - - # Handle initial messages (will be moved to Redis on first access) - self._initial_messages = list(messages) if messages else [] - self._initial_messages_added = False - - @property - def redis_key(self) -> str: - """Get the Redis key for this thread's messages. - - The key format is: {key_prefix}:{thread_id} - - Returns: - Redis key string used for storing this thread's messages. - - Example: - For key_prefix="chat_messages" and thread_id="user_123_session_456": - Returns "chat_messages:user_123_session_456" - """ - return f"{self.key_prefix}:{self.thread_id}" - - async def _ensure_initial_messages_added(self) -> None: - """Ensure initial messages are added to Redis if not already present. - - This method is called before any Redis operations to guarantee that - initial messages provided during construction are persisted to Redis. - """ - if not self._initial_messages or self._initial_messages_added: - return - - # Check if Redis key already has messages (prevents duplicate additions) - existing_count = await self._redis_client.llen(self.redis_key) # type: ignore[misc] # type: ignore[misc] - if existing_count == 0: - # Add initial messages using atomic pipeline operation - await self._add_redis_messages(self._initial_messages) - - # Mark as completed and free memory - self._initial_messages_added = True - self._initial_messages.clear() - - async def _add_redis_messages(self, messages: Sequence[ChatMessage]) -> None: - """Add multiple messages to Redis using atomic pipeline operation. - - This internal method efficiently adds multiple messages to the Redis list - using a single atomic transaction to ensure consistency. - - Args: - messages: Sequence of ChatMessage objects to add to Redis. - """ - if not messages: - return - - # Pre-serialize all messages for efficient pipeline operation - serialized_messages = [self._serialize_message(message) for message in messages] - - # Use Redis pipeline for atomic batch operation - async with self._redis_client.pipeline(transaction=True) as pipe: - for serialized_message in serialized_messages: - await pipe.rpush(self.redis_key, serialized_message) # type: ignore[misc] - await pipe.execute() - - async def add_messages(self, messages: Sequence[ChatMessage]) -> None: - """Add messages to the Redis store (ChatMessageStoreProtocol protocol method). - - This method implements the required ChatMessageStoreProtocol protocol for adding messages. - Messages are appended to the Redis list in chronological order, with automatic - trimming if message limits are configured. - - Args: - messages: Sequence of ChatMessage objects to add to the store. - Can be empty (no-op) or contain multiple messages. - - Thread Safety: - - Atomic pipeline ensures all messages are added together - - LTRIM operation is atomic for consistent message limits - - Example: - .. code-block:: python - - messages = [ChatMessage(role="user", text="Hello"), ChatMessage(role="assistant", text="Hi there!")] - await store.add_messages(messages) - """ - if not messages: - return - - # Ensure any initial messages are persisted first - await self._ensure_initial_messages_added() - - # Add new messages using atomic pipeline operation - await self._add_redis_messages(messages) - - # Apply message limit if configured (automatic cleanup) - if self.max_messages is not None: - current_count = await self._redis_client.llen(self.redis_key) # type: ignore[misc] - if current_count > self.max_messages: - # Keep only the most recent max_messages using LTRIM - await self._redis_client.ltrim(self.redis_key, -self.max_messages, -1) # type: ignore[misc] - - async def list_messages(self) -> list[ChatMessage]: - """Get all messages from the store in chronological order (ChatMessageStoreProtocol protocol method). - - This method implements the required ChatMessageStoreProtocol protocol for retrieving messages. - Returns all messages stored in Redis, ordered from oldest (index 0) to newest (index -1). - - Returns: - List of ChatMessage objects in chronological order (oldest first). - Returns empty list if no messages exist or if Redis connection fails. - - Example: - .. code-block:: python - - # Get all conversation history - messages = await store.list_messages() - """ - # Ensure any initial messages are persisted to Redis first - await self._ensure_initial_messages_added() - - messages = [] - # Retrieve all messages from Redis list (oldest to newest) - redis_messages = await self._redis_client.lrange(self.redis_key, 0, -1) # type: ignore[misc] - - if redis_messages: - for serialized_message in redis_messages: - # Deserialize each JSON message back to ChatMessage - message = self._deserialize_message(serialized_message) - messages.append(message) - - return messages - - async def serialize(self, **kwargs: Any) -> Any: - """Serialize the current store state for persistence (ChatMessageStoreProtocol protocol method). - - This method implements the required ChatMessageStoreProtocol protocol for state serialization. - Captures the Redis connection configuration and thread information needed to - reconstruct the store and reconnect to the same conversation data. - - Keyword Args: - **kwargs: Additional arguments passed to Pydantic model_dump() for serialization. - Common options: exclude_none=True, by_alias=True - - Returns: - Dictionary containing serialized store configuration that can be persisted - to databases, files, or other storage mechanisms. - """ - state = RedisStoreState( - thread_id=self.thread_id, - redis_url=self.redis_url, - key_prefix=self.key_prefix, - max_messages=self.max_messages, - ) - return state.to_dict(exclude_none=False, **kwargs) - - @classmethod - async def deserialize(cls, serialized_store_state: Any, **kwargs: Any) -> RedisChatMessageStore: - """Deserialize state data into a new store instance (ChatMessageStoreProtocol protocol method). - - This method implements the required ChatMessageStoreProtocol protocol for state deserialization. - Creates a new RedisChatMessageStore instance from previously serialized data, - allowing the store to reconnect to the same conversation data in Redis. - - Args: - serialized_store_state: Previously serialized state data from serialize_state(). - Should be a dictionary with thread_id, redis_url, etc. - - Keyword Args: - **kwargs: Additional arguments passed to Pydantic model validation. - - Returns: - A new RedisChatMessageStore instance configured from the serialized state. - - Raises: - ValueError: If required fields are missing or invalid in the serialized state. - """ - if not serialized_store_state: - raise ValueError("serialized_store_state is required for deserialization") - - # Validate and parse the serialized state using Pydantic - state = RedisStoreState.from_dict(serialized_store_state, **kwargs) - - # Create and return a new store instance with the deserialized configuration - return cls( - redis_url=state.redis_url, - thread_id=state.thread_id, - key_prefix=state.key_prefix, - max_messages=state.max_messages, - ) - - async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> None: - """Deserialize state data into this store instance (ChatMessageStoreProtocol protocol method). - - This method implements the required ChatMessageStoreProtocol protocol for state deserialization. - Restores the store configuration from previously serialized data, allowing the store - to reconnect to the same conversation data in Redis. - - Args: - serialized_store_state: Previously serialized state data from serialize_state(). - Should be a dictionary with thread_id, redis_url, etc. - - Keyword Args: - **kwargs: Additional arguments passed to Pydantic model validation. - """ - if not serialized_store_state: - return - - # Validate and parse the serialized state using Pydantic - state = RedisStoreState.from_dict(serialized_store_state, **kwargs) - - # Update store configuration from deserialized state - self.thread_id = state.thread_id - if state.redis_url is not None: - self.redis_url = state.redis_url - self.key_prefix = state.key_prefix - self.max_messages = state.max_messages - - # Recreate Redis client if the URL changed - if state.redis_url and state.redis_url != getattr(self, "_last_redis_url", None): - self._redis_client = redis.from_url(state.redis_url, decode_responses=True) # type: ignore[no-untyped-call] - self._last_redis_url = state.redis_url - - # Reset initial message state since we're connecting to existing data - self._initial_messages_added = False - - async def clear(self) -> None: - """Remove all messages from the store. - - Permanently deletes all messages for this conversation thread by removing - the Redis key. This operation cannot be undone. - - Warning: - - This permanently deletes all conversation history - - Consider exporting messages before clearing if backup is needed - - Example: - .. code-block:: python - - # Clear conversation history - await store.clear() - - # Verify messages are gone - messages = await store.list_messages() - assert len(messages) == 0 - """ - await self._redis_client.delete(self.redis_key) - - def _serialize_message(self, message: ChatMessage) -> str: - """Serialize a ChatMessage to JSON string. - - Args: - message: ChatMessage to serialize. - - Returns: - JSON string representation of the message. - """ - # Serialize to compact JSON (no extra whitespace for Redis efficiency) - return message.to_json(separators=(",", ":")) - - def _deserialize_message(self, serialized_message: str) -> ChatMessage: - """Deserialize a JSON string to ChatMessage. - - Args: - serialized_message: JSON string representation of a message. - - Returns: - ChatMessage object. - """ - # Reconstruct ChatMessage using custom deserialization - return ChatMessage.from_json(serialized_message) - - # ============================================================================ - # List-like Convenience Methods (Redis-optimized async versions) - # ============================================================================ - - def __bool__(self) -> bool: - """Return True since the store always exists once created. - - This method is called by Python's truthiness checks (if store:). - Since a RedisChatMessageStore instance always represents a valid store, - this always returns True. - - Returns: - Always True - the store exists and is ready for operations. - - Note: - This is used by the Agent Framework to check if a message store - is configured: `if thread.message_store:` - """ - return True - - async def __len__(self) -> int: - """Return the number of messages in the Redis store. - - Provides efficient message counting using Redis LLEN command. - This is the async equivalent of Python's built-in len() function. - - Returns: - The count of messages currently stored in Redis. - """ - await self._ensure_initial_messages_added() - return await self._redis_client.llen(self.redis_key) # type: ignore[misc,no-any-return] - - async def getitem(self, index: int) -> ChatMessage: - """Get a message by index using Redis LINDEX. - - Args: - index: The index of the message to retrieve. - - Returns: - The ChatMessage at the specified index. - - Raises: - IndexError: If the index is out of range. - """ - await self._ensure_initial_messages_added() - - # Use Redis LINDEX for efficient single-item access - serialized_message = await self._redis_client.lindex(self.redis_key, index) # type: ignore[misc] - if serialized_message is None: - raise IndexError("list index out of range") - - return self._deserialize_message(serialized_message) - - async def setitem(self, index: int, item: ChatMessage) -> None: - """Set a message at the specified index using Redis LSET. - - Args: - index: The index at which to set the message. - item: The ChatMessage to set at the specified index. - - Raises: - IndexError: If the index is out of range. - """ - await self._ensure_initial_messages_added() - - # Validate index exists using LLEN - current_count = await self._redis_client.llen(self.redis_key) # type: ignore[misc] - if index < 0: - index = current_count + index - if index < 0 or index >= current_count: - raise IndexError("list index out of range") - - # Use Redis LSET for efficient single-item update - serialized_message = self._serialize_message(item) - await self._redis_client.lset(self.redis_key, index, serialized_message) # type: ignore[misc] - - async def append(self, item: ChatMessage) -> None: - """Append a message to the end of the store. - - Args: - item: The ChatMessage to append. - """ - await self.add_messages([item]) - - async def count(self) -> int: - """Return the number of messages in the Redis store. - - Returns: - The count of messages currently stored in Redis. - """ - await self._ensure_initial_messages_added() - return await self._redis_client.llen(self.redis_key) # type: ignore[misc,no-any-return] - - async def index(self, item: ChatMessage) -> int: - """Return the index of the first occurrence of the specified message. - - Uses Redis LINDEX to iterate through the list without loading all messages. - Still O(N) but more memory efficient for large lists. - - Args: - item: The ChatMessage to find. - - Returns: - The index of the first occurrence of the message. - - Raises: - ValueError: If the message is not found in the store. - """ - await self._ensure_initial_messages_added() - - target_serialized = self._serialize_message(item) - list_length = await self._redis_client.llen(self.redis_key) # type: ignore[misc] - - # Iterate through Redis list using LINDEX - for i in range(list_length): - redis_message = await self._redis_client.lindex(self.redis_key, i) # type: ignore[misc] - if redis_message == target_serialized: - return i - - raise ValueError("ChatMessage not found in store") - - async def remove(self, item: ChatMessage) -> None: - """Remove the first occurrence of the specified message from the store. - - Uses Redis LREM command for efficient removal by value. - O(N) but performed natively in Redis without data transfer. - - Args: - item: The ChatMessage to remove. - - Raises: - ValueError: If the message is not found in the store. - """ - await self._ensure_initial_messages_added() - - # Serialize the message to match Redis storage format - target_serialized = self._serialize_message(item) - - # Use LREM to remove first occurrence (count=1) - removed_count = await self._redis_client.lrem(self.redis_key, 1, target_serialized) # type: ignore[misc] - - if removed_count == 0: - raise ValueError("ChatMessage not found in store") - - async def extend(self, items: Sequence[ChatMessage]) -> None: - """Extend the store by appending all messages from the iterable. - - Args: - items: Sequence of ChatMessage objects to append. - """ - await self.add_messages(items) - - async def ping(self) -> bool: - """Test the Redis connection. - - Returns: - True if the connection is successful, False otherwise. - """ - try: - await self._redis_client.ping() # type: ignore[misc] - return True - except Exception: - return False - - async def aclose(self) -> None: - """Close the Redis connection. - - This method provides a clean way to close the underlying Redis connection - when the store is no longer needed. This is particularly useful in samples - and applications where explicit resource cleanup is desired. - """ - await self._redis_client.aclose() # type: ignore[misc] - - def __repr__(self) -> str: - """String representation of the store.""" - return ( - f"RedisChatMessageStore(thread_id='{self.thread_id}', " - f"redis_key='{self.redis_key}', max_messages={self.max_messages})" - ) diff --git a/python/packages/redis/agent_framework_redis/_provider.py b/python/packages/redis/agent_framework_redis/_context_provider.py similarity index 58% rename from python/packages/redis/agent_framework_redis/_provider.py rename to python/packages/redis/agent_framework_redis/_context_provider.py index f8449962b7..e8e0dbd66e 100644 --- a/python/packages/redis/agent_framework_redis/_provider.py +++ b/python/packages/redis/agent_framework_redis/_context_provider.py @@ -1,23 +1,29 @@ # Copyright (c) Microsoft. All rights reserved. +"""New-pattern Redis context provider using BaseContextProvider. + +This module provides ``RedisContextProvider``, built on the new +:class:`BaseContextProvider` hooks pattern. +""" + from __future__ import annotations import json import sys -from collections.abc import MutableSequence, Sequence from functools import reduce from operator import and_ -from typing import Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, cast import numpy as np -from agent_framework import ChatMessage, Context, ContextProvider +from agent_framework import Message +from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext from agent_framework.exceptions import ( AgentException, ServiceInitializationError, ServiceInvalidRequestError, ) from redisvl.index import AsyncSearchIndex -from redisvl.query import FilterQuery, HybridQuery, TextQuery +from redisvl.query import HybridQuery, TextQuery from redisvl.query.filter import FilterExpression, Tag from redisvl.utils.token_escaper import TokenEscaper from redisvl.utils.vectorize import BaseVectorizer @@ -32,38 +38,41 @@ if sys.version_info >= (3, 12): else: from typing_extensions import override # type: ignore[import] # pragma: no cover +if TYPE_CHECKING: + from agent_framework._agents import SupportsAgentRun -class RedisProvider(ContextProvider): - """Redis context provider with dynamic, filterable schema. - Stores context in Redis and retrieves scoped context. - Uses full-text or optional hybrid vector search to ground model responses. +class RedisContextProvider(BaseContextProvider): + """Redis context provider using the new BaseContextProvider hooks pattern. + + Stores context in Redis and retrieves scoped context via full-text or + optional hybrid vector search. """ + DEFAULT_CONTEXT_PROMPT = "## Memories\nConsider the following memories when answering user questions:" + def __init__( self, + source_id: str, redis_url: str = "redis://localhost:6379", index_name: str = "context", prefix: str = "context", - # Redis vectorizer configuration (optional, injected by client) + *, redis_vectorizer: BaseVectorizer | None = None, vector_field_name: str | None = None, vector_algorithm: Literal["flat", "hnsw"] | None = None, vector_distance_metric: Literal["cosine", "ip", "l2"] | None = None, - # Partition fields (indexed for filtering) application_id: str | None = None, agent_id: str | None = None, user_id: str | None = None, - thread_id: str | None = None, - scope_to_per_operation_thread_id: bool = False, - # Prompt and runtime - context_prompt: str = ContextProvider.DEFAULT_CONTEXT_PROMPT, + context_prompt: str | None = None, redis_index: Any = None, overwrite_index: bool = False, ): """Create a Redis Context Provider. Args: + source_id: Unique identifier for this provider instance. redis_url: The Redis server URL. index_name: The name of the Redis index. prefix: The prefix for all keys in the Redis database. @@ -74,13 +83,11 @@ class RedisProvider(ContextProvider): application_id: The application ID to scope the context. agent_id: The agent ID to scope the context. user_id: The user ID to scope the context. - thread_id: The thread ID to scope the context. - scope_to_per_operation_thread_id: Whether to scope to the per-operation thread ID. context_prompt: The context prompt to use for the provider. redis_index: The Redis index to use for the provider. overwrite_index: Whether to overwrite the existing Redis index. - """ + super().__init__(source_id) self.redis_url = redis_url self.index_name = index_name self.prefix = prefix @@ -95,27 +102,80 @@ class RedisProvider(ContextProvider): self.application_id = application_id self.agent_id = agent_id self.user_id = user_id - self.thread_id = thread_id - self.scope_to_per_operation_thread_id = scope_to_per_operation_thread_id - self.context_prompt = context_prompt + self.context_prompt = context_prompt or self.DEFAULT_CONTEXT_PROMPT self.overwrite_index = overwrite_index - self._per_operation_thread_id: str | None = None self._token_escaper: TokenEscaper = TokenEscaper() - self._conversation_id: str | None = None self._index_initialized: bool = False self._schema_dict: dict[str, Any] | None = None self.redis_index = redis_index or AsyncSearchIndex.from_dict( self.schema_dict, redis_url=self.redis_url, validate_on_load=True ) + # -- Hooks pattern --------------------------------------------------------- + + @override + async def before_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Retrieve scoped context from Redis and add to the session context.""" + self._validate_filters() + input_text = "\n".join(msg.text for msg in context.input_messages if msg and msg.text and msg.text.strip()) + if not input_text.strip(): + return + + memories = await self._redis_search(text=input_text, session_id=context.session_id) + line_separated_memories = "\n".join( + str(memory.get("content", "")) for memory in memories if memory.get("content") + ) + if line_separated_memories: + context.extend_messages( + self.source_id, + [Message(role="user", text=f"{self.context_prompt}\n{line_separated_memories}")], + ) + + @override + async def after_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Store request/response messages to Redis for future retrieval.""" + self._validate_filters() + + messages_to_store: list[Message] = list(context.input_messages) + if context.response and context.response.messages: + messages_to_store.extend(context.response.messages) + + messages: list[dict[str, Any]] = [] + for message in messages_to_store: + if message.role in {"user", "assistant", "system"} and message.text and message.text.strip(): + shaped: dict[str, Any] = { + "role": message.role, + "content": message.text, + "conversation_id": context.session_id, + "message_id": message.message_id, + "author_name": message.author_name, + } + messages.append(shaped) + if messages: + await self._add(data=messages, session_id=context.session_id) + + # -- Internal methods (ported from RedisProvider) -------------------------- + @property def schema_dict(self) -> dict[str, Any]: """Get the Redis schema dictionary, computing and caching it on first access.""" if self._schema_dict is None: - # Get vector configuration from vectorizer if available vector_dims = self.redis_vectorizer.dims if self.redis_vectorizer is not None else None vector_datatype = self.redis_vectorizer.dtype if self.redis_vectorizer is not None else None - self._schema_dict = self._build_schema_dict( index_name=self.index_name, prefix=self.prefix, @@ -128,16 +188,7 @@ class RedisProvider(ContextProvider): return self._schema_dict def _build_filter_from_dict(self, filters: dict[str, str | None]) -> Any | None: - """Builds a combined filter expression from simple equality tags. - - This ANDs non-empty tag filters and is used to scope all operations to app/agent/user/thread partitions. - - Args: - filters: Mapping of field name to value; falsy values are ignored. - - Returns: - A combined filter expression or None if no filters are provided. - """ + """Builds a combined filter expression from simple equality tags.""" parts = [Tag(k) == v for k, v in filters.items() if v] return reduce(and_, parts) if parts else None @@ -152,38 +203,19 @@ class RedisProvider(ContextProvider): vector_algorithm: Literal["flat", "hnsw"] | None, vector_distance_metric: Literal["cosine", "ip", "l2"] | None, ) -> dict[str, Any]: - """Builds the RediSearch schema configuration dictionary. - - Defines text and tag fields for messages plus an optional vector field enabling KNN/hybrid search. - - Keyword Args: - index_name: Index name. - prefix: Key prefix. - vector_field_name: Vector field name or None. - vector_dims: Vector dimensionality or None. - vector_datatype: Vector datatype or None. - vector_algorithm: Vector index algorithm or None. - vector_distance_metric: Vector distance metric or None. - - Returns: - Dict representing the index and fields configuration. - """ + """Builds the RediSearch schema configuration dictionary.""" fields: list[dict[str, Any]] = [ {"name": "role", "type": "tag"}, {"name": "mime_type", "type": "tag"}, {"name": "content", "type": "text"}, - # Conversation tracking {"name": "conversation_id", "type": "tag"}, {"name": "message_id", "type": "tag"}, {"name": "author_name", "type": "tag"}, - # Partition fields (TAG for fast filtering) {"name": "application_id", "type": "tag"}, {"name": "agent_id", "type": "tag"}, {"name": "user_id", "type": "tag"}, {"name": "thread_id", "type": "tag"}, ] - - # Add vector field only if configured (keeps provider runnable with no params) if vector_field_name is not None and vector_dims is not None: fields.append({ "name": vector_field_name, @@ -195,50 +227,23 @@ class RedisProvider(ContextProvider): "datatype": (vector_datatype or "float32"), }, }) - return { - "index": { - "name": index_name, - "prefix": prefix, - "key_separator": ":", - "storage_type": "hash", - }, + "index": {"name": index_name, "prefix": prefix, "key_separator": ":", "storage_type": "hash"}, "fields": fields, } async def _ensure_index(self) -> None: - """Initialize the search index. - - - Connect to existing index if it exists and schema matches - - Create new index if it doesn't exist - - Overwrite if requested via overwrite_index=True - - Validate schema compatibility to prevent accidental data loss - """ + """Initialize the search index.""" if self._index_initialized: return - - # Check if index already exists index_exists = await self.redis_index.exists() - if not self.overwrite_index and index_exists: - # Validate schema compatibility before connecting await self._validate_schema_compatibility() - - # Create the index (will connect to existing or create new) await self.redis_index.create(overwrite=self.overwrite_index, drop=False) - self._index_initialized = True async def _validate_schema_compatibility(self) -> None: - """Validate that existing index schema matches current configuration. - - Raises ServiceInitializationError if schemas don't match, with helpful guidance. - - self._build_schema_dict returns a minimal schema while Redis returns an expanded - schema with all defaults filled in. To compare for incompatibilities, compare - significant parts of the schema by creating signatures with normalized default values. - """ - # Defaults for attr normalization + """Validate that existing index schema matches current configuration.""" TAG_DEFAULTS = {"separator": ",", "case_sensitive": False, "withsuffixtrie": False} TEXT_DEFAULTS = {"weight": 1.0, "no_stem": False} @@ -255,11 +260,9 @@ class RedisProvider(ContextProvider): def _sig_vector(attrs: dict[str, Any] | None) -> dict[str, Any]: a = {**(attrs or {})} - # Require these to exist if vector field is present return {k: a.get(k) for k in ("algorithm", "dims", "distance_metric", "datatype")} def _schema_signature(schema: dict[str, Any]) -> dict[str, Any]: - # Order-independent, minimal signature sig: dict[str, Any] = {"index": _significant_index(schema.get("index", {})), "fields": {}} for f in schema.get("fields", []): name, ftype = f.get("name"), f.get("type") @@ -272,19 +275,15 @@ class RedisProvider(ContextProvider): elif ftype == "vector": sig["fields"][name] = {"type": "vector", "attrs": _sig_vector(f.get("attrs"))} else: - # Unknown field types: compare by type only sig["fields"][name] = {"type": ftype} return sig existing_index = await AsyncSearchIndex.from_existing(self.index_name, redis_url=self.redis_url) existing_schema = existing_index.schema.to_dict() current_schema = self.schema_dict - existing_sig = _schema_signature(existing_schema) current_sig = _schema_signature(current_schema) - if existing_sig != current_sig: - # Add sigs to error message raise ServiceInitializationError( "Existing Redis index schema is incompatible with the current configuration.\n" f"Existing (significant): {json.dumps(existing_sig, indent=2, sort_keys=True)}\n" @@ -296,47 +295,28 @@ class RedisProvider(ContextProvider): self, *, data: dict[str, Any] | list[dict[str, Any]], + session_id: str | None = None, metadata: dict[str, Any] | None = None, ) -> None: - """Inserts one or many documents with partition fields populated. - - Fills default partition fields, optionally embeds content when configured, and loads documents in a batch. - - Keyword Args: - data: Single document or list of documents to insert. - metadata: Optional metadata dictionary (unused placeholder). - - Raises: - ServiceInvalidRequestError: If required fields are missing or invalid. - """ - # Ensure provider has at least one scope set (symmetry with Mem0Provider) + """Inserts one or many documents with partition fields populated.""" self._validate_filters() await self._ensure_index() docs = data if isinstance(data, list) else [data] prepared: list[dict[str, Any]] = [] for doc in docs: - d = dict(doc) # shallow copy - - # Partition defaults + d = dict(doc) d.setdefault("application_id", self.application_id) d.setdefault("agent_id", self.agent_id) d.setdefault("user_id", self.user_id) - d.setdefault("thread_id", self._effective_thread_id) - # Conversation defaults - d.setdefault("conversation_id", self._conversation_id) - - # Logical requirement + d.setdefault("thread_id", session_id) + d.setdefault("conversation_id", session_id) if "content" not in d: raise ServiceInvalidRequestError("add() requires a 'content' field in data") - - # Vector field requirement (only if schema has one) if self.vector_field_name: d.setdefault(self.vector_field_name, None) - prepared.append(d) - # Batch embed contents for every message if self.redis_vectorizer and self.vector_field_name: text_list = [d["content"] for d in prepared] embeddings = await self.redis_vectorizer.aembed_many(text_list, batch_size=len(text_list)) @@ -345,41 +325,20 @@ class RedisProvider(ContextProvider): field_name: str = self.vector_field_name d[field_name] = vec - # Load all at once if supported await self.redis_index.load(prepared) - return async def _redis_search( self, text: str, *, + session_id: str | None = None, text_scorer: str = "BM25STD", filter_expression: Any | None = None, return_fields: list[str] | None = None, num_results: int = 10, alpha: float = 0.7, ) -> list[dict[str, Any]]: - """Runs a text or hybrid vector-text search with optional filters. - - Builds a TextQuery or HybridQuery and automatically ANDs partition filters to keep results scoped and safe. - - Args: - text: Query text. - - Keyword Args: - text_scorer: Scorer to use for text ranking. - filter_expression: Additional filter expression to AND with partition filters. - return_fields: Fields to return in results. - num_results: Maximum number of results. - alpha: Hybrid balancing parameter when vectors are enabled. - - Returns: - List of result dictionaries. - - Raises: - ServiceInvalidRequestError: If input is invalid or the query fails. - """ - # Enforce presence of at least one provider-level filter (symmetry with Mem0Provider) + """Runs a text or hybrid vector-text search with optional filters.""" await self._ensure_index() self._validate_filters() @@ -392,14 +351,12 @@ class RedisProvider(ContextProvider): "application_id": self.application_id, "agent_id": self.agent_id, "user_id": self.user_id, - "thread_id": self._effective_thread_id, - "conversation_id": self._conversation_id, + "thread_id": session_id, + "conversation_id": session_id, }) - if filter_expression is not None: combined_filter = (combined_filter & filter_expression) if combined_filter else filter_expression - # Choose return fields return_fields = ( return_fields if return_fields is not None @@ -408,7 +365,6 @@ class RedisProvider(ContextProvider): try: if self.redis_vectorizer and self.vector_field_name: - # Build hybrid query: combine full-text and vector similarity vector = await self.redis_vectorizer.aembed(q) query = HybridQuery( text=q, @@ -425,7 +381,6 @@ class RedisProvider(ContextProvider): ) hybrid_results = await self.redis_index.query(query) return cast(list[dict[str, Any]], hybrid_results) - # Text-only search query = TextQuery( text=q, text_field_name="content", @@ -437,20 +392,20 @@ class RedisProvider(ContextProvider): ) text_results = await self.redis_index.query(query) return cast(list[dict[str, Any]], text_results) - except Exception as exc: # pragma: no cover - surface as framework error + except Exception as exc: # pragma: no cover raise ServiceInvalidRequestError(f"Redis text search failed: {exc}") from exc + def _validate_filters(self) -> None: + """Validates that at least one filter is provided.""" + if not self.agent_id and not self.user_id and not self.application_id: + raise ServiceInitializationError( + "At least one of the filters: agent_id, user_id, or application_id is required." + ) + async def search_all(self, page_size: int = 200) -> list[dict[str, Any]]: - """Returns all documents in the index. + """Returns all documents in the index.""" + from redisvl.query import FilterQuery - Streams results via pagination to avoid excessive memory and response sizes. - - Args: - page_size: Page size used for pagination under the hood. - - Returns: - List of all documents. - """ out: list[dict[str, Any]] = [] async for batch in self.redis_index.paginate( FilterQuery(FilterExpression("*"), return_fields=[], num_results=page_size), @@ -459,139 +414,12 @@ class RedisProvider(ContextProvider): out.extend(batch) return out - @property - def _effective_thread_id(self) -> str | None: - """Resolves the active thread id. - - Returns per-operation thread id when scoping is enabled; otherwise the provider's thread id. - """ - return self._per_operation_thread_id if self.scope_to_per_operation_thread_id else self.thread_id - - @override - async def thread_created(self, thread_id: str | None) -> None: - """Called when a new thread is created. - - Captures the per-operation thread id when scoping is enabled to enforce single-thread usage. - - Args: - thread_id: The ID of the thread or None. - """ - self._validate_per_operation_thread_id(thread_id) - self._per_operation_thread_id = self._per_operation_thread_id or thread_id - # Track current conversation id (Agent passes conversation_id here) - self._conversation_id = thread_id or self._conversation_id - - @override - async def invoked( - self, - request_messages: ChatMessage | Sequence[ChatMessage], - response_messages: ChatMessage | Sequence[ChatMessage] | None = None, - invoke_exception: Exception | None = None, - **kwargs: Any, - ) -> None: - self._validate_filters() - - request_messages_list = ( - [request_messages] if isinstance(request_messages, ChatMessage) else list(request_messages) - ) - response_messages_list = ( - [response_messages] - if isinstance(response_messages, ChatMessage) - else list(response_messages) - if response_messages - else [] - ) - messages_list = [*request_messages_list, *response_messages_list] - - messages: list[dict[str, Any]] = [] - for message in messages_list: - if message.role in {"user", "assistant", "system"} and message.text and message.text.strip(): - shaped: dict[str, Any] = { - "role": message.role, - "content": message.text, - "conversation_id": self._conversation_id, - "message_id": message.message_id, - "author_name": message.author_name, - } - messages.append(shaped) - if messages: - await self._add(data=messages) - - @override - async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context: - """Called before invoking the model to provide scoped context. - - Concatenates recent messages into a query, fetches matching memories from Redis. - Prepends them as instructions. - - Args: - messages: List of new messages in the thread. - - Keyword Args: - **kwargs: not used at present at present. - - Returns: - Context: Context object containing instructions with memories. - """ - self._validate_filters() - messages_list = [messages] if isinstance(messages, ChatMessage) else list(messages) - input_text = "\n".join(msg.text for msg in messages_list if msg and msg.text and msg.text.strip()) - - memories = await self._redis_search(text=input_text) - line_separated_memories = "\n".join( - str(memory.get("content", "")) for memory in memories if memory.get("content") - ) - - return Context( - messages=[ChatMessage(role="user", text=f"{self.context_prompt}\n{line_separated_memories}")] - if line_separated_memories - else None - ) - async def __aenter__(self) -> Self: - """Async context manager entry. - - No special setup is required; provided for symmetry with the Mem0 provider. - """ + """Async context manager entry.""" return self async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: - """Async context manager exit. + """Async context manager exit.""" - No cleanup is required; indexes/keys remain unless explicitly cleared. - """ - return - def _validate_filters(self) -> None: - """Validates that at least one filter is provided. - - Prevents unbounded operations by requiring a partition filter before reads or writes. - - Raises: - ServiceInitializationError: If no filters are provided. - """ - if not self.agent_id and not self.user_id and not self.application_id and not self.thread_id: - raise ServiceInitializationError( - "At least one of the filters: agent_id, user_id, application_id, or thread_id is required." - ) - - def _validate_per_operation_thread_id(self, thread_id: str | None) -> None: - """Validates that a new thread ID doesn't conflict when scoped. - - Prevents cross-thread data leakage by enforcing single-thread usage when per-operation scoping is enabled. - - Args: - thread_id: The new thread ID or None. - - Raises: - ValueError: If a new thread ID conflicts with the existing one. - """ - if ( - self.scope_to_per_operation_thread_id - and thread_id - and self._per_operation_thread_id - and thread_id != self._per_operation_thread_id - ): - raise ValueError( - "RedisProvider can only be used with one thread, when scope_to_per_operation_thread_id is True." - ) +__all__ = ["RedisContextProvider"] diff --git a/python/packages/redis/agent_framework_redis/_history_provider.py b/python/packages/redis/agent_framework_redis/_history_provider.py new file mode 100644 index 0000000000..9cb39be202 --- /dev/null +++ b/python/packages/redis/agent_framework_redis/_history_provider.py @@ -0,0 +1,177 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""New-pattern Redis history provider using BaseHistoryProvider. + +This module provides ``RedisHistoryProvider``, built on the new +:class:`BaseHistoryProvider` hooks pattern. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +import redis.asyncio as redis +from agent_framework import Message +from agent_framework._sessions import BaseHistoryProvider +from redis.credentials import CredentialProvider + + +class RedisHistoryProvider(BaseHistoryProvider): + """Redis-backed history provider using the new BaseHistoryProvider hooks pattern. + + Stores conversation history in Redis Lists, with each session isolated by a + unique Redis key. + """ + + def __init__( + self, + source_id: str, + redis_url: str | None = None, + credential_provider: CredentialProvider | None = None, + host: str | None = None, + port: int = 6380, + ssl: bool = True, + username: str | None = None, + *, + key_prefix: str = "chat_messages", + max_messages: int | None = None, + load_messages: bool = True, + store_outputs: bool = True, + store_inputs: bool = True, + store_context_messages: bool = False, + store_context_from: set[str] | None = None, + ) -> None: + """Initialize the Redis history provider. + + Args: + source_id: Unique identifier for this provider instance. + redis_url: Redis connection URL (e.g., "redis://localhost:6379"). + Mutually exclusive with credential_provider. + credential_provider: Redis credential provider for Azure AD authentication. + Requires host parameter. Mutually exclusive with redis_url. + host: Redis host name. Required when using credential_provider. + port: Redis port number. Defaults to 6380 (Azure Redis SSL port). + ssl: Enable SSL/TLS connection. Defaults to True. + username: Redis username. + key_prefix: Prefix for Redis keys. Defaults to 'chat_messages'. + max_messages: Maximum number of messages to retain per session. + When exceeded, oldest messages are automatically trimmed. + None means unlimited storage. + load_messages: Whether to load messages before invocation. + store_outputs: Whether to store response messages. + store_inputs: Whether to store input messages. + store_context_messages: Whether to store context from other providers. + store_context_from: If set, only store context from these source_ids. + + Raises: + ValueError: If neither redis_url nor credential_provider is provided. + ValueError: If both redis_url and credential_provider are provided. + ValueError: If credential_provider is used without host parameter. + """ + super().__init__( + source_id, + load_messages=load_messages, + store_outputs=store_outputs, + store_inputs=store_inputs, + store_context_messages=store_context_messages, + store_context_from=store_context_from, + ) + + if redis_url is None and credential_provider is None: + raise ValueError("Either redis_url or credential_provider must be provided") + if redis_url is not None and credential_provider is not None: + raise ValueError("redis_url and credential_provider are mutually exclusive") + if credential_provider is not None and host is None: + raise ValueError("host is required when using credential_provider") + + self.key_prefix = key_prefix + self.max_messages = max_messages + self.redis_url = redis_url + + if credential_provider is not None and host is not None: + self._redis_client = redis.Redis( + host=host, + port=port, + ssl=ssl, + username=username, + credential_provider=credential_provider, + decode_responses=True, + ) + else: + self._redis_client = redis.from_url(redis_url, decode_responses=True) # type: ignore[no-untyped-call] + + def _redis_key(self, session_id: str | None) -> str: + """Get the Redis key for a given session's messages.""" + return f"{self.key_prefix}:{session_id or 'default'}" + + async def get_messages(self, session_id: str | None, **kwargs: Any) -> list[Message]: + """Retrieve stored messages for this session from Redis. + + Args: + session_id: The session ID to retrieve messages for. + **kwargs: Additional arguments (unused). + + Returns: + List of stored Message objects in chronological order. + """ + key = self._redis_key(session_id) + redis_messages = await self._redis_client.lrange(key, 0, -1) # type: ignore[misc] + messages: list[Message] = [] + if redis_messages: + for serialized in redis_messages: + messages.append(Message.from_dict(self._deserialize_json(serialized))) + return messages + + async def save_messages(self, session_id: str | None, messages: Sequence[Message], **kwargs: Any) -> None: + """Persist messages for this session to Redis. + + Args: + session_id: The session ID to store messages for. + messages: The messages to persist. + **kwargs: Additional arguments (unused). + """ + if not messages: + return + + key = self._redis_key(session_id) + serialized_messages = [self._serialize_json(msg) for msg in messages] + + async with self._redis_client.pipeline(transaction=True) as pipe: + for serialized in serialized_messages: + await pipe.rpush(key, serialized) # type: ignore[misc] + await pipe.execute() + + if self.max_messages is not None: + current_count = await self._redis_client.llen(key) # type: ignore[misc] + if current_count > self.max_messages: + await self._redis_client.ltrim(key, -self.max_messages, -1) # type: ignore[misc] + + @staticmethod + def _serialize_json(message: Message) -> str: + """Serialize a Message to a JSON string for Redis storage.""" + import json + + return json.dumps(message.to_dict()) + + @staticmethod + def _deserialize_json(data: str) -> dict[str, Any]: + """Deserialize a JSON string from Redis to a dict.""" + import json + + return json.loads(data) # type: ignore[no-any-return] + + async def clear(self, session_id: str | None) -> None: + """Clear all messages for a session. + + Args: + session_id: The session ID to clear messages for. + """ + await self._redis_client.delete(self._redis_key(session_id)) + + async def aclose(self) -> None: + """Close the Redis connection.""" + await self._redis_client.aclose() # type: ignore[misc] + + +__all__ = ["RedisHistoryProvider"] diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml index 30ec085706..148ec83c6a 100644 --- a/python/packages/redis/pyproject.toml +++ b/python/packages/redis/pyproject.toml @@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0b260130", + "agent-framework-core>=1.0.0b260212", "redis>=6.4.0", "redisvl>=0.8.2", "numpy>=2.2.6" diff --git a/python/packages/redis/tests/test_providers.py b/python/packages/redis/tests/test_providers.py new file mode 100644 index 0000000000..9bd388517d --- /dev/null +++ b/python/packages/redis/tests/test_providers.py @@ -0,0 +1,455 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for RedisContextProvider and RedisHistoryProvider.""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework import AgentResponse, Message +from agent_framework._sessions import AgentSession, SessionContext +from agent_framework.exceptions import ServiceInitializationError + +from agent_framework_redis._context_provider import RedisContextProvider +from agent_framework_redis._history_provider import RedisHistoryProvider + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_index() -> AsyncMock: + idx = AsyncMock() + idx.create = AsyncMock() + idx.load = AsyncMock() + idx.query = AsyncMock(return_value=[]) + idx.exists = AsyncMock(return_value=False) + return idx + + +@pytest.fixture +def patch_index_from_dict(mock_index: AsyncMock): + with patch("agent_framework_redis._context_provider.AsyncSearchIndex") as mock_cls: + mock_cls.from_dict = MagicMock(return_value=mock_index) + + async def mock_from_existing(index_name: str, redis_url: str): # noqa: ARG001 + mock_existing = AsyncMock() + mock_existing.schema.to_dict = MagicMock( + side_effect=lambda: mock_cls.from_dict.call_args[0][0] if mock_cls.from_dict.call_args else {} + ) + return mock_existing + + mock_cls.from_existing = AsyncMock(side_effect=mock_from_existing) + yield mock_cls + + +@pytest.fixture +def mock_redis_client(): + client = MagicMock() + client.lrange = AsyncMock(return_value=[]) + client.llen = AsyncMock(return_value=0) + client.ltrim = AsyncMock() + client.delete = AsyncMock() + + mock_pipeline = AsyncMock() + mock_pipeline.rpush = AsyncMock() + mock_pipeline.execute = AsyncMock() + client.pipeline.return_value.__aenter__.return_value = mock_pipeline + + return client + + +# =========================================================================== +# RedisContextProvider tests +# =========================================================================== + + +class TestRedisContextProviderInit: + def test_basic_construction(self, patch_index_from_dict: MagicMock): # noqa: ARG002 + provider = RedisContextProvider(source_id="ctx", user_id="u1") + assert provider.source_id == "ctx" + assert provider.user_id == "u1" + assert provider.redis_url == "redis://localhost:6379" + assert provider.index_name == "context" + assert provider.prefix == "context" + + def test_custom_params(self, patch_index_from_dict: MagicMock): # noqa: ARG002 + provider = RedisContextProvider( + source_id="ctx", + redis_url="redis://custom:6380", + index_name="my_idx", + prefix="my_prefix", + application_id="app1", + agent_id="agent1", + user_id="user1", + context_prompt="Custom prompt", + ) + assert provider.redis_url == "redis://custom:6380" + assert provider.index_name == "my_idx" + assert provider.prefix == "my_prefix" + assert provider.application_id == "app1" + assert provider.agent_id == "agent1" + assert provider.context_prompt == "Custom prompt" + + def test_default_context_prompt(self, patch_index_from_dict: MagicMock): # noqa: ARG002 + provider = RedisContextProvider(source_id="ctx", user_id="u1") + assert "Memories" in provider.context_prompt + + def test_invalid_vectorizer_raises(self, patch_index_from_dict: MagicMock): # noqa: ARG002 + from agent_framework.exceptions import AgentException + + with pytest.raises(AgentException, match="not a valid type"): + RedisContextProvider(source_id="ctx", user_id="u1", redis_vectorizer="bad") # type: ignore[arg-type] + + +class TestRedisContextProviderValidateFilters: + def test_no_filters_raises(self, patch_index_from_dict: MagicMock): # noqa: ARG002 + provider = RedisContextProvider(source_id="ctx") + with pytest.raises(ServiceInitializationError, match="(?i)at least one"): + provider._validate_filters() + + def test_any_single_filter_ok(self, patch_index_from_dict: MagicMock): # noqa: ARG002 + for kwargs in [{"user_id": "u"}, {"agent_id": "a"}, {"application_id": "app"}]: + provider = RedisContextProvider(source_id="ctx", **kwargs) + provider._validate_filters() # should not raise + + +class TestRedisContextProviderSchema: + def test_schema_has_expected_fields(self, patch_index_from_dict: MagicMock): # noqa: ARG002 + provider = RedisContextProvider(source_id="ctx", user_id="u1") + schema = provider.schema_dict + field_names = [f["name"] for f in schema["fields"]] + for expected in ("role", "content", "conversation_id", "message_id", "application_id", "agent_id", "user_id"): + assert expected in field_names + assert schema["index"]["name"] == "context" + assert schema["index"]["prefix"] == "context" + + def test_schema_no_vector_without_vectorizer(self, patch_index_from_dict: MagicMock): # noqa: ARG002 + provider = RedisContextProvider(source_id="ctx", user_id="u1") + field_types = [f["type"] for f in provider.schema_dict["fields"]] + assert "vector" not in field_types + + +class TestRedisContextProviderBeforeRun: + async def test_search_results_added_to_context( + self, + mock_index: AsyncMock, + patch_index_from_dict: MagicMock, # noqa: ARG002 + ): + mock_index.query = AsyncMock(return_value=[{"content": "Memory A"}, {"content": "Memory B"}]) + provider = RedisContextProvider(source_id="ctx", user_id="u1") + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", contents=["test query"])], session_id="s1") + + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + assert "ctx" in ctx.context_messages + msgs = ctx.context_messages["ctx"] + assert len(msgs) == 1 + assert "Memory A" in msgs[0].text + assert "Memory B" in msgs[0].text + + async def test_empty_input_no_search( + self, + mock_index: AsyncMock, + patch_index_from_dict: MagicMock, # noqa: ARG002 + ): + provider = RedisContextProvider(source_id="ctx", user_id="u1") + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", contents=[" "])], session_id="s1") + + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + mock_index.query.assert_not_called() + assert "ctx" not in ctx.context_messages + + async def test_empty_results_no_messages( + self, + mock_index: AsyncMock, + patch_index_from_dict: MagicMock, # noqa: ARG002 + ): + mock_index.query = AsyncMock(return_value=[]) + provider = RedisContextProvider(source_id="ctx", user_id="u1") + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", contents=["hello"])], session_id="s1") + + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + assert "ctx" not in ctx.context_messages + + +class TestRedisContextProviderAfterRun: + async def test_stores_messages( + self, + mock_index: AsyncMock, + patch_index_from_dict: MagicMock, # noqa: ARG002 + ): + provider = RedisContextProvider(source_id="ctx", user_id="u1") + session = AgentSession(session_id="test-session") + response = AgentResponse(messages=[Message(role="assistant", contents=["response text"])]) + ctx = SessionContext(input_messages=[Message(role="user", contents=["user input"])], session_id="s1") + ctx._response = response + + await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + mock_index.load.assert_called_once() + loaded = mock_index.load.call_args[0][0] + assert len(loaded) == 2 + roles = {d["role"] for d in loaded} + assert roles == {"user", "assistant"} + + async def test_skips_empty_conversations( + self, + mock_index: AsyncMock, + patch_index_from_dict: MagicMock, # noqa: ARG002 + ): + provider = RedisContextProvider(source_id="ctx", user_id="u1") + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", contents=[" "])], session_id="s1") + + await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + mock_index.load.assert_not_called() + + async def test_stores_partition_fields( + self, + mock_index: AsyncMock, + patch_index_from_dict: MagicMock, # noqa: ARG002 + ): + provider = RedisContextProvider(source_id="ctx", application_id="app", agent_id="ag", user_id="u1") + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", contents=["hello"])], session_id="s1") + + await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + loaded = mock_index.load.call_args[0][0] + doc = loaded[0] + assert doc["application_id"] == "app" + assert doc["agent_id"] == "ag" + assert doc["user_id"] == "u1" + assert doc["conversation_id"] == "s1" + + +class TestRedisContextProviderContextManager: + async def test_aenter_returns_self(self, patch_index_from_dict: MagicMock): # noqa: ARG002 + provider = RedisContextProvider(source_id="ctx", user_id="u1") + async with provider as p: + assert p is provider + + +# =========================================================================== +# RedisHistoryProvider tests +# =========================================================================== + + +class TestRedisHistoryProviderInit: + def test_basic_construction(self, mock_redis_client: MagicMock): + with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: + mock_from_url.return_value = mock_redis_client + provider = RedisHistoryProvider("memory", redis_url="redis://localhost:6379") + + assert provider.source_id == "memory" + assert provider.key_prefix == "chat_messages" + assert provider.max_messages is None + assert provider.load_messages is True + assert provider.store_outputs is True + assert provider.store_inputs is True + + def test_custom_params(self, mock_redis_client: MagicMock): + with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: + mock_from_url.return_value = mock_redis_client + provider = RedisHistoryProvider( + "mem", + redis_url="redis://localhost:6379", + key_prefix="custom", + max_messages=50, + load_messages=False, + store_outputs=False, + store_inputs=False, + ) + + assert provider.key_prefix == "custom" + assert provider.max_messages == 50 + assert provider.load_messages is False + assert provider.store_outputs is False + assert provider.store_inputs is False + + def test_no_redis_url_or_credential_raises(self): + with pytest.raises(ValueError, match="Either redis_url or credential_provider must be provided"): + RedisHistoryProvider("mem") + + def test_both_url_and_credential_raises(self): + mock_cred = MagicMock() + with pytest.raises(ValueError, match="mutually exclusive"): + RedisHistoryProvider( + "mem", + redis_url="redis://localhost:6379", + credential_provider=mock_cred, + host="myhost", + ) + + def test_credential_provider_without_host_raises(self): + mock_cred = MagicMock() + with pytest.raises(ValueError, match="host is required"): + RedisHistoryProvider("mem", credential_provider=mock_cred) + + def test_credential_provider_with_host(self): + mock_cred = MagicMock() + with patch("agent_framework_redis._history_provider.redis.Redis") as mock_redis_cls: + mock_redis_cls.return_value = MagicMock() + provider = RedisHistoryProvider("mem", credential_provider=mock_cred, host="myhost") + + mock_redis_cls.assert_called_once_with( + host="myhost", + port=6380, + ssl=True, + username=None, + credential_provider=mock_cred, + decode_responses=True, + ) + assert provider.redis_url is None + + +class TestRedisHistoryProviderRedisKey: + def test_key_format(self, mock_redis_client: MagicMock): + with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: + mock_from_url.return_value = mock_redis_client + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", key_prefix="msgs") + + assert provider._redis_key("session-123") == "msgs:session-123" + assert provider._redis_key(None) == "msgs:default" + + +class TestRedisHistoryProviderGetMessages: + async def test_returns_deserialized_messages(self, mock_redis_client: MagicMock): + msg1 = Message(role="user", contents=["Hello"]) + msg2 = Message(role="assistant", contents=["Hi!"]) + mock_redis_client.lrange = AsyncMock(return_value=[json.dumps(msg1.to_dict()), json.dumps(msg2.to_dict())]) + + with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: + mock_from_url.return_value = mock_redis_client + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + + messages = await provider.get_messages("s1") + assert len(messages) == 2 + assert messages[0].role == "user" + assert messages[0].text == "Hello" + assert messages[1].role == "assistant" + assert messages[1].text == "Hi!" + + async def test_empty_returns_empty(self, mock_redis_client: MagicMock): + mock_redis_client.lrange = AsyncMock(return_value=[]) + + with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: + mock_from_url.return_value = mock_redis_client + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + + messages = await provider.get_messages("s1") + assert messages == [] + + +class TestRedisHistoryProviderSaveMessages: + async def test_saves_serialized_messages(self, mock_redis_client: MagicMock): + with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: + mock_from_url.return_value = mock_redis_client + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + + msgs = [Message(role="user", contents=["Hello"]), Message(role="assistant", contents=["Hi"])] + await provider.save_messages("s1", msgs) + + pipeline = mock_redis_client.pipeline.return_value.__aenter__.return_value + assert pipeline.rpush.call_count == 2 + pipeline.execute.assert_called_once() + + async def test_empty_messages_noop(self, mock_redis_client: MagicMock): + with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: + mock_from_url.return_value = mock_redis_client + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + + await provider.save_messages("s1", []) + mock_redis_client.pipeline.assert_not_called() + + async def test_max_messages_trimming(self, mock_redis_client: MagicMock): + mock_redis_client.llen = AsyncMock(return_value=15) + + with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: + mock_from_url.return_value = mock_redis_client + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", max_messages=10) + + await provider.save_messages("s1", [Message(role="user", contents=["msg"])]) + + mock_redis_client.ltrim.assert_called_once_with("chat_messages:s1", -10, -1) + + async def test_no_trim_when_under_limit(self, mock_redis_client: MagicMock): + mock_redis_client.llen = AsyncMock(return_value=3) + + with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: + mock_from_url.return_value = mock_redis_client + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", max_messages=10) + + await provider.save_messages("s1", [Message(role="user", contents=["msg"])]) + + mock_redis_client.ltrim.assert_not_called() + + +class TestRedisHistoryProviderClear: + async def test_clear_calls_delete(self, mock_redis_client: MagicMock): + with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: + mock_from_url.return_value = mock_redis_client + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + + await provider.clear("session-1") + mock_redis_client.delete.assert_called_once_with("chat_messages:session-1") + + +class TestRedisHistoryProviderBeforeAfterRun: + """Test before_run/after_run integration via BaseHistoryProvider defaults.""" + + async def test_before_run_loads_history(self, mock_redis_client: MagicMock): + msg = Message(role="user", contents=["old msg"]) + mock_redis_client.lrange = AsyncMock(return_value=[json.dumps(msg.to_dict())]) + + with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: + mock_from_url.return_value = mock_redis_client + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + + session = AgentSession(session_id="test") + ctx = SessionContext(input_messages=[Message(role="user", contents=["new msg"])], session_id="s1") + + await provider.before_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + assert "mem" in ctx.context_messages + assert len(ctx.context_messages["mem"]) == 1 + assert ctx.context_messages["mem"][0].text == "old msg" + + async def test_after_run_stores_input_and_response(self, mock_redis_client: MagicMock): + with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: + mock_from_url.return_value = mock_redis_client + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + + session = AgentSession(session_id="test") + ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hello"])]) + + await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + pipeline = mock_redis_client.pipeline.return_value.__aenter__.return_value + assert pipeline.rpush.call_count == 2 + pipeline.execute.assert_called_once() + + async def test_after_run_skips_when_no_messages(self, mock_redis_client: MagicMock): + with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: + mock_from_url.return_value = mock_redis_client + provider = RedisHistoryProvider( + "mem", redis_url="redis://localhost:6379", store_inputs=False, store_outputs=False + ) + + session = AgentSession(session_id="test") + ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1") + + await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type] + + mock_redis_client.pipeline.assert_not_called() diff --git a/python/packages/redis/tests/test_redis_chat_message_store.py b/python/packages/redis/tests/test_redis_chat_message_store.py deleted file mode 100644 index 152d99fdf1..0000000000 --- a/python/packages/redis/tests/test_redis_chat_message_store.py +++ /dev/null @@ -1,621 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from agent_framework import ChatMessage, Content - -from agent_framework_redis import RedisChatMessageStore - - -class TestRedisChatMessageStore: - """Unit tests for RedisChatMessageStore using mocked Redis client. - - These tests use mocked Redis operations to verify the logic and behavior - of the RedisChatMessageStore without requiring a real Redis server. - """ - - @pytest.fixture - def sample_messages(self): - """Sample chat messages for testing.""" - return [ - ChatMessage(role="user", text="Hello", message_id="msg1"), - ChatMessage(role="assistant", text="Hi there!", message_id="msg2"), - ChatMessage(role="user", text="How are you?", message_id="msg3"), - ] - - @pytest.fixture - def mock_redis_client(self): - """Mock Redis client with all required methods.""" - client = MagicMock() - # Core list operations - client.lrange = AsyncMock(return_value=[]) - client.llen = AsyncMock(return_value=0) - client.lindex = AsyncMock(return_value=None) - client.lset = AsyncMock(return_value=True) - client.lrem = AsyncMock(return_value=0) - client.lpop = AsyncMock(return_value=None) - client.rpop = AsyncMock(return_value=None) - client.ltrim = AsyncMock(return_value=True) - client.delete = AsyncMock(return_value=1) - - # Pipeline operations - mock_pipeline = AsyncMock() - mock_pipeline.rpush = AsyncMock() - mock_pipeline.execute = AsyncMock() - client.pipeline.return_value.__aenter__.return_value = mock_pipeline - - return client - - @pytest.fixture - def redis_store(self, mock_redis_client): - """Redis chat message store with mocked client.""" - with patch("agent_framework_redis._chat_message_store.redis.from_url") as mock_from_url: - mock_from_url.return_value = mock_redis_client - store = RedisChatMessageStore(redis_url="redis://localhost:6379", thread_id="test_thread_123") - store._redis_client = mock_redis_client - return store - - def test_init_with_thread_id(self): - """Test initialization with explicit thread ID.""" - thread_id = "user123_session456" - with patch("agent_framework_redis._chat_message_store.redis.from_url"): - store = RedisChatMessageStore(redis_url="redis://localhost:6379", thread_id=thread_id) - - assert store.thread_id == thread_id - assert store.redis_url == "redis://localhost:6379" - assert store.key_prefix == "chat_messages" - assert store.redis_key == f"chat_messages:{thread_id}" - - def test_init_auto_generate_thread_id(self): - """Test initialization with auto-generated thread ID.""" - with patch("agent_framework_redis._chat_message_store.redis.from_url"): - store = RedisChatMessageStore(redis_url="redis://localhost:6379") - - assert store.thread_id is not None - assert store.thread_id.startswith("thread_") - assert len(store.thread_id) > 10 # Should be a UUID - - def test_init_with_custom_prefix(self): - """Test initialization with custom key prefix.""" - with patch("agent_framework_redis._chat_message_store.redis.from_url"): - store = RedisChatMessageStore( - redis_url="redis://localhost:6379", thread_id="test123", key_prefix="custom_messages" - ) - - assert store.redis_key == "custom_messages:test123" - - def test_init_with_max_messages(self): - """Test initialization with message limit.""" - with patch("agent_framework_redis._chat_message_store.redis.from_url"): - store = RedisChatMessageStore(redis_url="redis://localhost:6379", thread_id="test123", max_messages=100) - - assert store.max_messages == 100 - - def test_init_with_redis_url_required(self): - """Test that either redis_url or credential_provider is required.""" - with pytest.raises(ValueError, match="Either redis_url or credential_provider must be provided"): - RedisChatMessageStore(thread_id="test123") - - def test_init_with_credential_provider(self): - """Test initialization with credential_provider.""" - mock_credential_provider = MagicMock() - - with patch("agent_framework_redis._chat_message_store.redis.Redis") as mock_redis_class: - mock_redis_instance = MagicMock() - mock_redis_class.return_value = mock_redis_instance - - store = RedisChatMessageStore( - credential_provider=mock_credential_provider, - host="myredis.redis.cache.windows.net", - thread_id="test123", - ) - - # Verify Redis.Redis was called with correct parameters - mock_redis_class.assert_called_once_with( - host="myredis.redis.cache.windows.net", - port=6380, - ssl=True, - username=None, - credential_provider=mock_credential_provider, - decode_responses=True, - ) - # Verify store instance is properly initialized - assert store.thread_id == "test123" - assert store.redis_url is None # Should be None for credential provider auth - assert store.key_prefix == "chat_messages" - assert store.max_messages is None - - def test_init_with_credential_provider_custom_port(self): - """Test initialization with credential_provider and custom port.""" - mock_credential_provider = MagicMock() - - with patch("agent_framework_redis._chat_message_store.redis.Redis") as mock_redis_class: - mock_redis_instance = MagicMock() - mock_redis_class.return_value = mock_redis_instance - - store = RedisChatMessageStore( - credential_provider=mock_credential_provider, - host="myredis.redis.cache.windows.net", - port=6379, - ssl=False, - username="admin", - thread_id="test123", - ) - - # Verify custom parameters were passed - mock_redis_class.assert_called_once_with( - host="myredis.redis.cache.windows.net", - port=6379, - ssl=False, - username="admin", - credential_provider=mock_credential_provider, - decode_responses=True, - ) - # Verify store instance is properly initialized - assert store.thread_id == "test123" - assert store.redis_url is None # Should be None for credential provider auth - assert store.key_prefix == "chat_messages" - - def test_init_credential_provider_requires_host(self): - """Test that credential_provider requires host parameter.""" - mock_credential_provider = MagicMock() - - with pytest.raises(ValueError, match="host is required when using credential_provider"): - RedisChatMessageStore( - credential_provider=mock_credential_provider, - thread_id="test123", - ) - - def test_init_mutually_exclusive_params(self): - """Test that redis_url and credential_provider are mutually exclusive.""" - mock_credential_provider = MagicMock() - - with pytest.raises(ValueError, match="redis_url and credential_provider are mutually exclusive"): - RedisChatMessageStore( - redis_url="redis://localhost:6379", - credential_provider=mock_credential_provider, - host="myredis.redis.cache.windows.net", - thread_id="test123", - ) - - async def test_serialize_with_credential_provider(self): - """Test that serialization works correctly with credential provider authentication.""" - mock_credential_provider = MagicMock() - - with patch("agent_framework_redis._chat_message_store.redis.Redis") as mock_redis_class: - mock_redis_instance = MagicMock() - mock_redis_class.return_value = mock_redis_instance - - store = RedisChatMessageStore( - credential_provider=mock_credential_provider, - host="myredis.redis.cache.windows.net", - thread_id="test123", - key_prefix="custom_prefix", - max_messages=100, - ) - - # Serialize the store state - state = await store.serialize() - - # Verify serialization includes correct values - assert state["thread_id"] == "test123" - assert state["redis_url"] is None # Should be None for credential provider auth - assert state["key_prefix"] == "custom_prefix" - assert state["max_messages"] == 100 - assert state["type"] == "redis_store_state" - - def test_init_with_initial_messages(self, sample_messages): - """Test initialization with initial messages.""" - with patch("agent_framework_redis._chat_message_store.redis.from_url"): - store = RedisChatMessageStore( - redis_url="redis://localhost:6379", thread_id="test123", messages=sample_messages - ) - - assert store._initial_messages == sample_messages - - async def test_add_messages_single(self, redis_store, mock_redis_client, sample_messages): - """Test adding a single message using pipeline operations.""" - message = sample_messages[0] - - await redis_store.add_messages([message]) - - # Verify pipeline operations were called - mock_redis_client.pipeline.assert_called_with(transaction=True) - - # Get the pipeline mock and verify it was used correctly - pipeline_mock = mock_redis_client.pipeline.return_value.__aenter__.return_value - pipeline_mock.rpush.assert_called() - pipeline_mock.execute.assert_called() - - async def test_add_messages_multiple(self, redis_store, mock_redis_client, sample_messages): - """Test adding multiple messages using pipeline operations.""" - await redis_store.add_messages(sample_messages) - - # Verify pipeline operations - mock_redis_client.pipeline.assert_called_with(transaction=True) - - # Verify rpush was called for each message - pipeline_mock = mock_redis_client.pipeline.return_value.__aenter__.return_value - assert pipeline_mock.rpush.call_count == len(sample_messages) - - async def test_add_messages_with_max_limit(self, mock_redis_client): - """Test adding messages with max limit triggers trimming.""" - with patch("agent_framework_redis._chat_message_store.redis.from_url") as mock_from_url: - mock_from_url.return_value = mock_redis_client - - # Mock llen to return count that exceeds limit after adding - mock_redis_client.llen.return_value = 5 - - store = RedisChatMessageStore(redis_url="redis://localhost:6379", thread_id="test123", max_messages=3) - store._redis_client = mock_redis_client - - message = ChatMessage(role="user", text="Test") - await store.add_messages([message]) - - # Should trim after adding to keep only last 3 messages - mock_redis_client.ltrim.assert_called_once_with("chat_messages:test123", -3, -1) - - async def test_list_messages_empty(self, redis_store, mock_redis_client): - """Test listing messages when store is empty.""" - mock_redis_client.lrange.return_value = [] - - messages = await redis_store.list_messages() - - assert messages == [] - mock_redis_client.lrange.assert_called_once_with("chat_messages:test_thread_123", 0, -1) - - async def test_list_messages_with_data(self, redis_store, mock_redis_client, sample_messages): - """Test listing messages with data in Redis.""" - # Create proper serialized messages using the actual serialization method - test_messages = [ - ChatMessage(role="user", text="Hello", message_id="msg1"), - ChatMessage(role="assistant", text="Hi there!", message_id="msg2"), - ] - serialized_messages = [redis_store._serialize_message(msg) for msg in test_messages] - mock_redis_client.lrange.return_value = serialized_messages - - messages = await redis_store.list_messages() - - assert len(messages) == 2 - assert messages[0].role == "user" - assert messages[0].text == "Hello" - assert messages[1].role == "assistant" - assert messages[1].text == "Hi there!" - - async def test_list_messages_with_initial_messages(self, sample_messages): - """Test that initial messages are added to Redis and retrieved correctly.""" - with patch("agent_framework_redis._chat_message_store.redis.from_url") as mock_from_url: - mock_redis_client = MagicMock() - mock_redis_client.llen = AsyncMock(return_value=0) # Redis key is empty - mock_redis_client.lrange = AsyncMock(return_value=[]) - - # Mock pipeline for adding initial messages - mock_pipeline = AsyncMock() - mock_pipeline.rpush = AsyncMock() - mock_pipeline.execute = AsyncMock() - mock_redis_client.pipeline.return_value.__aenter__.return_value = mock_pipeline - - mock_from_url.return_value = mock_redis_client - - store = RedisChatMessageStore( - redis_url="redis://localhost:6379", - thread_id="test123", - messages=sample_messages[:1], # One initial message - ) - store._redis_client = mock_redis_client - - # Mock Redis to return the initial message after it's added - initial_message_json = store._serialize_message(sample_messages[0]) - mock_redis_client.lrange.return_value = [initial_message_json] - - messages = await store.list_messages() - - assert len(messages) == 1 - assert messages[0].text == "Hello" - # Verify initial message was added to Redis via pipeline - mock_pipeline.rpush.assert_called() - - async def test_initial_messages_not_added_if_key_exists(self, sample_messages): - """Test that initial messages are not added if Redis key already has data.""" - with patch("agent_framework_redis._chat_message_store.redis.from_url") as mock_from_url: - mock_redis_client = MagicMock() - mock_redis_client.llen = AsyncMock(return_value=5) # Key already has messages - mock_redis_client.lrange = AsyncMock(return_value=[]) - - # Pipeline should not be called since key already exists - mock_pipeline = AsyncMock() - mock_pipeline.rpush = AsyncMock() - mock_pipeline.execute = AsyncMock() - mock_redis_client.pipeline.return_value.__aenter__.return_value = mock_pipeline - - mock_from_url.return_value = mock_redis_client - - store = RedisChatMessageStore( - redis_url="redis://localhost:6379", - thread_id="test123", - messages=sample_messages[:1], # One initial message - ) - store._redis_client = mock_redis_client - - await store.list_messages() - - # Should check length but not add messages since key exists - mock_redis_client.llen.assert_called() - mock_pipeline.rpush.assert_not_called() - - async def test_serialize_state(self, redis_store): - """Test state serialization.""" - state = await redis_store.serialize() - - expected_state = { - "type": "redis_store_state", - "thread_id": "test_thread_123", - "redis_url": "redis://localhost:6379", - "key_prefix": "chat_messages", - "max_messages": None, - } - - assert state == expected_state - - async def test_deserialize_state(self, redis_store): - """Test state deserialization.""" - serialized_state = { - "thread_id": "restored_thread_456", - "redis_url": "redis://localhost:6380", - "key_prefix": "restored_messages", - "max_messages": 50, - } - - await redis_store.update_from_state(serialized_state) - - assert redis_store.thread_id == "restored_thread_456" - assert redis_store.redis_url == "redis://localhost:6380" - assert redis_store.key_prefix == "restored_messages" - assert redis_store.max_messages == 50 - - async def test_deserialize_state_empty(self, redis_store): - """Test deserializing empty state doesn't change anything.""" - original_thread_id = redis_store.thread_id - - await redis_store.update_from_state(None) - - assert redis_store.thread_id == original_thread_id - - async def test_clear_messages(self, redis_store, mock_redis_client): - """Test clearing all messages.""" - await redis_store.clear() - - mock_redis_client.delete.assert_called_once_with("chat_messages:test_thread_123") - - async def test_message_serialization_roundtrip(self, sample_messages): - """Test message serialization and deserialization roundtrip.""" - with patch("agent_framework_redis._chat_message_store.redis.from_url"): - store = RedisChatMessageStore(redis_url="redis://localhost:6379", thread_id="test123") - - message = sample_messages[0] - - # Test serialization - serialized = store._serialize_message(message) - assert isinstance(serialized, str) - - # Test deserialization - deserialized = store._deserialize_message(serialized) - assert deserialized.role == message.role - assert deserialized.text == message.text - assert deserialized.message_id == message.message_id - - async def test_message_serialization_with_complex_content(self): - """Test serialization of messages with complex content.""" - with patch("agent_framework_redis._chat_message_store.redis.from_url"): - store = RedisChatMessageStore(redis_url="redis://localhost:6379", thread_id="test123") - - # Message with multiple content types - message = ChatMessage( - role="assistant", - contents=[Content.from_text(text="Hello"), Content.from_text(text="World")], - author_name="TestBot", - message_id="complex_msg", - additional_properties={"metadata": "test"}, - ) - - serialized = store._serialize_message(message) - deserialized = store._deserialize_message(serialized) - - assert deserialized.role == "assistant" - assert deserialized.text == "Hello World" - assert deserialized.author_name == "TestBot" - assert deserialized.message_id == "complex_msg" - assert deserialized.additional_properties == {"metadata": "test"} - - async def test_redis_connection_error_handling(self): - """Test handling Redis connection errors in add_messages.""" - with patch("agent_framework_redis._chat_message_store.redis.from_url") as mock_from_url: - mock_client = MagicMock() - - # Mock pipeline to raise exception during execution - mock_pipeline = AsyncMock() - mock_pipeline.rpush = AsyncMock() - mock_pipeline.execute = AsyncMock(side_effect=Exception("Connection failed")) - mock_client.pipeline.return_value.__aenter__.return_value = mock_pipeline - - mock_from_url.return_value = mock_client - - store = RedisChatMessageStore(redis_url="redis://localhost:6379", thread_id="test123") - store._redis_client = mock_client - - message = ChatMessage(role="user", text="Test") - - # Should propagate Redis connection errors - with pytest.raises(Exception, match="Connection failed"): - await store.add_messages([message]) - - async def test_getitem(self, redis_store, mock_redis_client, sample_messages): - """Test getitem method using Redis LINDEX.""" - # Mock LINDEX to return specific messages - serialized_msg0 = redis_store._serialize_message(sample_messages[0]) - serialized_msg1 = redis_store._serialize_message(sample_messages[1]) - - def mock_lindex(key, index): - if index == 0: - return serialized_msg0 - if index == -1 or index == 1: - return serialized_msg1 - return None - - mock_redis_client.lindex = AsyncMock(side_effect=mock_lindex) - - # Test positive index - message = await redis_store.getitem(0) - assert message.text == "Hello" - - # Test negative index - message = await redis_store.getitem(-1) - assert message.text == "Hi there!" - - async def test_getitem_index_error(self, redis_store, mock_redis_client): - """Test getitem raises IndexError for invalid index.""" - mock_redis_client.lindex = AsyncMock(return_value=None) - - with pytest.raises(IndexError): - await redis_store.getitem(0) - - async def test_setitem(self, redis_store, mock_redis_client, sample_messages): - """Test setitem method using Redis LSET.""" - mock_redis_client.llen.return_value = 2 - mock_redis_client.lset = AsyncMock() - - new_message = ChatMessage(role="user", text="Updated message") - await redis_store.setitem(0, new_message) - - mock_redis_client.lset.assert_called_once() - call_args = mock_redis_client.lset.call_args - assert call_args[0][0] == "chat_messages:test_thread_123" - assert call_args[0][1] == 0 - - async def test_setitem_index_error(self, redis_store, mock_redis_client): - """Test setitem raises IndexError for invalid index.""" - mock_redis_client.llen.return_value = 0 - - new_message = ChatMessage(role="user", text="Test") - with pytest.raises(IndexError): - await redis_store.setitem(0, new_message) - - async def test_append(self, redis_store, mock_redis_client): - """Test append method delegates to add_messages.""" - message = ChatMessage(role="user", text="Appended message") - await redis_store.append(message) - - # Should call pipeline operations via add_messages - mock_redis_client.pipeline.assert_called_with(transaction=True) - - # Verify the message was added via pipeline - pipeline_mock = mock_redis_client.pipeline.return_value.__aenter__.return_value - pipeline_mock.rpush.assert_called() - pipeline_mock.execute.assert_called() - - async def test_count(self, redis_store, mock_redis_client): - """Test count method.""" - mock_redis_client.llen.return_value = 5 - - count = await redis_store.count() - - assert count == 5 - mock_redis_client.llen.assert_called_with("chat_messages:test_thread_123") - - async def test_len_method(self, redis_store, mock_redis_client): - """Test async __len__ method.""" - mock_redis_client.llen.return_value = 3 - - length = await redis_store.__len__() - - assert length == 3 - mock_redis_client.llen.assert_called_with("chat_messages:test_thread_123") - - def test_bool_method(self, redis_store): - """Test __bool__ method always returns True.""" - # Store should always be truthy - assert bool(redis_store) is True - assert redis_store.__bool__() is True - - # Should work in if statements (this is what Agent Framework uses) - if redis_store: - assert True # Should reach this - else: - raise AssertionError("Store should be truthy") - - async def test_index_found(self, redis_store, mock_redis_client, sample_messages): - """Test index method when message is found using Redis LINDEX.""" - mock_redis_client.llen.return_value = 2 - - # Mock LINDEX to return messages at each position - serialized_msg0 = redis_store._serialize_message(sample_messages[0]) - serialized_msg1 = redis_store._serialize_message(sample_messages[1]) - - def mock_lindex(key, index): - if index == 0: - return serialized_msg0 - if index == 1: - return serialized_msg1 - return None - - mock_redis_client.lindex = AsyncMock(side_effect=mock_lindex) - - index = await redis_store.index(sample_messages[1]) - assert index == 1 - - # Should have called lindex twice (index 0, then index 1) - assert mock_redis_client.lindex.call_count == 2 - - async def test_index_not_found(self, redis_store, mock_redis_client, sample_messages): - """Test index method when message is not found.""" - mock_redis_client.llen.return_value = 1 - mock_redis_client.lindex = AsyncMock(return_value="different_message") - - with pytest.raises(ValueError, match="ChatMessage not found in store"): - await redis_store.index(sample_messages[0]) - - async def test_remove(self, redis_store, mock_redis_client, sample_messages): - """Test remove method using Redis LREM.""" - mock_redis_client.lrem = AsyncMock(return_value=1) # 1 element removed - - await redis_store.remove(sample_messages[0]) - - # Should use LREM to remove the message - expected_serialized = redis_store._serialize_message(sample_messages[0]) - mock_redis_client.lrem.assert_called_once_with("chat_messages:test_thread_123", 1, expected_serialized) - - async def test_remove_not_found(self, redis_store, mock_redis_client, sample_messages): - """Test remove method when message is not found.""" - mock_redis_client.lrem = AsyncMock(return_value=0) # 0 elements removed - - with pytest.raises(ValueError, match="ChatMessage not found in store"): - await redis_store.remove(sample_messages[0]) - - async def test_extend(self, redis_store, mock_redis_client, sample_messages): - """Test extend method delegates to add_messages.""" - await redis_store.extend(sample_messages[:2]) - - # Should call pipeline operations via add_messages - mock_redis_client.pipeline.assert_called_with(transaction=True) - - # Verify rpush was called for each message - pipeline_mock = mock_redis_client.pipeline.return_value.__aenter__.return_value - assert pipeline_mock.rpush.call_count >= 2 - - async def test_serialize_with_agent_thread(self, redis_store, sample_messages): - """Test that RedisChatMessageStore can be serialized within an AgentThread. - - This test verifies the fix for issue #1991 where calling thread.serialize() - with a RedisChatMessageStore would fail with "Messages should be a list" error. - """ - from agent_framework import AgentThread - - thread = AgentThread(message_store=redis_store) - await thread.on_new_messages(sample_messages) - - serialized = await thread.serialize() - - assert serialized is not None - assert "chat_message_store_state" in serialized - assert serialized["chat_message_store_state"] is not None diff --git a/python/packages/redis/tests/test_redis_provider.py b/python/packages/redis/tests/test_redis_provider.py deleted file mode 100644 index 41ce7b37b8..0000000000 --- a/python/packages/redis/tests/test_redis_provider.py +++ /dev/null @@ -1,425 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import numpy as np -import pytest -from agent_framework import ChatMessage -from agent_framework.exceptions import AgentException, ServiceInitializationError -from redisvl.utils.vectorize import CustomTextVectorizer - -from agent_framework_redis import RedisProvider - -CUSTOM_VECTORIZER = CustomTextVectorizer(embed=lambda x: [1.0, 2.0, 3.0], dtype="float32") - - -@pytest.fixture -def mock_index() -> AsyncMock: - idx = AsyncMock() - idx.create = AsyncMock() - idx.load = AsyncMock() - idx.query = AsyncMock() - idx.exists = AsyncMock(return_value=False) - - async def _paginate_generator(*_args: Any, **_kwargs: Any): - # Default empty generator; override per-test as needed - if False: # pragma: no cover - yield [] - return - - idx.paginate = _paginate_generator - return idx - - -@pytest.fixture -def patch_index_from_dict(mock_index: AsyncMock): - with patch("agent_framework_redis._provider.AsyncSearchIndex") as mock_cls: - mock_cls.from_dict = MagicMock(return_value=mock_index) - - # Mock from_existing to return a mock with matching schema by default - # This prevents schema validation errors in tests that don't specifically test schema validation - async def mock_from_existing(index_name, redis_url): - mock_existing = AsyncMock() - # Return a schema that will match whatever the provider generates - # This is a bit of a hack, but allows existing tests to continue working - mock_existing.schema.to_dict = MagicMock( - side_effect=lambda: mock_cls.from_dict.call_args[0][0] if mock_cls.from_dict.call_args else {} - ) - return mock_existing - - mock_cls.from_existing = AsyncMock(side_effect=mock_from_existing) - - yield mock_cls - - -@pytest.fixture -def patch_queries(): - calls: dict[str, Any] = {"TextQuery": [], "HybridQuery": [], "FilterExpression": []} - - def _mk_query(kind: str): - class _Q: # simple marker object with captured kwargs - def __init__(self, **kwargs): - self.kind = kind - self.kwargs = kwargs - - return _Q - - with ( - patch( - "agent_framework_redis._provider.TextQuery", - side_effect=lambda **k: calls["TextQuery"].append(k) or _mk_query("text")(**k), - ) as text_q, - patch( - "agent_framework_redis._provider.HybridQuery", - side_effect=lambda **k: calls["HybridQuery"].append(k) or _mk_query("hybrid")(**k), - ) as hybrid_q, - patch( - "agent_framework_redis._provider.FilterExpression", - side_effect=lambda s: calls["FilterExpression"].append(s) or ("FE", s), - ) as filt, - ): - yield {"calls": calls, "TextQuery": text_q, "HybridQuery": hybrid_q, "FilterExpression": filt} - - -class TestRedisProviderInitialization: - # Verifies the provider can be imported from the package - def test_import(self): - from agent_framework_redis._provider import RedisProvider - - assert RedisProvider is not None - - # Constructing without filters should not raise; filters are enforced at call-time - def test_init_without_filters_ok(self, patch_index_from_dict): # noqa: ARG002 - provider = RedisProvider() - assert provider.user_id is None - assert provider.agent_id is None - assert provider.application_id is None - assert provider.thread_id is None - - # Schema should omit vector field when no vector configuration is provided - def test_schema_without_vector_field(self, patch_index_from_dict): - RedisProvider(user_id="u1") - # Inspect schema passed to from_dict - args, kwargs = patch_index_from_dict.from_dict.call_args - schema = args[0] - assert isinstance(schema, dict) - names = [f["name"] for f in schema["fields"]] - types = [f["type"] for f in schema["fields"]] - assert "content" in names - assert "text" in types - assert "vector" not in types - - -class TestRedisProviderMessages: - @pytest.fixture - def sample_messages(self) -> list[ChatMessage]: - return [ - ChatMessage(role="user", text="Hello, how are you?"), - ChatMessage(role="assistant", text="I'm doing well, thank you!"), - ChatMessage(role="system", text="You are a helpful assistant"), - ] - - # Writes require at least one scoping filter to avoid unbounded operations - async def test_messages_adding_requires_filters(self, patch_index_from_dict): # noqa: ARG002 - provider = RedisProvider() - with pytest.raises(ServiceInitializationError): - await provider.invoked("thread123", ChatMessage(role="user", text="Hello")) - - # Captures the per-operation thread id when provided - async def test_thread_created_sets_per_operation_id(self, patch_index_from_dict): # noqa: ARG002 - provider = RedisProvider(user_id="u1") - await provider.thread_created("t1") - assert provider._per_operation_thread_id == "t1" - - # Enforces single-thread usage when scope_to_per_operation_thread_id is True - async def test_thread_created_conflict_when_scoped(self, patch_index_from_dict): # noqa: ARG002 - provider = RedisProvider(user_id="u1", scope_to_per_operation_thread_id=True) - provider._per_operation_thread_id = "t1" - with pytest.raises(ValueError) as exc: - await provider.thread_created("t2") - assert "only be used with one thread" in str(exc.value) - - # Aggregates all results from the async paginator into a flat list - async def test_search_all_paginates(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002 - async def gen(_q, page_size: int = 200): # noqa: ARG001, ANN001 - yield [{"id": 1}] - yield [{"id": 2}, {"id": 3}] - - mock_index.paginate = gen - provider = RedisProvider(user_id="u1") - res = await provider.search_all(page_size=2) - assert res == [{"id": 1}, {"id": 2}, {"id": 3}] - - -class TestRedisProviderModelInvoking: - # Reads require at least one scoping filter to avoid unbounded operations - async def test_model_invoking_requires_filters(self, patch_index_from_dict): # noqa: ARG002 - provider = RedisProvider() - with pytest.raises(ServiceInitializationError): - await provider.invoking(ChatMessage(role="user", text="Hi")) - - # Ensures text-only search path is used and context is composed from hits - async def test_textquery_path_and_context_contents( - self, mock_index: AsyncMock, patch_index_from_dict, patch_queries - ): # noqa: ARG002 - # Arrange: text-only search - mock_index.query = AsyncMock(return_value=[{"content": "A"}, {"content": "B"}]) - provider = RedisProvider(user_id="u1") - - # Act - ctx = await provider.invoking([ChatMessage(role="user", text="q1")]) - - # Assert: TextQuery used (not HybridQuery), filter_expression included - assert patch_queries["TextQuery"].call_count == 1 - assert patch_queries["HybridQuery"].call_count == 0 - kwargs = patch_queries["calls"]["TextQuery"][0] - assert kwargs["text"] == "q1" - assert kwargs["text_field_name"] == "content" - assert kwargs["num_results"] == 10 - assert "filter_expression" in kwargs - - # Context contains memories joined after the default prompt - assert ctx.messages is not None and len(ctx.messages) == 1 - text = ctx.messages[0].text - assert text.endswith("A\nB") - - # When no results are returned, Context should have no contents - async def test_model_invoking_empty_results_returns_empty_context( - self, mock_index: AsyncMock, patch_index_from_dict, patch_queries - ): # noqa: ARG002 - mock_index.query = AsyncMock(return_value=[]) - provider = RedisProvider(user_id="u1") - ctx = await provider.invoking([ChatMessage(role="user", text="any")]) - assert ctx.messages == [] - - # Ensures hybrid vector-text search is used when a vectorizer and vector field are configured - async def test_hybridquery_path_with_vectorizer(self, mock_index: AsyncMock, patch_index_from_dict, patch_queries): # noqa: ARG002 - mock_index.query = AsyncMock(return_value=[{"content": "Hit"}]) - provider = RedisProvider(user_id="u1", redis_vectorizer=CUSTOM_VECTORIZER, vector_field_name="vec") - - ctx = await provider.invoking([ChatMessage(role="user", text="hello")]) - - # Assert: HybridQuery used with vector and vector field - assert patch_queries["HybridQuery"].call_count == 1 - k = patch_queries["calls"]["HybridQuery"][0] - assert k["text"] == "hello" - assert k["vector_field_name"] == "vec" - assert k["vector"] == [1.0, 2.0, 3.0] - assert k["dtype"] == "float32" - assert k["num_results"] == 10 - assert "filter_expression" in k - - # Context assembled from returned memories - assert ctx.messages and "Hit" in ctx.messages[0].text - - -class TestRedisProviderContextManager: - # Verifies async context manager returns self for chaining - async def test_async_context_manager_returns_self(self, patch_index_from_dict): # noqa: ARG002 - provider = RedisProvider(user_id="u1") - async with provider as ctx: - assert ctx is provider - - # Exit should be a no-op and not raise - async def test_aexit_noop(self, patch_index_from_dict): # noqa: ARG002 - provider = RedisProvider(user_id="u1") - assert await provider.__aexit__(None, None, None) is None - - -class TestMessagesAddingBehavior: - # Adds messages while injecting partition defaults and preserving allowed roles - async def test_messages_adding_adds_partition_defaults_and_roles( - self, mock_index: AsyncMock, patch_index_from_dict - ): # noqa: ARG002 - provider = RedisProvider( - application_id="app", - agent_id="agent", - user_id="u1", - scope_to_per_operation_thread_id=True, - ) - - msgs = [ - ChatMessage(role="user", text="u"), - ChatMessage(role="assistant", text="a"), - ChatMessage(role="system", text="s"), - ] - - await provider.invoked(msgs) - - # Ensure load invoked with shaped docs containing defaults - assert mock_index.load.await_count == 1 - (loaded_args, _kwargs) = mock_index.load.call_args - docs = loaded_args[0] - assert isinstance(docs, list) and len(docs) == 3 - for d in docs: - assert d["role"] in {"user", "assistant", "system"} - assert d["content"] in {"u", "a", "s"} - assert d["application_id"] == "app" - assert d["agent_id"] == "agent" - assert d["user_id"] == "u1" - - # Skips blank text and disallowed roles (e.g., TOOL) when adding messages - async def test_messages_adding_ignores_blank_and_disallowed_roles( - self, mock_index: AsyncMock, patch_index_from_dict - ): # noqa: ARG002 - provider = RedisProvider(user_id="u1", scope_to_per_operation_thread_id=True) - msgs = [ - ChatMessage(role="user", text=" "), - ChatMessage(role="tool", text="tool output"), - ] - await provider.invoked(msgs) - # No valid messages -> no load - assert mock_index.load.await_count == 0 - - -class TestIndexCreationPublicCalls: - # Ensures index is created only once when drop=True on first public write call - async def test_messages_adding_triggers_index_create_once_when_drop_true( - self, mock_index: AsyncMock, patch_index_from_dict - ): # noqa: ARG002 - provider = RedisProvider(user_id="u1") - await provider.invoked(ChatMessage(role="user", text="m1")) - await provider.invoked(ChatMessage(role="user", text="m2")) - # create only on first call - assert mock_index.create.await_count == 1 - - # Ensures index is created when drop=False and the index does not exist on first read - async def test_model_invoking_triggers_create_when_drop_false_and_not_exists( - self, mock_index: AsyncMock, patch_index_from_dict - ): # noqa: ARG002 - mock_index.exists = AsyncMock(return_value=False) - provider = RedisProvider(user_id="u1") - mock_index.query = AsyncMock(return_value=[{"content": "C"}]) - await provider.invoking([ChatMessage(role="user", text="q")]) - assert mock_index.create.await_count == 1 - - -class TestThreadCreatedAdditional: - # Allows None or same thread id repeatedly; different id raises when scoped - async def test_thread_created_allows_none_and_same_id(self, patch_index_from_dict): # noqa: ARG002 - provider = RedisProvider(user_id="u1", scope_to_per_operation_thread_id=True) - # None is allowed - await provider.thread_created(None) - # Same id is allowed repeatedly - await provider.thread_created("t1") - await provider.thread_created("t1") - # Different id should raise - with pytest.raises(ValueError): - await provider.thread_created("t2") - - -class TestVectorPopulation: - # When vectorizer configured, invoked should embed content and populate the vector field - async def test_messages_adding_populates_vector_field_when_vectorizer_present( - self, mock_index: AsyncMock, patch_index_from_dict - ): # noqa: ARG002 - provider = RedisProvider( - user_id="u1", - scope_to_per_operation_thread_id=True, - redis_vectorizer=CUSTOM_VECTORIZER, - vector_field_name="vec", - ) - - await provider.invoked(ChatMessage(role="user", text="hello")) - assert mock_index.load.await_count == 1 - (loaded_args, _kwargs) = mock_index.load.call_args - docs = loaded_args[0] - assert isinstance(docs, list) and len(docs) == 1 - vec = docs[0].get("vec") - assert isinstance(vec, (bytes, bytearray)) - assert len(vec) == 3 * np.dtype(np.float32).itemsize - - -class TestRedisProviderSchemaVectors: - # Adds a vector field when vectorizer supplies dims implicitly - def test_schema_with_vector_field_and_dims_inferred(self, patch_index_from_dict): # noqa: ARG002 - RedisProvider(user_id="u1", redis_vectorizer=CUSTOM_VECTORIZER, vector_field_name="vec") - args, _ = patch_index_from_dict.from_dict.call_args - schema = args[0] - names = [f["name"] for f in schema["fields"]] - types = {f["name"]: f["type"] for f in schema["fields"]} - assert "vec" in names - assert types["vec"] == "vector" - - # Raises when redis_vectorizer is not the correct type - def test_init_invalid_vectorizer(self, patch_index_from_dict): # noqa: ARG002 - class DummyVectorizer: - pass - - with pytest.raises(AgentException): - RedisProvider(user_id="u1", redis_vectorizer=DummyVectorizer(), vector_field_name="vec") - - -class TestEnsureIndex: - # Creates index once and marks _index_initialized to prevent duplicate calls - async def test_ensure_index_creates_once(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002 - # Mock index doesn't exist, so it will be created - mock_index.exists = AsyncMock(return_value=False) - provider = RedisProvider(user_id="u1", overwrite_index=False) - - assert provider._index_initialized is False - await provider._ensure_index() - assert mock_index.create.await_count == 1 - assert provider._index_initialized is True - - # Second call should not create again due to _index_initialized flag - await provider._ensure_index() - assert mock_index.create.await_count == 1 - - # Creates index with overwrite=True when overwrite_index=True - async def test_ensure_index_with_overwrite_true(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002 - mock_index.exists = AsyncMock(return_value=True) - provider = RedisProvider(user_id="u1", overwrite_index=True) - - await provider._ensure_index() - - # Should call create with overwrite=True, drop=False - mock_index.create.assert_called_once_with(overwrite=True, drop=False) - - # Creates index with overwrite=False when index doesn't exist - async def test_ensure_index_create_if_missing(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002 - mock_index.exists = AsyncMock(return_value=False) - provider = RedisProvider(user_id="u1", overwrite_index=False) - - await provider._ensure_index() - - # Should call create with overwrite=False, drop=False - mock_index.create.assert_called_once_with(overwrite=False, drop=False) - - # Validates schema compatibility when index exists and overwrite=False - async def test_ensure_index_schema_validation_success(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002 - mock_index.exists = AsyncMock(return_value=True) - provider = RedisProvider(user_id="u1", overwrite_index=False) - - # Mock existing index with matching schema - expected_schema = provider.schema_dict - patch_index_from_dict.from_existing.return_value.schema.to_dict.return_value = expected_schema - - await provider._ensure_index() - - # Should validate schema and proceed to create - patch_index_from_dict.from_existing.assert_called_once_with("context", redis_url="redis://localhost:6379") - mock_index.create.assert_called_once_with(overwrite=False, drop=False) - - # Raises ServiceInitializationError when schemas don't match - async def test_ensure_index_schema_validation_failure(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002 - mock_index.exists = AsyncMock(return_value=True) - provider = RedisProvider(user_id="u1", overwrite_index=False) - - # Override the mock to return a different schema after provider is created - async def mock_from_existing_different(index_name, redis_url): - mock_existing = AsyncMock() - mock_existing.schema.to_dict = MagicMock(return_value={"different": "schema"}) - return mock_existing - - patch_index_from_dict.from_existing = AsyncMock(side_effect=mock_from_existing_different) - - with pytest.raises(ServiceInitializationError) as exc: - await provider._ensure_index() - - assert "incompatible with the current configuration" in str(exc.value) - assert "overwrite_index=True" in str(exc.value) - - # Should not call create when schema validation fails - mock_index.create.assert_not_called() diff --git a/python/pyproject.toml b/python/pyproject.toml index af88ff92db..3a5a841082 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260130" +version = "1.0.0b260212" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core[all]==1.0.0b260130", + "agent-framework-core[all]==1.0.0b260212", ] [dependency-groups] @@ -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/pyrightconfig.samples.json b/python/pyrightconfig.samples.json index a74e252474..4e77569f94 100644 --- a/python/pyrightconfig.samples.json +++ b/python/pyrightconfig.samples.json @@ -5,7 +5,10 @@ "**/autogen-migration/**", "**/semantic-kernel-migration/**", "**/demos/**", - "**/agent_with_foundry_tracing.py" + "**/_to_delete/**", + "**/05-end-to-end/**", + "**/agent_with_foundry_tracing.py", + "**/azure_responses_client_with_foundry.py" ], "typeCheckingMode": "off", "reportMissingImports": "error", diff --git a/python/samples/01-get-started/01_hello_agent.py b/python/samples/01-get-started/01_hello_agent.py new file mode 100644 index 0000000000..a3353a5e87 --- /dev/null +++ b/python/samples/01-get-started/01_hello_agent.py @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +from agent_framework.azure import AzureOpenAIResponsesClient +from azure.identity import AzureCliCredential + +""" +Hello Agent — Simplest possible agent + +This sample creates a minimal agent using AzureOpenAIResponsesClient via an +Azure AI Foundry project endpoint, and runs it in both non-streaming and streaming modes. + +Environment variables: + AZURE_AI_PROJECT_ENDPOINT — Your Azure AI Foundry project endpoint + AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME — Model deployment name (e.g. gpt-4o) +""" + + +async def main() -> None: + # + credential = AzureCliCredential() + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + credential=credential, + ) + + agent = client.as_agent( + name="HelloAgent", + instructions="You are a friendly assistant. Keep your answers brief.", + ) + # + + # + # Non-streaming: get the complete response at once + result = await agent.run("What is the capital of France?") + print(f"Agent: {result}") + # + + # + # Streaming: receive tokens as they are generated + print("Agent (streaming): ", end="", flush=True) + async for chunk in agent.run("Tell me a one-sentence fun fact.", stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print() + # + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/01-get-started/02_add_tools.py b/python/samples/01-get-started/02_add_tools.py new file mode 100644 index 0000000000..04045de16c --- /dev/null +++ b/python/samples/01-get-started/02_add_tools.py @@ -0,0 +1,61 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from random import randint +from typing import Annotated + +from agent_framework import tool +from agent_framework.azure import AzureOpenAIResponsesClient +from azure.identity import AzureCliCredential +from pydantic import Field + +""" +Add Tools — Give your agent a function tool + +This sample shows how to define a function tool with the @tool decorator +and wire it into an agent so the model can call it. + +Environment variables: + AZURE_AI_PROJECT_ENDPOINT — Your Azure AI Foundry project endpoint + AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME — Model deployment name (e.g. gpt-4o) +""" + + +# +# NOTE: approval_mode="never_require" is for sample brevity. +# Use "always_require" in production for user confirmation before tool execution. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." +# + + +async def main() -> None: + credential = AzureCliCredential() + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + credential=credential, + ) + + # + agent = client.as_agent( + name="WeatherAgent", + instructions="You are a helpful weather agent. Use the get_weather tool to answer questions.", + tools=get_weather, + ) + # + + # + result = await agent.run("What's the weather like in Seattle?") + print(f"Agent: {result}") + # + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/01-get-started/03_multi_turn.py b/python/samples/01-get-started/03_multi_turn.py new file mode 100644 index 0000000000..266764c395 --- /dev/null +++ b/python/samples/01-get-started/03_multi_turn.py @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +from agent_framework.azure import AzureOpenAIResponsesClient +from azure.identity import AzureCliCredential + +""" +Multi-Turn Conversations — Use AgentSession to maintain context + +This sample shows how to keep conversation history across multiple calls +by reusing the same session object. + +Environment variables: + AZURE_AI_PROJECT_ENDPOINT — Your Azure AI Foundry project endpoint + AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME — Model deployment name (e.g. gpt-4o) +""" + + +async def main() -> None: + # + credential = AzureCliCredential() + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + credential=credential, + ) + + agent = client.as_agent( + name="ConversationAgent", + instructions="You are a friendly assistant. Keep your answers brief.", + ) + # + + # + # Create a session to maintain conversation history + session = agent.create_session() + + # First turn + result = await agent.run("My name is Alice and I love hiking.", session=session) + print(f"Agent: {result}\n") + + # Second turn — the agent should remember the user's name and hobby + result = await agent.run("What do you remember about me?", session=session) + print(f"Agent: {result}") + # + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/01-get-started/04_memory.py b/python/samples/01-get-started/04_memory.py new file mode 100644 index 0000000000..d35ef21581 --- /dev/null +++ b/python/samples/01-get-started/04_memory.py @@ -0,0 +1,99 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from typing import Any + +from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext +from agent_framework.azure import AzureOpenAIResponsesClient +from azure.identity import AzureCliCredential + +""" +Agent Memory with Context Providers + +Context providers let you inject dynamic instructions and context into each +agent invocation. This sample defines a simple provider that tracks the user's +name and enriches every request with personalization instructions. + +Environment variables: + AZURE_AI_PROJECT_ENDPOINT — Your Azure AI Foundry project endpoint + AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME — Model deployment name (e.g. gpt-4o) +""" + + +# +class UserNameProvider(BaseContextProvider): + """A simple context provider that remembers the user's name.""" + + def __init__(self) -> None: + super().__init__(source_id="user-name-provider") + self.user_name: str | None = None + + async def before_run( + self, + *, + agent: Any, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Called before each agent invocation — add extra instructions.""" + if self.user_name: + context.instructions.append(f"The user's name is {self.user_name}. Always address them by name.") + else: + context.instructions.append("You don't know the user's name yet. Ask for it politely.") + + async def after_run( + self, + *, + agent: Any, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Called after each agent invocation — extract information.""" + for msg in context.input_messages: + text = msg.text if hasattr(msg, "text") else "" + if isinstance(text, str) and "my name is" in text.lower(): + # Simple extraction — production code should use structured extraction + self.user_name = text.lower().split("my name is")[-1].strip().split()[0].capitalize() +# + + +async def main() -> None: + # + credential = AzureCliCredential() + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + credential=credential, + ) + + memory = UserNameProvider() + + agent = client.as_agent( + name="MemoryAgent", + instructions="You are a friendly assistant.", + context_providers=[memory], + ) + # + + session = agent.create_session() + + # The provider doesn't know the user yet — it will ask for a name + result = await agent.run("Hello! What's the square root of 9?", session=session) + print(f"Agent: {result}\n") + + # Now provide the name — the provider extracts and stores it + result = await agent.run("My name is Alice", session=session) + print(f"Agent: {result}\n") + + # Subsequent calls are personalized + result = await agent.run("What is 2 + 2?", session=session) + print(f"Agent: {result}\n") + + print(f"[Memory] Stored user name: {memory.user_name}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/01-get-started/05_first_workflow.py b/python/samples/01-get-started/05_first_workflow.py new file mode 100644 index 0000000000..ffce2e97d8 --- /dev/null +++ b/python/samples/01-get-started/05_first_workflow.py @@ -0,0 +1,72 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import ( + Executor, + WorkflowBuilder, + WorkflowContext, + executor, + handler, +) +from typing_extensions import Never + +""" +First Workflow — Chain executors with edges + +This sample builds a minimal workflow with two steps: +1. Convert text to uppercase (class-based executor) +2. Reverse the text (function-based executor) + +No external services are required. +""" + + +# +# Step 1: A class-based executor that converts text to uppercase +class UpperCase(Executor): + def __init__(self, id: str): + super().__init__(id=id) + + @handler + async def to_upper_case(self, text: str, ctx: WorkflowContext[str]) -> None: + """Convert input to uppercase and forward to the next node.""" + await ctx.send_message(text.upper()) + + +# Step 2: A function-based executor that reverses the string and yields output +@executor(id="reverse_text") +async def reverse_text(text: str, ctx: WorkflowContext[Never, str]) -> None: + """Reverse the string and yield the final workflow output.""" + await ctx.yield_output(text[::-1]) + + +def create_workflow(): + """Build the workflow: UpperCase → reverse_text.""" + upper = UpperCase(id="upper_case") + return ( + WorkflowBuilder(start_executor=upper) + .add_edge(upper, reverse_text) + .build() + ) +# + + +async def main() -> None: + # + workflow = create_workflow() + + events = await workflow.run("hello world") + print(f"Output: {events.get_outputs()}") + print(f"Final state: {events.get_final_state()}") + # + + """ + Expected output: + Output: ['DLROW OLLEH'] + Final state: WorkflowRunState.IDLE + """ + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/01-get-started/06_host_your_agent.py b/python/samples/01-get-started/06_host_your_agent.py new file mode 100644 index 0000000000..916944cdb6 --- /dev/null +++ b/python/samples/01-get-started/06_host_your_agent.py @@ -0,0 +1,60 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +from agent_framework.azure import AzureOpenAIResponsesClient +from azure.identity import AzureCliCredential + +""" +Host Your Agent — Minimal A2A hosting stub + +This sample shows the pattern for exposing an agent via the Agent-to-Agent +(A2A) protocol. It creates the agent and demonstrates how to wrap it with +the A2A hosting layer. + +Prerequisites: + pip install agent-framework[a2a] --pre + +Environment variables: + AZURE_AI_PROJECT_ENDPOINT — Your Azure AI Foundry project endpoint + AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME — Model deployment name (e.g. gpt-4o) + +To run a full A2A server, see samples/04-hosting/a2a/ for a complete example. +""" + + +async def main() -> None: + # + credential = AzureCliCredential() + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + credential=credential, + ) + + agent = client.as_agent( + name="HostedAgent", + instructions="You are a helpful assistant exposed via A2A.", + ) + # + + # + # The A2A hosting integration wraps your agent behind an HTTP endpoint. + # Import is gated so this sample can run without the a2a extra installed. + try: + from agent_framework.a2a import A2AAgent # noqa: F401 + + print("A2A support is available.") + print("See samples/04-hosting/a2a/ for a runnable A2A server example.") + except ImportError: + print("Install a2a extras: pip install agent-framework[a2a] --pre") + + # Quick smoke-test: run the agent locally to verify it works + result = await agent.run("Hello! What can you do?") + print(f"Agent: {result}") + # + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/01-get-started/README.md b/python/samples/01-get-started/README.md new file mode 100644 index 0000000000..f13eeb26c5 --- /dev/null +++ b/python/samples/01-get-started/README.md @@ -0,0 +1,34 @@ +# Get Started with Agent Framework for Python + +This folder contains a progressive set of samples that introduce the core +concepts of **Agent Framework** one step at a time. + +## Prerequisites + +```bash +pip install agent-framework --pre +``` + +Set the required environment variables: + +```bash +export OPENAI_API_KEY="sk-..." +export OPENAI_RESPONSES_MODEL_ID="gpt-4o" # optional, defaults to gpt-4o +``` + +## Samples + +| # | File | What you'll learn | +|---|------|-------------------| +| 1 | [01_hello_agent.py](01_hello_agent.py) | Create your first agent and run it (streaming and non-streaming). | +| 2 | [02_add_tools.py](02_add_tools.py) | Define a function tool with `@tool` and attach it to an agent. | +| 3 | [03_multi_turn.py](03_multi_turn.py) | Keep conversation history across turns with `AgentThread`. | +| 4 | [04_memory.py](04_memory.py) | Add dynamic context with a custom `ContextProvider`. | +| 5 | [05_first_workflow.py](05_first_workflow.py) | Chain executors into a workflow with edges. | +| 6 | [06_host_your_agent.py](06_host_your_agent.py) | Prepare your agent for A2A hosting. | + +Run any sample with: + +```bash +python 01_hello_agent.py +``` diff --git a/python/samples/getting_started/__init__.py b/python/samples/02-agents/__init__.py similarity index 100% rename from python/samples/getting_started/__init__.py rename to python/samples/02-agents/__init__.py diff --git a/python/samples/02-agents/background_responses.py b/python/samples/02-agents/background_responses.py new file mode 100644 index 0000000000..9c04a59f27 --- /dev/null +++ b/python/samples/02-agents/background_responses.py @@ -0,0 +1,139 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Agent +from agent_framework.openai import OpenAIResponsesClient + +"""Background Responses Sample. + +This sample demonstrates long-running agent operations using the OpenAI +Responses API ``background`` option. Two patterns are shown: + +1. **Non-streaming polling** – start a background run, then poll with the + ``continuation_token`` until the operation completes. +2. **Streaming with resumption** – start a background streaming run, simulate + an interruption, and resume from the last ``continuation_token``. + +Prerequisites: + - Set the ``OPENAI_API_KEY`` environment variable. + - A model that benefits from background execution (e.g. ``o3``). +""" + + +# 1. Create the agent with an OpenAI Responses client. +agent = Agent( + name="researcher", + instructions="You are a helpful research assistant. Be concise.", + client=OpenAIResponsesClient(model_id="o3"), +) + + +async def non_streaming_polling() -> None: + """Demonstrate non-streaming background run with polling.""" + print("=== Non-Streaming Polling ===\n") + + session = agent.create_session() + + # 2. Start a background run — returns immediately. + response = await agent.run( + messages="Briefly explain the theory of relativity in two sentences.", + session=session, + options={"background": True}, + ) + + print(f"Initial status: continuation_token={'set' if response.continuation_token else 'None'}") + + # 3. Poll until the operation completes. + poll_count = 0 + while response.continuation_token is not None: + poll_count += 1 + await asyncio.sleep(2) + response = await agent.run( + session=session, + options={"continuation_token": response.continuation_token}, + ) + print(f" Poll {poll_count}: continuation_token={'set' if response.continuation_token else 'None'}") + + # 4. Done — print the final result. + print(f"\nResult ({poll_count} poll(s)):\n{response.text}\n") + + +async def streaming_with_resumption() -> None: + """Demonstrate streaming background run with simulated interruption and resumption.""" + print("=== Streaming with Resumption ===\n") + + session = agent.create_session() + + # 2. Start a streaming background run. + last_token = None + stream = agent.run( + messages="Briefly list three benefits of exercise.", + stream=True, + session=session, + options={"background": True}, + ) + + # 3. Read some chunks, then simulate an interruption. + chunk_count = 0 + print("First stream (before interruption):") + async for update in stream: + last_token = update.continuation_token + if update.text: + print(update.text, end="", flush=True) + chunk_count += 1 + if chunk_count >= 3: + print("\n [simulated interruption]") + break + + # 4. Resume from the last continuation token. + if last_token is not None: + print("Resumed stream:") + stream = agent.run( + stream=True, + session=session, + options={"continuation_token": last_token}, + ) + async for update in stream: + if update.text: + print(update.text, end="", flush=True) + + print("\n") + + +async def main() -> None: + await non_streaming_polling() + await streaming_with_resumption() + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: + +=== Non-Streaming Polling === + +Initial status: continuation_token=set + Poll 1: continuation_token=set + Poll 2: continuation_token=None + +Result (2 poll(s)): +The theory of relativity, developed by Albert Einstein, consists of special +relativity (1905), which shows that the laws of physics are the same for all +non-accelerating observers and that the speed of light is constant, and general +relativity (1915), which describes gravity as the curvature of spacetime caused +by mass and energy. + +=== Streaming with Resumption === + +First stream (before interruption): +Here are three + [simulated interruption] +Resumed stream: +key benefits of regular exercise: + +1. **Improved cardiovascular health** ... +2. **Better mental health** ... +3. **Stronger muscles and bones** ... +""" diff --git a/python/samples/getting_started/chat_client/README.md b/python/samples/02-agents/chat_client/README.md similarity index 97% rename from python/samples/getting_started/chat_client/README.md rename to python/samples/02-agents/chat_client/README.md index 20060f691d..5bf9b471ad 100644 --- a/python/samples/getting_started/chat_client/README.md +++ b/python/samples/02-agents/chat_client/README.md @@ -14,7 +14,7 @@ This folder contains simple examples demonstrating direct usage of various chat | [`openai_assistants_client.py`](openai_assistants_client.py) | Direct usage of OpenAI Assistants Client for basic chat interactions with OpenAI assistants. | | [`openai_chat_client.py`](openai_chat_client.py) | Direct usage of OpenAI Chat Client for chat interactions with OpenAI models. | | [`openai_responses_client.py`](openai_responses_client.py) | Direct usage of OpenAI Responses Client for structured response generation with OpenAI models. | -| [`custom_chat_client.py`](custom_chat_client.py) | Demonstrates how to create custom chat clients by extending the `BaseChatClient` class. Shows a `EchoingChatClient` implementation and how to integrate it with `ChatAgent` using the `as_agent()` method. | +| [`custom_chat_client.py`](custom_chat_client.py) | Demonstrates how to create custom chat clients by extending the `BaseChatClient` class. Shows a `EchoingChatClient` implementation and how to integrate it with `Agent` using the `as_agent()` method. | ## Environment Variables diff --git a/python/samples/getting_started/chat_client/azure_ai_chat_client.py b/python/samples/02-agents/chat_client/azure_ai_chat_client.py similarity index 90% rename from python/samples/getting_started/chat_client/azure_ai_chat_client.py rename to python/samples/02-agents/chat_client/azure_ai_chat_client.py index b699add89e..236d93f1a7 100644 --- a/python/samples/getting_started/chat_client/azure_ai_chat_client.py +++ b/python/samples/02-agents/chat_client/azure_ai_chat_client.py @@ -17,7 +17,7 @@ Shows function calling capabilities with custom business logic. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/chat_client/azure_assistants_client.py b/python/samples/02-agents/chat_client/azure_assistants_client.py similarity index 90% rename from python/samples/getting_started/chat_client/azure_assistants_client.py rename to python/samples/02-agents/chat_client/azure_assistants_client.py index 599593f54c..66034e8eee 100644 --- a/python/samples/getting_started/chat_client/azure_assistants_client.py +++ b/python/samples/02-agents/chat_client/azure_assistants_client.py @@ -17,7 +17,7 @@ Shows function calling capabilities and automatic assistant creation. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/chat_client/azure_chat_client.py b/python/samples/02-agents/chat_client/azure_chat_client.py similarity index 90% rename from python/samples/getting_started/chat_client/azure_chat_client.py rename to python/samples/02-agents/chat_client/azure_chat_client.py index 13a299ca30..675df29774 100644 --- a/python/samples/getting_started/chat_client/azure_chat_client.py +++ b/python/samples/02-agents/chat_client/azure_chat_client.py @@ -17,7 +17,7 @@ Shows function calling capabilities with custom business logic. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/chat_client/azure_responses_client.py b/python/samples/02-agents/chat_client/azure_responses_client.py similarity index 93% rename from python/samples/getting_started/chat_client/azure_responses_client.py rename to python/samples/02-agents/chat_client/azure_responses_client.py index e2b9796826..7ab4212a3a 100644 --- a/python/samples/getting_started/chat_client/azure_responses_client.py +++ b/python/samples/02-agents/chat_client/azure_responses_client.py @@ -17,7 +17,7 @@ Shows function calling capabilities with custom business logic. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, "The location to get the weather for."], diff --git a/python/samples/getting_started/chat_client/chat_response_cancellation.py b/python/samples/02-agents/chat_client/chat_response_cancellation.py similarity index 86% rename from python/samples/getting_started/chat_client/chat_response_cancellation.py rename to python/samples/02-agents/chat_client/chat_response_cancellation.py index 6ed214808d..3435363512 100644 --- a/python/samples/getting_started/chat_client/chat_response_cancellation.py +++ b/python/samples/02-agents/chat_client/chat_response_cancellation.py @@ -21,10 +21,10 @@ async def main() -> None: - OpenAI model ID: Use "model_id" parameter or "OPENAI_CHAT_MODEL_ID" environment variable - OpenAI API key: Use "api_key" parameter or "OPENAI_API_KEY" environment variable """ - chat_client = OpenAIChatClient() + client = OpenAIChatClient() try: - task = asyncio.create_task(chat_client.get_response(messages=["Tell me a fantasy story."])) + task = asyncio.create_task(client.get_response(messages=["Tell me a fantasy story."])) await asyncio.sleep(1) task.cancel() await task diff --git a/python/samples/getting_started/chat_client/custom_chat_client.py b/python/samples/02-agents/chat_client/custom_chat_client.py similarity index 84% rename from python/samples/getting_started/chat_client/custom_chat_client.py rename to python/samples/02-agents/chat_client/custom_chat_client.py index af56e5456f..b6c69bd0ac 100644 --- a/python/samples/getting_started/chat_client/custom_chat_client.py +++ b/python/samples/02-agents/chat_client/custom_chat_client.py @@ -8,16 +8,16 @@ from typing import Any, ClassVar, Generic from agent_framework import ( BaseChatClient, - ChatMessage, ChatMiddlewareLayer, ChatResponse, ChatResponseUpdate, Content, FunctionInvocationLayer, + Message, ResponseStream, Role, ) -from agent_framework._clients import TOptions_co +from agent_framework._clients import OptionsCoT from agent_framework.observability import ChatTelemetryLayer if sys.version_info >= (3, 13): @@ -38,7 +38,7 @@ middleware, telemetry, and function invocation layers explicitly. """ -class EchoingChatClient(BaseChatClient[TOptions_co], Generic[TOptions_co]): +class EchoingChatClient(BaseChatClient[OptionsCoT], Generic[OptionsCoT]): """A custom chat client that echoes messages back with modifications. This demonstrates how to implement a custom chat client by extending BaseChatClient @@ -61,7 +61,7 @@ class EchoingChatClient(BaseChatClient[TOptions_co], Generic[TOptions_co]): def _inner_get_response( self, *, - messages: Sequence[ChatMessage], + messages: Sequence[Message], stream: bool = False, options: Mapping[str, Any], **kwargs: Any, @@ -82,7 +82,7 @@ class EchoingChatClient(BaseChatClient[TOptions_co], Generic[TOptions_co]): else: response_text = f"{self.prefix} [No text message found]" - response_message = ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text(response_text)]) + response_message = Message(role=Role.ASSISTANT, contents=[Content.from_text(response_text)]) response = ChatResponse( messages=[response_message], @@ -112,11 +112,11 @@ class EchoingChatClient(BaseChatClient[TOptions_co], Generic[TOptions_co]): class EchoingChatClientWithLayers( # type: ignore[misc,type-var] - ChatMiddlewareLayer[TOptions_co], - ChatTelemetryLayer[TOptions_co], - FunctionInvocationLayer[TOptions_co], - EchoingChatClient[TOptions_co], - Generic[TOptions_co], + ChatMiddlewareLayer[OptionsCoT], + ChatTelemetryLayer[OptionsCoT], + FunctionInvocationLayer[OptionsCoT], + EchoingChatClient[OptionsCoT], + Generic[OptionsCoT], ): """Echoing chat client that explicitly composes middleware, telemetry, and function layers.""" @@ -124,7 +124,7 @@ class EchoingChatClientWithLayers( # type: ignore[misc,type-var] async def main() -> None: - """Demonstrates how to implement and use a custom chat client with ChatAgent.""" + """Demonstrates how to implement and use a custom chat client with Agent.""" print("=== Custom Chat Client Example ===\n") # Create the custom chat client @@ -160,10 +160,10 @@ async def main() -> None: print(chunk.text, end="", flush=True) print() - # Example: Using with threads and conversation history - print("\n--- Using Custom Chat Client with Thread ---") + # Example: Using with sessions and conversation history + print("\n--- Using Custom Chat Client with Session ---") - thread = echo_agent.get_new_thread() + session = echo_agent.create_session() # Multiple messages in conversation messages = [ @@ -173,16 +173,17 @@ async def main() -> None: ] for msg in messages: - result = await echo_agent.run(msg, thread=thread) + result = await echo_agent.run(msg, session=session) print(f"User: {msg}") print(f"Agent: {result.messages[0].text}\n") # Check conversation history - if thread.message_store: - thread_messages = await thread.message_store.list_messages() - print(f"Thread contains {len(thread_messages)} messages") + memory_state = session.state.get("memory", {}) + session_messages = memory_state.get("messages", []) + if session_messages: + print(f"Session contains {len(session_messages)} messages") else: - print("Thread has no message store configured") + print("Session has no messages stored") if __name__ == "__main__": diff --git a/python/samples/getting_started/chat_client/openai_assistants_client.py b/python/samples/02-agents/chat_client/openai_assistants_client.py similarity index 89% rename from python/samples/getting_started/chat_client/openai_assistants_client.py rename to python/samples/02-agents/chat_client/openai_assistants_client.py index 9ff13f39ab..7783743950 100644 --- a/python/samples/getting_started/chat_client/openai_assistants_client.py +++ b/python/samples/02-agents/chat_client/openai_assistants_client.py @@ -17,7 +17,7 @@ Shows function calling capabilities and automatic assistant creation. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/chat_client/openai_chat_client.py b/python/samples/02-agents/chat_client/openai_chat_client.py similarity index 89% rename from python/samples/getting_started/chat_client/openai_chat_client.py rename to python/samples/02-agents/chat_client/openai_chat_client.py index 279d3eb186..e784c17ae2 100644 --- a/python/samples/getting_started/chat_client/openai_chat_client.py +++ b/python/samples/02-agents/chat_client/openai_chat_client.py @@ -17,7 +17,7 @@ Shows function calling capabilities with custom business logic. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/chat_client/openai_responses_client.py b/python/samples/02-agents/chat_client/openai_responses_client.py similarity index 89% rename from python/samples/getting_started/chat_client/openai_responses_client.py rename to python/samples/02-agents/chat_client/openai_responses_client.py index ed58c0be29..ba589e1c2f 100644 --- a/python/samples/getting_started/chat_client/openai_responses_client.py +++ b/python/samples/02-agents/chat_client/openai_responses_client.py @@ -17,7 +17,7 @@ Shows function calling capabilities with custom business logic. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/context_providers/azure_ai_search/README.md b/python/samples/02-agents/context_providers/azure_ai_search/README.md similarity index 98% rename from python/samples/getting_started/context_providers/azure_ai_search/README.md rename to python/samples/02-agents/context_providers/azure_ai_search/README.md index fe7635e72f..49403d106c 100644 --- a/python/samples/getting_started/context_providers/azure_ai_search/README.md +++ b/python/samples/02-agents/context_providers/azure_ai_search/README.md @@ -126,7 +126,7 @@ AZURE_OPENAI_RESOURCE_URL=https://myresource.openai.azure.com ### Semantic Mode ```python -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.azure import AzureAIAgentClient, AzureAISearchContextProvider from azure.identity.aio import DefaultAzureCredential @@ -141,10 +141,10 @@ search_provider = AzureAISearchContextProvider( # Create agent with search context async with AzureAIAgentClient(credential=DefaultAzureCredential()) as client: - async with ChatAgent( - chat_client=client, + async with Agent( + client=client, model=model_deployment, - context_provider=search_provider, + context_providers=[search_provider], ) as agent: response = await agent.run("What information is in the knowledge base?") ``` @@ -166,10 +166,10 @@ search_provider = AzureAISearchContextProvider( ) # Use with agent (same as semantic mode) -async with ChatAgent( - chat_client=client, +async with Agent( + client=client, model=model_deployment, - context_provider=search_provider, + context_providers=[search_provider], ) as agent: response = await agent.run("Analyze and compare topics across documents") ``` diff --git a/python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py b/python/samples/02-agents/context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py similarity index 97% rename from python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py rename to python/samples/02-agents/context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py index 6e3e40a216..5a4503f920 100644 --- a/python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py +++ b/python/samples/02-agents/context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py @@ -3,7 +3,7 @@ import asyncio import os -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.azure import AzureAIAgentClient, AzureAISearchContextProvider from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -112,15 +112,15 @@ async def main() -> None: model_deployment_name=model_deployment, credential=AzureCliCredential(), ) as client, - ChatAgent( - chat_client=client, + Agent( + client=client, name="SearchAgent", instructions=( "You are a helpful assistant with advanced reasoning capabilities. " "Use the provided context from the knowledge base to answer complex " "questions that may require synthesizing information from multiple sources." ), - context_provider=search_provider, + context_providers=[search_provider], ) as agent, ): print("=== Azure AI Agent with Search Context (Agentic Mode) ===\n") diff --git a/python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_semantic.py b/python/samples/02-agents/context_providers/azure_ai_search/azure_ai_with_search_context_semantic.py similarity index 96% rename from python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_semantic.py rename to python/samples/02-agents/context_providers/azure_ai_search/azure_ai_with_search_context_semantic.py index 4fce526a1f..8309d5197c 100644 --- a/python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_semantic.py +++ b/python/samples/02-agents/context_providers/azure_ai_search/azure_ai_with_search_context_semantic.py @@ -3,7 +3,7 @@ import asyncio import os -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.azure import AzureAIAgentClient, AzureAISearchContextProvider from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -69,14 +69,14 @@ async def main() -> None: model_deployment_name=model_deployment, credential=AzureCliCredential(), ) as client, - ChatAgent( - chat_client=client, + Agent( + client=client, name="SearchAgent", instructions=( "You are a helpful assistant. Use the provided context from the " "knowledge base to answer questions accurately." ), - context_provider=search_provider, + context_providers=[search_provider], ) as agent, ): print("=== Azure AI Agent with Search Context (Semantic Mode) ===\n") diff --git a/python/samples/getting_started/context_providers/mem0/README.md b/python/samples/02-agents/context_providers/mem0/README.md similarity index 87% rename from python/samples/getting_started/context_providers/mem0/README.md rename to python/samples/02-agents/context_providers/mem0/README.md index 61d8bbd51f..4c12bb67d8 100644 --- a/python/samples/getting_started/context_providers/mem0/README.md +++ b/python/samples/02-agents/context_providers/mem0/README.md @@ -9,7 +9,7 @@ This folder contains examples demonstrating how to use the Mem0 context provider | File | Description | |------|-------------| | [`mem0_basic.py`](mem0_basic.py) | Basic example of using Mem0 context provider to store and retrieve user preferences across different conversation threads. | -| [`mem0_threads.py`](mem0_threads.py) | Advanced example demonstrating different thread scoping strategies with Mem0. Covers global thread scope (memories shared across all operations), per-operation thread scope (memories isolated per thread), and multiple agents with different memory configurations for personal vs. work contexts. | +| [`mem0_sessions.py`](mem0_sessions.py) | Advanced example demonstrating different thread scoping strategies with Mem0. Covers global thread scope (memories shared across all operations), per-operation thread scope (memories isolated per thread), and multiple agents with different memory configurations for personal vs. work contexts. | | [`mem0_oss.py`](mem0_oss.py) | Example of using the Mem0 Open Source self-hosted version as the context provider. Demonstrates setup and configuration for local deployment. | ## Prerequisites diff --git a/python/samples/getting_started/context_providers/mem0/mem0_basic.py b/python/samples/02-agents/context_providers/mem0/mem0_basic.py similarity index 83% rename from python/samples/getting_started/context_providers/mem0/mem0_basic.py rename to python/samples/02-agents/context_providers/mem0/mem0_basic.py index b82dab0ae1..b4e99e0a9f 100644 --- a/python/samples/getting_started/context_providers/mem0/mem0_basic.py +++ b/python/samples/02-agents/context_providers/mem0/mem0_basic.py @@ -5,11 +5,11 @@ import uuid from agent_framework import tool from agent_framework.azure import AzureAIAgentClient -from agent_framework.mem0 import Mem0Provider +from agent_framework.mem0 import Mem0ContextProvider from azure.identity.aio import AzureCliCredential -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def retrieve_company_report(company_code: str, detailed: bool) -> str: if company_code != "CNTS": @@ -39,7 +39,7 @@ async def main() -> None: name="FriendlyAssistant", instructions="You are a friendly assistant.", tools=retrieve_company_report, - context_provider=Mem0Provider(user_id=user_id), + context_providers=[Mem0ContextProvider(user_id=user_id)], ) as agent, ): # First ask the agent to retrieve a company report with no previous context. @@ -64,17 +64,17 @@ async def main() -> None: print("Waiting for memories to be processed...") await asyncio.sleep(12) # Empirically determined delay for Mem0 indexing - print("\nRequest within a new thread:") - # Create a new thread for the agent. - # The new thread has no context of the previous conversation. - thread = agent.get_new_thread() + print("\nRequest within a new session:") + # Create a new session for the agent. + # The new session has no context of the previous conversation. + session = agent.create_session() - # Since we have the mem0 component in the thread, the agent should be able to + # Since we have the mem0 component in the session, the agent should be able to # retrieve the company report without asking for clarification, as it will # be able to remember the user preferences from Mem0 component. query = "Please retrieve my company report" print(f"User: {query}") - result = await agent.run(query, thread=thread) + result = await agent.run(query, session=session) print(f"Agent: {result}\n") diff --git a/python/samples/getting_started/context_providers/mem0/mem0_oss.py b/python/samples/02-agents/context_providers/mem0/mem0_oss.py similarity index 81% rename from python/samples/getting_started/context_providers/mem0/mem0_oss.py rename to python/samples/02-agents/context_providers/mem0/mem0_oss.py index 84156434b0..1b03ac5fc1 100644 --- a/python/samples/getting_started/context_providers/mem0/mem0_oss.py +++ b/python/samples/02-agents/context_providers/mem0/mem0_oss.py @@ -5,12 +5,12 @@ import uuid from agent_framework import tool from agent_framework.azure import AzureAIAgentClient -from agent_framework.mem0 import Mem0Provider +from agent_framework.mem0 import Mem0ContextProvider from azure.identity.aio import AzureCliCredential from mem0 import AsyncMemory -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def retrieve_company_report(company_code: str, detailed: bool) -> str: if company_code != "CNTS": @@ -42,7 +42,7 @@ async def main() -> None: name="FriendlyAssistant", instructions="You are a friendly assistant.", tools=retrieve_company_report, - context_provider=Mem0Provider(user_id=user_id, mem0_client=local_mem0_client), + context_providers=[Mem0ContextProvider(user_id=user_id, mem0_client=local_mem0_client)], ) as agent, ): # First ask the agent to retrieve a company report with no previous context. @@ -60,18 +60,18 @@ async def main() -> None: result = await agent.run(query) print(f"Agent: {result}\n") - print("\nRequest within a new thread:") + print("\nRequest within a new session:") - # Create a new thread for the agent. - # The new thread has no context of the previous conversation. - thread = agent.get_new_thread() + # Create a new session for the agent. + # The new session has no context of the previous conversation. + session = agent.create_session() - # Since we have the mem0 component in the thread, the agent should be able to + # Since we have the mem0 component in the session, the agent should be able to # retrieve the company report without asking for clarification, as it will # be able to remember the user preferences from Mem0 component. query = "Please retrieve my company report" print(f"User: {query}") - result = await agent.run(query, thread=thread) + result = await agent.run(query, session=session) print(f"Agent: {result}\n") diff --git a/python/samples/getting_started/context_providers/mem0/mem0_threads.py b/python/samples/02-agents/context_providers/mem0/mem0_sessions.py similarity index 74% rename from python/samples/getting_started/context_providers/mem0/mem0_threads.py rename to python/samples/02-agents/context_providers/mem0/mem0_sessions.py index 15a57ad796..cc5548e979 100644 --- a/python/samples/getting_started/context_providers/mem0/mem0_threads.py +++ b/python/samples/02-agents/context_providers/mem0/mem0_sessions.py @@ -5,11 +5,11 @@ import uuid from agent_framework import tool from agent_framework.azure import AzureAIAgentClient -from agent_framework.mem0 import Mem0Provider +from agent_framework.mem0 import Mem0ContextProvider from azure.identity.aio import AzureCliCredential -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_user_preferences(user_id: str) -> str: """Mock function to get user preferences.""" @@ -34,11 +34,11 @@ async def example_global_thread_scope() -> None: name="GlobalMemoryAssistant", instructions="You are an assistant that remembers user preferences across conversations.", tools=get_user_preferences, - context_provider=Mem0Provider( + context_providers=[Mem0ContextProvider( user_id=user_id, thread_id=global_thread_id, - scope_to_per_operation_thread_id=False, # Share memories across all threads - ), + scope_to_per_operation_thread_id=False, # Share memories across all sessions + )], ) as global_agent, ): # Store some preferences in the global scope @@ -47,19 +47,19 @@ async def example_global_thread_scope() -> None: result = await global_agent.run(query) print(f"Agent: {result}\n") - # Create a new thread - but memories should still be accessible due to global scope - new_thread = global_agent.get_new_thread() + # Create a new session - but memories should still be accessible due to global scope + new_session = global_agent.create_session() query = "What do you know about my preferences?" - print(f"User (new thread): {query}") - result = await global_agent.run(query, thread=new_thread) + print(f"User (new session): {query}") + result = await global_agent.run(query, session=new_session) print(f"Agent: {result}\n") async def example_per_operation_thread_scope() -> None: - """Example 2: Per-operation thread scope (memories isolated per thread). + """Example 2: Per-operation thread scope (memories isolated per session). - Note: When scope_to_per_operation_thread_id=True, the provider is bound to a single thread - throughout its lifetime. Use the same thread object for all operations with that provider. + Note: When scope_to_per_operation_thread_id=True, the provider is bound to a single session + throughout its lifetime. Use the same session object for all operations with that provider. """ print("2. Per-Operation Thread Scope Example:") print("-" * 40) @@ -72,37 +72,37 @@ async def example_per_operation_thread_scope() -> None: name="ScopedMemoryAssistant", instructions="You are an assistant with thread-scoped memory.", tools=get_user_preferences, - context_provider=Mem0Provider( + context_providers=[Mem0ContextProvider( user_id=user_id, - scope_to_per_operation_thread_id=True, # Isolate memories per thread - ), + scope_to_per_operation_thread_id=True, # Isolate memories per session + )], ) as scoped_agent, ): - # Create a specific thread for this scoped provider - dedicated_thread = scoped_agent.get_new_thread() + # Create a specific session for this scoped provider + dedicated_session = scoped_agent.create_session() - # Store some information in the dedicated thread + # Store some information in the dedicated session query = "Remember that for this conversation, I'm working on a Python project about data analysis." - print(f"User (dedicated thread): {query}") - result = await scoped_agent.run(query, thread=dedicated_thread) + print(f"User (dedicated session): {query}") + result = await scoped_agent.run(query, session=dedicated_session) print(f"Agent: {result}\n") - # Test memory retrieval in the same dedicated thread + # Test memory retrieval in the same dedicated session query = "What project am I working on?" - print(f"User (same dedicated thread): {query}") - result = await scoped_agent.run(query, thread=dedicated_thread) + print(f"User (same dedicated session): {query}") + result = await scoped_agent.run(query, session=dedicated_session) print(f"Agent: {result}\n") - # Store more information in the same thread + # Store more information in the same session query = "Also remember that I prefer using pandas and matplotlib for this project." - print(f"User (same dedicated thread): {query}") - result = await scoped_agent.run(query, thread=dedicated_thread) + print(f"User (same dedicated session): {query}") + result = await scoped_agent.run(query, session=dedicated_session) print(f"Agent: {result}\n") # Test comprehensive memory retrieval query = "What do you know about my current project and preferences?" - print(f"User (same dedicated thread): {query}") - result = await scoped_agent.run(query, thread=dedicated_thread) + print(f"User (same dedicated session): {query}") + result = await scoped_agent.run(query, session=dedicated_session) print(f"Agent: {result}\n") @@ -119,16 +119,16 @@ async def example_multiple_agents() -> None: AzureAIAgentClient(credential=credential).as_agent( name="PersonalAssistant", instructions="You are a personal assistant that helps with personal tasks.", - context_provider=Mem0Provider( + context_providers=[Mem0ContextProvider( agent_id=agent_id_1, - ), + )], ) as personal_agent, AzureAIAgentClient(credential=credential).as_agent( name="WorkAssistant", instructions="You are a work assistant that helps with professional tasks.", - context_provider=Mem0Provider( + context_providers=[Mem0ContextProvider( agent_id=agent_id_2, - ), + )], ) as work_agent, ): # Store personal information diff --git a/python/samples/getting_started/context_providers/redis/README.md b/python/samples/02-agents/context_providers/redis/README.md similarity index 86% rename from python/samples/getting_started/context_providers/redis/README.md rename to python/samples/02-agents/context_providers/redis/README.md index e0fde57bf2..b7b25c8d77 100644 --- a/python/samples/getting_started/context_providers/redis/README.md +++ b/python/samples/02-agents/context_providers/redis/README.md @@ -8,10 +8,10 @@ This folder contains an example demonstrating how to use the Redis context provi | File | Description | |------|-------------| -| [`azure_redis_conversation.py`](azure_redis_conversation.py) | Demonstrates conversation persistence with RedisChatMessageStore and Azure Redis with Azure AD (Entra ID) authentication using credential provider. | +| [`azure_redis_conversation.py`](azure_redis_conversation.py) | Demonstrates conversation persistence with RedisHistoryProvider and Azure Redis with Azure AD (Entra ID) authentication using credential provider. | | [`redis_basics.py`](redis_basics.py) | Shows standalone provider usage and agent integration. Demonstrates writing messages to Redis, retrieving context via full‑text or hybrid vector search, and persisting preferences across threads. Also includes a simple tool example whose outputs are remembered. | -| [`redis_conversation.py`](redis_conversation.py) | Simple example showing conversation persistence with RedisChatMessageStore using traditional connection string authentication. | -| [`redis_threads.py`](redis_threads.py) | Demonstrates thread scoping. Includes: (1) global thread scope with a fixed `thread_id` shared across operations; (2) per‑operation thread scope where `scope_to_per_operation_thread_id=True` binds memory to a single thread for the provider's lifetime; and (3) multiple agents with isolated memory via different `agent_id` values. | +| [`redis_conversation.py`](redis_conversation.py) | Simple example showing conversation persistence with RedisContextProvider using traditional connection string authentication. | +| [`redis_sessions.py`](redis_sessions.py) | Demonstrates thread scoping. Includes: (1) global thread scope with a fixed `thread_id` shared across operations; (2) per‑operation thread scope where `scope_to_per_operation_thread_id=True` binds memory to a single thread for the provider's lifetime; and (3) multiple agents with isolated memory via different `agent_id` values. | ## Prerequisites diff --git a/python/samples/getting_started/context_providers/redis/azure_redis_conversation.py b/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py similarity index 83% rename from python/samples/getting_started/context_providers/redis/azure_redis_conversation.py rename to python/samples/02-agents/context_providers/redis/azure_redis_conversation.py index 5c300abcbf..ce569be8cb 100644 --- a/python/samples/getting_started/context_providers/redis/azure_redis_conversation.py +++ b/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. -"""Azure Managed Redis Chat Message Store with Azure AD Authentication +"""Azure Managed Redis History Provider with Azure AD Authentication This example demonstrates how to use Azure Managed Redis with Azure AD authentication -to persist conversational details using RedisChatMessageStore. +to persist conversational details using RedisHistoryProvider. Requirements: - Azure Managed Redis instance with Azure AD authentication enabled @@ -22,7 +22,7 @@ import asyncio import os from agent_framework.openai import OpenAIChatClient -from agent_framework.redis import RedisChatMessageStore +from agent_framework.redis import RedisHistoryProvider from azure.identity.aio import AzureCliCredential from redis.credentials import CredentialProvider @@ -60,28 +60,27 @@ async def main() -> None: azure_credential = AzureCliCredential() credential_provider = AzureCredentialProvider(azure_credential, user_object_id) - thread_id = "azure_test_thread" + session_id = "azure_test_session" - # Factory for creating Azure Redis chat message store - def chat_message_store_factory(): - return RedisChatMessageStore( - credential_provider=credential_provider, - host=redis_host, - port=10000, - ssl=True, - thread_id=thread_id, - key_prefix="chat_messages", - max_messages=100, - ) + # Create Azure Redis history provider + history_provider = RedisHistoryProvider( + credential_provider=credential_provider, + host=redis_host, + port=10000, + ssl=True, + thread_id=session_id, + key_prefix="chat_messages", + max_messages=100, + ) # Create chat client client = OpenAIChatClient() - # Create agent with Azure Redis store + # Create agent with Azure Redis history provider agent = client.as_agent( name="AzureRedisAssistant", instructions="You are a helpful assistant.", - chat_message_store_factory=chat_message_store_factory, + context_providers=[history_provider], ) # Conversation diff --git a/python/samples/getting_started/context_providers/redis/redis_basics.py b/python/samples/02-agents/context_providers/redis/redis_basics.py similarity index 85% rename from python/samples/getting_started/context_providers/redis/redis_basics.py rename to python/samples/02-agents/context_providers/redis/redis_basics.py index 9f5a654ea1..5f78d65320 100644 --- a/python/samples/getting_started/context_providers/redis/redis_basics.py +++ b/python/samples/02-agents/context_providers/redis/redis_basics.py @@ -30,14 +30,14 @@ Run: import asyncio import os -from agent_framework import ChatMessage, tool +from agent_framework import Message, tool from agent_framework.openai import OpenAIChatClient -from agent_framework_redis._provider import RedisProvider +from agent_framework.redis import RedisContextProvider from redisvl.extensions.cache.embeddings import EmbeddingsCache from redisvl.utils.vectorize import OpenAITextVectorizer -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def search_flights(origin_airport_code: str, destination_airport_code: str, detailed: bool = False) -> str: """Simulated flight-search tool to demonstrate tool memory. @@ -104,7 +104,7 @@ async def main() -> None: # Recommend default for OPENAI_CHAT_MODEL_ID is gpt-4o-mini # We attach an embedding vectorizer so the provider can perform hybrid (text + vector) - # retrieval. If you prefer text-only retrieval, instantiate RedisProvider without the + # retrieval. If you prefer text-only retrieval, instantiate RedisContextProvider without the # 'vectorizer' and vector_* parameters. vectorizer = OpenAITextVectorizer( model="text-embedding-ada-002", @@ -114,7 +114,7 @@ async def main() -> None: # The provider manages persistence and retrieval. application_id/agent_id/user_id # scope data for multi-tenant separation; thread_id (set later) narrows to a # specific conversation. - provider = RedisProvider( + provider = RedisContextProvider( redis_url="redis://localhost:6379", index_name="redis_basics", application_id="matrix_of_kermits", @@ -128,26 +128,32 @@ async def main() -> None: # Build sample chat messages to persist to Redis messages = [ - ChatMessage("user", ["runA CONVO: User Message"]), - ChatMessage("assistant", ["runA CONVO: Assistant Message"]), - ChatMessage("system", ["runA CONVO: System Message"]), + Message("user", ["runA CONVO: User Message"]), + Message("assistant", ["runA CONVO: Assistant Message"]), + Message("system", ["runA CONVO: System Message"]), ] - # Declare/start a conversation/thread and write messages under 'runA'. - # Threads are logical boundaries used by the provider to group and retrieve - # conversation-specific context. - await provider.thread_created(thread_id="runA") - await provider.invoked(request_messages=messages) + # Use the provider's before_run/after_run API to store and retrieve messages. + # In practice, the agent handles this automatically; this shows the low-level API. + from agent_framework import AgentSession, SessionContext - # Retrieve relevant memories for a hypothetical model call. The provider uses - # the current request messages as the retrieval query and returns context to - # be injected into the model's instructions. - ctx = await provider.invoking([ChatMessage("system", ["B: Assistant Message"])]) + session = AgentSession(session_id="runA") + context = SessionContext() + context.extend_messages("input", messages) + state = session.state + + # Store messages via after_run + await provider.after_run(agent=None, session=session, context=context, state=state) + + # Retrieve relevant memories via before_run + query_context = SessionContext() + query_context.extend_messages("input", [Message("system", ["B: Assistant Message"])]) + await provider.before_run(agent=None, session=session, context=query_context, state=state) # Inspect retrieved memories that would be injected into instructions # (Debug-only output so you can verify retrieval works as expected.) - print("Model Invoking Result:") - print(ctx) + print("Before Run Result:") + print(query_context) # Drop / delete the provider index in Redis await provider.redis_index.delete() @@ -163,7 +169,7 @@ async def main() -> None: cache=EmbeddingsCache(name="openai_embeddings_cache", redis_url="redis://localhost:6379"), ) # Recreate a clean index so the next scenario starts fresh - provider = RedisProvider( + provider = RedisContextProvider( redis_url="redis://localhost:6379", index_name="redis_basics_2", prefix="context_2", @@ -187,7 +193,7 @@ async def main() -> None: "Before answering, always check for stored context" ), tools=[], - context_provider=provider, + context_providers=[provider], ) # Teach a user preference; the agent writes this to the provider's memory @@ -210,7 +216,7 @@ async def main() -> None: print("\n3. Agent + provider + tool: store and recall tool-derived context") print("-" * 40) # Text-only provider (full-text search only). Omits vectorizer and related params. - provider = RedisProvider( + provider = RedisContextProvider( redis_url="redis://localhost:6379", index_name="redis_basics_3", prefix="context_3", @@ -229,7 +235,7 @@ async def main() -> None: "Before answering, always check for stored context" ), tools=search_flights, - context_provider=provider, + context_providers=[provider], ) # Invoke the tool; outputs become part of memory/context query = "Are there any flights from new york city (jfk) to la? Give me details" diff --git a/python/samples/getting_started/context_providers/redis/redis_conversation.py b/python/samples/02-agents/context_providers/redis/redis_conversation.py similarity index 83% rename from python/samples/getting_started/context_providers/redis/redis_conversation.py rename to python/samples/02-agents/context_providers/redis/redis_conversation.py index f202a0cd2c..2d345d9930 100644 --- a/python/samples/getting_started/context_providers/redis/redis_conversation.py +++ b/python/samples/02-agents/context_providers/redis/redis_conversation.py @@ -2,7 +2,7 @@ """Redis Context Provider: Basic usage and agent integration -This example demonstrates how to use the Redis ChatMessageStoreProtocol to persist +This example demonstrates how to use the Redis context provider to persist conversational details. Pass it as a constructor argument to create_agent. Requirements: @@ -18,8 +18,7 @@ import asyncio import os from agent_framework.openai import OpenAIChatClient -from agent_framework_redis._chat_message_store import RedisChatMessageStore -from agent_framework_redis._provider import RedisProvider +from agent_framework.redis import RedisContextProvider from redisvl.extensions.cache.embeddings import EmbeddingsCache from redisvl.utils.vectorize import OpenAITextVectorizer @@ -37,9 +36,9 @@ async def main() -> None: cache=EmbeddingsCache(name="openai_embeddings_cache", redis_url="redis://localhost:6379"), ) - thread_id = "test_thread" + session_id = "test_session" - provider = RedisProvider( + provider = RedisContextProvider( redis_url="redis://localhost:6379", index_name="redis_conversation", prefix="redis_conversation", @@ -50,17 +49,9 @@ async def main() -> None: vector_field_name="vector", vector_algorithm="hnsw", vector_distance_metric="cosine", - thread_id=thread_id, + thread_id=session_id, ) - def chat_message_store_factory(): - return RedisChatMessageStore( - redis_url="redis://localhost:6379", - thread_id=thread_id, - key_prefix="chat_messages", - max_messages=100, - ) - # Create chat client for the agent client = OpenAIChatClient(model_id=os.getenv("OPENAI_CHAT_MODEL_ID"), api_key=os.getenv("OPENAI_API_KEY")) # Create agent wired to the Redis context provider. The provider automatically @@ -72,8 +63,7 @@ async def main() -> None: "Before answering, always check for stored context" ), tools=[], - context_provider=provider, - chat_message_store_factory=chat_message_store_factory, + context_providers=[provider], ) # Teach a user preference; the agent writes this to the provider's memory diff --git a/python/samples/getting_started/context_providers/redis/redis_threads.py b/python/samples/02-agents/context_providers/redis/redis_sessions.py similarity index 83% rename from python/samples/getting_started/context_providers/redis/redis_threads.py rename to python/samples/02-agents/context_providers/redis/redis_sessions.py index 2347281bf5..34179048d9 100644 --- a/python/samples/getting_started/context_providers/redis/redis_threads.py +++ b/python/samples/02-agents/context_providers/redis/redis_sessions.py @@ -31,7 +31,7 @@ import os import uuid from agent_framework.openai import OpenAIChatClient -from agent_framework_redis._provider import RedisProvider +from agent_framework.redis import RedisContextProvider from redisvl.extensions.cache.embeddings import EmbeddingsCache from redisvl.utils.vectorize import OpenAITextVectorizer @@ -51,16 +51,14 @@ async def example_global_thread_scope() -> None: api_key=os.getenv("OPENAI_API_KEY"), ) - provider = RedisProvider( + provider = RedisContextProvider( redis_url="redis://localhost:6379", index_name="redis_threads_global", - # overwrite_redis_index=True, - # drop_redis_index=True, application_id="threads_demo_app", agent_id="threads_demo_agent", user_id="threads_demo_user", thread_id=global_thread_id, - scope_to_per_operation_thread_id=False, # Share memories across all threads + scope_to_per_operation_thread_id=False, # Share memories across all sessions ) agent = client.as_agent( @@ -70,7 +68,7 @@ async def example_global_thread_scope() -> None: "Before answering, always check for stored context containing information" ), tools=[], - context_provider=provider, + context_providers=[provider], ) # Store a preference in the global scope @@ -79,11 +77,11 @@ async def example_global_thread_scope() -> None: result = await agent.run(query) print(f"Agent: {result}\n") - # Create a new thread - memories should still be accessible due to global scope - new_thread = agent.get_new_thread() + # Create a new session - memories should still be accessible due to global scope + new_session = agent.create_session() query = "What technical responses do I prefer?" - print(f"User (new thread): {query}") - result = await agent.run(query, thread=new_thread) + print(f"User (new session): {query}") + result = await agent.run(query, session=new_session) print(f"Agent: {result}\n") # Clean up the Redis index @@ -91,10 +89,10 @@ async def example_global_thread_scope() -> None: async def example_per_operation_thread_scope() -> None: - """Example 2: Per-operation thread scope (memories isolated per thread). + """Example 2: Per-operation thread scope (memories isolated per session). - Note: When scope_to_per_operation_thread_id=True, the provider is bound to a single thread - throughout its lifetime. Use the same thread object for all operations with that provider. + Note: When scope_to_per_operation_thread_id=True, the provider is bound to a single session + throughout its lifetime. Use the same session object for all operations with that provider. """ print("2. Per-Operation Thread Scope Example:") print("-" * 40) @@ -110,7 +108,7 @@ async def example_per_operation_thread_scope() -> None: cache=EmbeddingsCache(name="openai_embeddings_cache", redis_url="redis://localhost:6379"), ) - provider = RedisProvider( + provider = RedisContextProvider( redis_url="redis://localhost:6379", index_name="redis_threads_dynamic", # overwrite_redis_index=True, @@ -118,7 +116,7 @@ async def example_per_operation_thread_scope() -> None: application_id="threads_demo_app", agent_id="threads_demo_agent", user_id="threads_demo_user", - scope_to_per_operation_thread_id=True, # Isolate memories per thread + scope_to_per_operation_thread_id=True, # Isolate memories per session redis_vectorizer=vectorizer, vector_field_name="vector", vector_algorithm="hnsw", @@ -128,34 +126,34 @@ async def example_per_operation_thread_scope() -> None: agent = client.as_agent( name="ScopedMemoryAssistant", instructions="You are an assistant with thread-scoped memory.", - context_provider=provider, + context_providers=[provider], ) - # Create a specific thread for this scoped provider - dedicated_thread = agent.get_new_thread() + # Create a specific session for this scoped provider + dedicated_session = agent.create_session() - # Store some information in the dedicated thread + # Store some information in the dedicated session query = "Remember that for this conversation, I'm working on a Python project about data analysis." - print(f"User (dedicated thread): {query}") - result = await agent.run(query, thread=dedicated_thread) + print(f"User (dedicated session): {query}") + result = await agent.run(query, session=dedicated_session) print(f"Agent: {result}\n") - # Test memory retrieval in the same dedicated thread + # Test memory retrieval in the same dedicated session query = "What project am I working on?" - print(f"User (same dedicated thread): {query}") - result = await agent.run(query, thread=dedicated_thread) + print(f"User (same dedicated session): {query}") + result = await agent.run(query, session=dedicated_session) print(f"Agent: {result}\n") - # Store more information in the same thread + # Store more information in the same session query = "Also remember that I prefer using pandas and matplotlib for this project." - print(f"User (same dedicated thread): {query}") - result = await agent.run(query, thread=dedicated_thread) + print(f"User (same dedicated session): {query}") + result = await agent.run(query, session=dedicated_session) print(f"Agent: {result}\n") # Test comprehensive memory retrieval query = "What do you know about my current project and preferences?" - print(f"User (same dedicated thread): {query}") - result = await agent.run(query, thread=dedicated_thread) + print(f"User (same dedicated session): {query}") + result = await agent.run(query, session=dedicated_session) print(f"Agent: {result}\n") # Clean up the Redis index @@ -178,7 +176,7 @@ async def example_multiple_agents() -> None: cache=EmbeddingsCache(name="openai_embeddings_cache", redis_url="redis://localhost:6379"), ) - personal_provider = RedisProvider( + personal_provider = RedisContextProvider( redis_url="redis://localhost:6379", index_name="redis_threads_agents", application_id="threads_demo_app", @@ -193,10 +191,10 @@ async def example_multiple_agents() -> None: personal_agent = client.as_agent( name="PersonalAssistant", instructions="You are a personal assistant that helps with personal tasks.", - context_provider=personal_provider, + context_providers=[personal_provider], ) - work_provider = RedisProvider( + work_provider = RedisContextProvider( redis_url="redis://localhost:6379", index_name="redis_threads_agents", application_id="threads_demo_app", @@ -211,7 +209,7 @@ async def example_multiple_agents() -> None: work_agent = client.as_agent( name="WorkAssistant", instructions="You are a work assistant that helps with professional tasks.", - context_provider=work_provider, + context_providers=[work_provider], ) # Store personal information diff --git a/python/samples/getting_started/context_providers/simple_context_provider.py b/python/samples/02-agents/context_providers/simple_context_provider.py similarity index 65% rename from python/samples/getting_started/context_providers/simple_context_provider.py rename to python/samples/02-agents/context_providers/simple_context_provider.py index e32266cb14..fd2a7ce747 100644 --- a/python/samples/getting_started/context_providers/simple_context_provider.py +++ b/python/samples/02-agents/context_providers/simple_context_provider.py @@ -1,10 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio -from collections.abc import MutableSequence, Sequence from typing import Any -from agent_framework import ChatAgent, ChatClientProtocol, ChatMessage, Context, ContextProvider +from agent_framework import Agent, AgentSession, BaseContextProvider, SessionContext, SupportsChatGetResponse from agent_framework.azure import AzureAIClient from azure.identity.aio import AzureCliCredential from pydantic import BaseModel @@ -15,14 +14,14 @@ class UserInfo(BaseModel): age: int | None = None -class UserInfoMemory(ContextProvider): - def __init__(self, chat_client: ChatClientProtocol, user_info: UserInfo | None = None, **kwargs: Any): +class UserInfoMemory(BaseContextProvider): + def __init__(self, client: SupportsChatGetResponse, user_info: UserInfo | None = None, **kwargs: Any): """Create the memory. If you pass in kwargs, they will be attempted to be used to create a UserInfo object. """ - - self._chat_client = chat_client + super().__init__("user-info-memory") + self._chat_client = client if user_info: self.user_info = user_info elif kwargs: @@ -30,14 +29,16 @@ class UserInfoMemory(ContextProvider): else: self.user_info = UserInfo() - async def invoked( + async def after_run( self, - request_messages: ChatMessage | Sequence[ChatMessage], - response_messages: ChatMessage | Sequence[ChatMessage] | None = None, - invoke_exception: Exception | None = None, - **kwargs: Any, + *, + agent: Any, + session: AgentSession | None, + context: SessionContext, + state: dict[str, Any], ) -> None: """Extract user information from messages after each agent call.""" + request_messages = context.get_messages() # Check if we need to extract user info from user messages user_messages = [msg for msg in request_messages if hasattr(msg, "role") and msg.role == "user"] # type: ignore @@ -64,7 +65,14 @@ class UserInfoMemory(ContextProvider): except Exception: pass # Failed to extract, continue without updating - async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context: + async def before_run( + self, + *, + agent: Any, + session: AgentSession | None, + context: SessionContext, + state: dict[str, Any], + ) -> None: """Provide user information context before each agent call.""" instructions: list[str] = [] @@ -82,40 +90,39 @@ class UserInfoMemory(ContextProvider): else: instructions.append(f"The user's age is {self.user_info.age}.") - # Return context with additional instructions - return Context(instructions=" ".join(instructions)) + # Add context with additional instructions + context.extend_instructions(self.source_id, " ".join(instructions)) def serialize(self) -> str: - """Serialize the user info for thread persistence.""" + """Serialize the user info for session persistence.""" return self.user_info.model_dump_json() async def main(): async with AzureCliCredential() as credential: - chat_client = AzureAIClient(credential=credential) + client = AzureAIClient(credential=credential) # Create the memory provider - memory_provider = UserInfoMemory(chat_client) + memory_provider = UserInfoMemory(client) # Create the agent with memory - async with ChatAgent( - chat_client=chat_client, + async with Agent( + client=client, instructions="You are a friendly assistant. Always address the user by their name.", - context_provider=memory_provider, + context_providers=[memory_provider], ) as agent: - # Create a new thread for the conversation - thread = agent.get_new_thread() + # Create a new session for the conversation + session = agent.create_session() - print(await agent.run("Hello, what is the square root of 9?", thread=thread)) - print(await agent.run("My name is Ruaidhrí", thread=thread)) - print(await agent.run("I am 20 years old", thread=thread)) + print(await agent.run("Hello, what is the square root of 9?", session=session)) + print(await agent.run("My name is Ruaidhrí", session=session)) + print(await agent.run("I am 20 years old", session=session)) - # Access the memory component via the thread's get_service method and inspect the memories - user_info_memory = thread.context_provider.providers[0] # type: ignore - if user_info_memory: + # Access the memory component and inspect the memories + if memory_provider: print() - print(f"MEMORY - User Name: {user_info_memory.user_info.name}") # type: ignore - print(f"MEMORY - User Age: {user_info_memory.user_info.age}") # type: ignore + print(f"MEMORY - User Name: {memory_provider.user_info.name}") + print(f"MEMORY - User Age: {memory_provider.user_info.age}") if __name__ == "__main__": diff --git a/python/samples/02-agents/conversations/custom_chat_message_store_session.py b/python/samples/02-agents/conversations/custom_chat_message_store_session.py new file mode 100644 index 0000000000..e3ce5c5905 --- /dev/null +++ b/python/samples/02-agents/conversations/custom_chat_message_store_session.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from collections.abc import Sequence +from typing import Any + +from agent_framework import AgentSession, BaseHistoryProvider, Message +from agent_framework.openai import OpenAIChatClient + +""" +Custom History Provider Example + +This sample demonstrates how to implement and use a custom history provider +for session management, allowing you to persist conversation history in your +preferred storage solution (database, file system, etc.). +""" + + +class CustomHistoryProvider(BaseHistoryProvider): + """Implementation of custom history provider. + In real applications, this can be an implementation of relational database or vector store.""" + + def __init__(self) -> None: + super().__init__("custom-history") + self._storage: dict[str, list[Message]] = {} + + async def get_messages( + self, session_id: str | None, *, state: dict[str, Any] | None = None, **kwargs: Any + ) -> list[Message]: + key = session_id or "default" + return list(self._storage.get(key, [])) + + async def save_messages( + self, + session_id: str | None, + messages: Sequence[Message], + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + key = session_id or "default" + if key not in self._storage: + self._storage[key] = [] + self._storage[key].extend(messages) + + +async def main() -> None: + """Demonstrates how to use 3rd party or custom history provider for sessions.""" + print("=== Session with 3rd party or custom history provider ===") + + # OpenAI Chat Client is used as an example here, + # other chat clients can be used as well. + agent = OpenAIChatClient().as_agent( + name="CustomBot", + instructions="You are a helpful assistant that remembers our conversation.", + # Use custom history provider. + # If not provided, the default in-memory provider will be used. + context_providers=[CustomHistoryProvider()], + ) + + # Start a new session for the agent conversation. + session = agent.create_session() + + # Respond to user input. + query = "Hello! My name is Alice and I love pizza." + print(f"User: {query}") + print(f"Agent: {await agent.run(query, session=session)}\n") + + # Serialize the session state, so it can be stored for later use. + serialized_session = session.to_dict() + + # The session can now be saved to a database, file, or any other storage mechanism and loaded again later. + print(f"Serialized session: {serialized_session}\n") + + # Deserialize the session state after loading from storage. + resumed_session = AgentSession.from_dict(serialized_session) + + # Respond to user input. + query = "What do you remember about me?" + print(f"User: {query}") + print(f"Agent: {await agent.run(query, session=resumed_session)}\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/conversations/redis_chat_message_store_session.py b/python/samples/02-agents/conversations/redis_chat_message_store_session.py new file mode 100644 index 0000000000..f54edd8170 --- /dev/null +++ b/python/samples/02-agents/conversations/redis_chat_message_store_session.py @@ -0,0 +1,257 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from uuid import uuid4 + +from agent_framework import AgentSession +from agent_framework.openai import OpenAIChatClient +from agent_framework.redis import RedisHistoryProvider + +""" +Redis History Provider Session Example + +This sample demonstrates how to use Redis as a history provider for session +management, enabling persistent conversation history storage across sessions +with Redis as the backend data store. +""" + + +async def example_manual_memory_store() -> None: + """Basic example of using Redis history provider.""" + print("=== Basic Redis History Provider Example ===") + + # Create Redis history provider + redis_provider = RedisHistoryProvider( + source_id="redis_basic_chat", + redis_url="redis://localhost:6379", + ) + + # Create agent with Redis history provider + agent = OpenAIChatClient().as_agent( + name="RedisBot", + instructions="You are a helpful assistant that remembers our conversation using Redis.", + context_providers=[redis_provider], + ) + + # Create session + session = agent.create_session() + + # Have a conversation + print("\n--- Starting conversation ---") + query1 = "Hello! My name is Alice and I love pizza." + print(f"User: {query1}") + response1 = await agent.run(query1, session=session) + print(f"Agent: {response1.text}") + + query2 = "What do you remember about me?" + print(f"User: {query2}") + response2 = await agent.run(query2, session=session) + print(f"Agent: {response2.text}") + + print("Done\n") + + +async def example_user_session_management() -> None: + """Example of managing user sessions with Redis.""" + print("=== User Session Management Example ===") + + user_id = "alice_123" + session_id = f"session_{uuid4()}" + + # Create Redis history provider for specific user session + redis_provider = RedisHistoryProvider( + source_id=f"redis_{user_id}", + redis_url="redis://localhost:6379", + max_messages=10, # Keep only last 10 messages + ) + + # Create agent with history provider + agent = OpenAIChatClient().as_agent( + name="SessionBot", + instructions="You are a helpful assistant. Keep track of user preferences.", + context_providers=[redis_provider], + ) + + # Start conversation + session = agent.create_session(session_id=session_id) + + print(f"Started session for user {user_id}") + + # Simulate conversation + queries = [ + "Hi, I'm Alice and I prefer vegetarian food.", + "What restaurants would you recommend?", + "I also love Italian cuisine.", + "Can you remember my food preferences?", + ] + + for i, query in enumerate(queries, 1): + print(f"\n--- Message {i} ---") + print(f"User: {query}") + response = await agent.run(query, session=session) + print(f"Agent: {response.text}") + + print("Done\n") + + +async def example_conversation_persistence() -> None: + """Example of conversation persistence across application restarts.""" + print("=== Conversation Persistence Example ===") + + # Phase 1: Start conversation + print("--- Phase 1: Starting conversation ---") + redis_provider = RedisHistoryProvider( + source_id="redis_persistent_chat", + redis_url="redis://localhost:6379", + ) + + agent = OpenAIChatClient().as_agent( + name="PersistentBot", + instructions="You are a helpful assistant. Remember our conversation history.", + context_providers=[redis_provider], + ) + + session = agent.create_session() + + # Start conversation + query1 = "Hello! I'm working on a Python project about machine learning." + print(f"User: {query1}") + response1 = await agent.run(query1, session=session) + print(f"Agent: {response1.text}") + + query2 = "I'm specifically interested in neural networks." + print(f"User: {query2}") + response2 = await agent.run(query2, session=session) + print(f"Agent: {response2.text}") + + # Serialize session state + serialized = session.to_dict() + + # Phase 2: Resume conversation (simulating app restart) + print("\n--- Phase 2: Resuming conversation (after 'restart') ---") + restored_session = AgentSession.from_dict(serialized) + + # Continue conversation - agent should remember context + query3 = "What was I working on before?" + print(f"User: {query3}") + response3 = await agent.run(query3, session=restored_session) + print(f"Agent: {response3.text}") + + query4 = "Can you suggest some Python libraries for neural networks?" + print(f"User: {query4}") + response4 = await agent.run(query4, session=restored_session) + print(f"Agent: {response4.text}") + + print("Done\n") + + +async def example_session_serialization() -> None: + """Example of session state serialization and deserialization.""" + print("=== Session Serialization Example ===") + + redis_provider = RedisHistoryProvider( + source_id="redis_serialization_chat", + redis_url="redis://localhost:6379", + ) + + agent = OpenAIChatClient().as_agent( + name="SerializationBot", + instructions="You are a helpful assistant.", + context_providers=[redis_provider], + ) + + session = agent.create_session() + + # Have initial conversation + print("--- Initial conversation ---") + query1 = "Hello! I'm testing serialization." + print(f"User: {query1}") + response1 = await agent.run(query1, session=session) + print(f"Agent: {response1.text}") + + # Serialize session state + serialized = session.to_dict() + print(f"\nSerialized session state: {serialized}") + + # Deserialize session state (simulating loading from database/file) + print("\n--- Deserializing session state ---") + restored_session = AgentSession.from_dict(serialized) + + # Continue conversation with restored session + query2 = "Do you remember what I said about testing?" + print(f"User: {query2}") + response2 = await agent.run(query2, session=restored_session) + print(f"Agent: {response2.text}") + + print("Done\n") + + +async def example_message_limits() -> None: + """Example of automatic message trimming with limits.""" + print("=== Message Limits Example ===") + + # Create provider with small message limit + redis_provider = RedisHistoryProvider( + source_id="redis_limited_chat", + redis_url="redis://localhost:6379", + max_messages=3, # Keep only 3 most recent messages + ) + + agent = OpenAIChatClient().as_agent( + name="LimitBot", + instructions="You are a helpful assistant with limited memory.", + context_providers=[redis_provider], + ) + + session = agent.create_session() + + # Send multiple messages to test trimming + messages = [ + "Message 1: Hello!", + "Message 2: How are you?", + "Message 3: What's the weather?", + "Message 4: Tell me a joke.", + "Message 5: This should trigger trimming.", + ] + + for i, query in enumerate(messages, 1): + print(f"\n--- Sending message {i} ---") + print(f"User: {query}") + response = await agent.run(query, session=session) + print(f"Agent: {response.text}") + + print("Done\n") + + +async def main() -> None: + """Run all Redis history provider examples.""" + print("Redis History Provider Examples") + print("=" * 50) + print("Prerequisites:") + print("- Redis server running on localhost:6379") + print("- OPENAI_API_KEY environment variable set") + print("=" * 50) + + # Check prerequisites + if not os.getenv("OPENAI_API_KEY"): + print("ERROR: OPENAI_API_KEY environment variable not set") + return + + try: + # Run all examples + await example_manual_memory_store() + await example_user_session_management() + await example_conversation_persistence() + await example_session_serialization() + await example_message_limits() + + print("All examples completed successfully!") + + except Exception as e: + print(f"Error running examples: {e}") + raise + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/conversations/suspend_resume_session.py b/python/samples/02-agents/conversations/suspend_resume_session.py new file mode 100644 index 0000000000..dcbb00d06a --- /dev/null +++ b/python/samples/02-agents/conversations/suspend_resume_session.py @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import AgentSession +from agent_framework.azure import AzureAIAgentClient +from agent_framework.openai import OpenAIChatClient +from azure.identity.aio import AzureCliCredential + +""" +Session Suspend and Resume Example + +This sample demonstrates how to suspend and resume conversation sessions, comparing +service-managed sessions (Azure AI) with in-memory sessions (OpenAI) for persistent +conversation state across sessions. +""" + + +async def suspend_resume_service_managed_session() -> None: + """Demonstrates how to suspend and resume a service-managed session.""" + print("=== Suspend-Resume Service-Managed Session ===") + + # AzureAIAgentClient supports service-managed sessions. + async with ( + AzureCliCredential() as credential, + AzureAIAgentClient(credential=credential).as_agent( + name="MemoryBot", instructions="You are a helpful assistant that remembers our conversation." + ) as agent, + ): + # Start a new session for the agent conversation. + session = agent.create_session() + + # Respond to user input. + query = "Hello! My name is Alice and I love pizza." + print(f"User: {query}") + print(f"Agent: {await agent.run(query, session=session)}\n") + + # Serialize the session state, so it can be stored for later use. + serialized_session = session.to_dict() + + # The session can now be saved to a database, file, or any other storage mechanism and loaded again later. + print(f"Serialized session: {serialized_session}\n") + + # Deserialize the session state after loading from storage. + resumed_session = AgentSession.from_dict(serialized_session) + + # Respond to user input. + query = "What do you remember about me?" + print(f"User: {query}") + print(f"Agent: {await agent.run(query, session=resumed_session)}\n") + + +async def suspend_resume_in_memory_session() -> None: + """Demonstrates how to suspend and resume an in-memory session.""" + print("=== Suspend-Resume In-Memory Session ===") + + # OpenAI Chat Client is used as an example here, + # other chat clients can be used as well. + agent = OpenAIChatClient().as_agent( + name="MemoryBot", instructions="You are a helpful assistant that remembers our conversation." + ) + + # Start a new session for the agent conversation. + session = agent.create_session() + + # Respond to user input. + query = "Hello! My name is Alice and I love pizza." + print(f"User: {query}") + print(f"Agent: {await agent.run(query, session=session)}\n") + + # Serialize the session state, so it can be stored for later use. + serialized_session = session.to_dict() + + # The session can now be saved to a database, file, or any other storage mechanism and loaded again later. + print(f"Serialized session: {serialized_session}\n") + + # Deserialize the session state after loading from storage. + resumed_session = AgentSession.from_dict(serialized_session) + + # Respond to user input. + query = "What do you remember about me?" + print(f"User: {query}") + print(f"Agent: {await agent.run(query, session=resumed_session)}\n") + + +async def main() -> None: + print("=== Suspend-Resume Session Examples ===") + await suspend_resume_service_managed_session() + await suspend_resume_in_memory_session() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/declarative/README.md b/python/samples/02-agents/declarative/README.md similarity index 99% rename from python/samples/getting_started/declarative/README.md rename to python/samples/02-agents/declarative/README.md index 6241f632d4..35a75ef36b 100644 --- a/python/samples/getting_started/declarative/README.md +++ b/python/samples/02-agents/declarative/README.md @@ -175,7 +175,7 @@ agent = agent_factory.create_agent_from_yaml_path(Path("custom_provider.yaml")) This allows you to extend the declarative framework with custom chat client implementations. The mapping requires: - **package**: The Python package/module to import from -- **name**: The class name of your ChatClientProtocol implementation +- **name**: The class name of your SupportsChatGetResponse implementation - **model_id_field**: The constructor parameter name that accepts the value of the `model.id` field from the YAML You can reference your custom provider using either `Provider.ApiType` format or just `Provider` in your YAML configuration, as long as it matches the registered mapping. diff --git a/python/samples/getting_started/declarative/azure_openai_responses_agent.py b/python/samples/02-agents/declarative/azure_openai_responses_agent.py similarity index 100% rename from python/samples/getting_started/declarative/azure_openai_responses_agent.py rename to python/samples/02-agents/declarative/azure_openai_responses_agent.py diff --git a/python/samples/getting_started/declarative/get_weather_agent.py b/python/samples/02-agents/declarative/get_weather_agent.py similarity index 94% rename from python/samples/getting_started/declarative/get_weather_agent.py rename to python/samples/02-agents/declarative/get_weather_agent.py index 4e54af2461..af44382c00 100644 --- a/python/samples/getting_started/declarative/get_weather_agent.py +++ b/python/samples/02-agents/declarative/get_weather_agent.py @@ -26,7 +26,7 @@ async def main(): # create the AgentFactory with a chat client and bindings agent_factory = AgentFactory( - chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), bindings={"get_weather": get_weather}, ) # create the agent from the yaml diff --git a/python/samples/getting_started/declarative/inline_yaml.py b/python/samples/02-agents/declarative/inline_yaml.py similarity index 100% rename from python/samples/getting_started/declarative/inline_yaml.py rename to python/samples/02-agents/declarative/inline_yaml.py diff --git a/python/samples/getting_started/declarative/mcp_tool_yaml.py b/python/samples/02-agents/declarative/mcp_tool_yaml.py similarity index 100% rename from python/samples/getting_started/declarative/mcp_tool_yaml.py rename to python/samples/02-agents/declarative/mcp_tool_yaml.py diff --git a/python/samples/getting_started/declarative/microsoft_learn_agent.py b/python/samples/02-agents/declarative/microsoft_learn_agent.py similarity index 100% rename from python/samples/getting_started/declarative/microsoft_learn_agent.py rename to python/samples/02-agents/declarative/microsoft_learn_agent.py diff --git a/python/samples/getting_started/declarative/openai_responses_agent.py b/python/samples/02-agents/declarative/openai_responses_agent.py similarity index 100% rename from python/samples/getting_started/declarative/openai_responses_agent.py rename to python/samples/02-agents/declarative/openai_responses_agent.py diff --git a/python/samples/getting_started/devui/.gitignore b/python/samples/02-agents/devui/.gitignore similarity index 100% rename from python/samples/getting_started/devui/.gitignore rename to python/samples/02-agents/devui/.gitignore diff --git a/python/samples/getting_started/devui/README.md b/python/samples/02-agents/devui/README.md similarity index 96% rename from python/samples/getting_started/devui/README.md rename to python/samples/02-agents/devui/README.md index bfbee3a70b..2bdd6d2233 100644 --- a/python/samples/getting_started/devui/README.md +++ b/python/samples/02-agents/devui/README.md @@ -21,7 +21,7 @@ DevUI is a sample application that provides: Run a single sample directly. This demonstrates how to wrap agents and workflows programmatically without needing a directory structure: ```bash -cd python/samples/getting_started/devui +cd python/samples/02-agents/devui python in_memory_mode.py ``` @@ -32,7 +32,7 @@ This opens your browser at http://localhost:8090 with pre-configured agents and Launch DevUI to discover all samples in this folder: ```bash -cd python/samples/getting_started/devui +cd python/samples/02-agents/devui devui ``` @@ -44,7 +44,7 @@ Each agent/workflow follows a strict structure required by DevUI's discovery sys ``` agent_name/ -├── __init__.py # Must export: agent = ChatAgent(...) +├── __init__.py # Must export: agent = Agent(...) ├── agent.py # Agent implementation └── .env.example # Example environment variables ``` @@ -100,13 +100,13 @@ Example: ```python # my_agent/__init__.py -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.openai import OpenAIChatClient -agent = ChatAgent( +agent = Agent( name="MyAgent", description="My custom agent", - chat_client=OpenAIChatClient(), + client=OpenAIChatClient(), # ... your configuration ) ``` diff --git a/python/samples/getting_started/devui/azure_responses_agent/.env.example b/python/samples/02-agents/devui/azure_responses_agent/.env.example similarity index 100% rename from python/samples/getting_started/devui/azure_responses_agent/.env.example rename to python/samples/02-agents/devui/azure_responses_agent/.env.example diff --git a/python/samples/getting_started/devui/azure_responses_agent/__init__.py b/python/samples/02-agents/devui/azure_responses_agent/__init__.py similarity index 100% rename from python/samples/getting_started/devui/azure_responses_agent/__init__.py rename to python/samples/02-agents/devui/azure_responses_agent/__init__.py diff --git a/python/samples/getting_started/devui/azure_responses_agent/agent.py b/python/samples/02-agents/devui/azure_responses_agent/agent.py similarity index 93% rename from python/samples/getting_started/devui/azure_responses_agent/agent.py rename to python/samples/02-agents/devui/azure_responses_agent/agent.py index b2fbe9c995..bb7de3d54d 100644 --- a/python/samples/getting_started/devui/azure_responses_agent/agent.py +++ b/python/samples/02-agents/devui/azure_responses_agent/agent.py @@ -21,7 +21,7 @@ import logging import os from typing import Annotated -from agent_framework import ChatAgent, tool +from agent_framework import Agent, tool from agent_framework.azure import AzureOpenAIResponsesClient logger = logging.getLogger(__name__) @@ -50,7 +50,7 @@ def analyze_content( return f"Analyzing content for: {query}" -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def summarize_document( length: Annotated[str, "Desired summary length: 'brief', 'medium', or 'detailed'"] = "medium", @@ -68,7 +68,7 @@ def extract_key_points( # Agent using Azure OpenAI Responses API (supports PDF uploads!) -agent = ChatAgent( +agent = Agent( name="AzureResponsesAgent", description="An agent that can analyze PDFs, images, and other documents using Azure OpenAI Responses API", instructions=""" @@ -85,7 +85,7 @@ agent = ChatAgent( For PDFs, you can read and understand the text, tables, and structure. For images, you can describe what you see and extract any text. """, - chat_client=AzureOpenAIResponsesClient( + client=AzureOpenAIResponsesClient( deployment_name=_deployment_name, endpoint=_endpoint, api_version="2025-03-01-preview", # Required for Responses API diff --git a/python/samples/getting_started/devui/declarative/__init__.py b/python/samples/02-agents/devui/declarative/__init__.py similarity index 100% rename from python/samples/getting_started/devui/declarative/__init__.py rename to python/samples/02-agents/devui/declarative/__init__.py diff --git a/python/samples/getting_started/devui/declarative/workflow.py b/python/samples/02-agents/devui/declarative/workflow.py similarity index 100% rename from python/samples/getting_started/devui/declarative/workflow.py rename to python/samples/02-agents/devui/declarative/workflow.py diff --git a/python/samples/getting_started/devui/declarative/workflow.yaml b/python/samples/02-agents/devui/declarative/workflow.yaml similarity index 100% rename from python/samples/getting_started/devui/declarative/workflow.yaml rename to python/samples/02-agents/devui/declarative/workflow.yaml diff --git a/python/samples/getting_started/devui/fanout_workflow/__init__.py b/python/samples/02-agents/devui/fanout_workflow/__init__.py similarity index 100% rename from python/samples/getting_started/devui/fanout_workflow/__init__.py rename to python/samples/02-agents/devui/fanout_workflow/__init__.py diff --git a/python/samples/getting_started/devui/fanout_workflow/workflow.py b/python/samples/02-agents/devui/fanout_workflow/workflow.py similarity index 100% rename from python/samples/getting_started/devui/fanout_workflow/workflow.py rename to python/samples/02-agents/devui/fanout_workflow/workflow.py diff --git a/python/samples/getting_started/devui/foundry_agent/.env.example b/python/samples/02-agents/devui/foundry_agent/.env.example similarity index 100% rename from python/samples/getting_started/devui/foundry_agent/.env.example rename to python/samples/02-agents/devui/foundry_agent/.env.example diff --git a/python/samples/getting_started/devui/foundry_agent/__init__.py b/python/samples/02-agents/devui/foundry_agent/__init__.py similarity index 100% rename from python/samples/getting_started/devui/foundry_agent/__init__.py rename to python/samples/02-agents/devui/foundry_agent/__init__.py diff --git a/python/samples/getting_started/devui/foundry_agent/agent.py b/python/samples/02-agents/devui/foundry_agent/agent.py similarity index 90% rename from python/samples/getting_started/devui/foundry_agent/agent.py rename to python/samples/02-agents/devui/foundry_agent/agent.py index f2ce12058d..59599bce54 100644 --- a/python/samples/getting_started/devui/foundry_agent/agent.py +++ b/python/samples/02-agents/devui/foundry_agent/agent.py @@ -8,13 +8,13 @@ Make sure to run 'az login' before starting devui. import os from typing import Annotated -from agent_framework import ChatAgent, tool +from agent_framework import Agent, tool from agent_framework.azure import AzureAIAgentClient from azure.identity.aio import AzureCliCredential from pydantic import Field -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -43,9 +43,9 @@ def get_forecast( # Agent instance following Agent Framework conventions -agent = ChatAgent( +agent = Agent( name="FoundryWeatherAgent", - chat_client=AzureAIAgentClient( + client=AzureAIAgentClient( project_endpoint=os.environ.get("AZURE_AI_PROJECT_ENDPOINT"), model_deployment_name=os.environ.get("FOUNDRY_MODEL_DEPLOYMENT_NAME"), credential=AzureCliCredential(), diff --git a/python/samples/getting_started/devui/in_memory_mode.py b/python/samples/02-agents/devui/in_memory_mode.py similarity index 90% rename from python/samples/getting_started/devui/in_memory_mode.py rename to python/samples/02-agents/devui/in_memory_mode.py index 9f98d9be50..78e0ae18ed 100644 --- a/python/samples/getting_started/devui/in_memory_mode.py +++ b/python/samples/02-agents/devui/in_memory_mode.py @@ -10,13 +10,13 @@ import logging import os from typing import Annotated -from agent_framework import ChatAgent, Executor, WorkflowBuilder, WorkflowContext, handler, tool +from agent_framework import Agent, Executor, WorkflowBuilder, WorkflowContext, handler, tool from agent_framework.azure import AzureOpenAIChatClient from agent_framework.devui import serve from typing_extensions import Never -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") # Tool functions for the agent @tool(approval_mode="never_require") @@ -68,7 +68,7 @@ def main(): logger = logging.getLogger(__name__) # Create Azure OpenAI chat client - chat_client = AzureOpenAIChatClient( + client = AzureOpenAIChatClient( api_key=os.environ.get("AZURE_OPENAI_API_KEY"), azure_endpoint=os.environ.get("AZURE_OPENAI_ENDPOINT"), api_version=os.environ.get("AZURE_OPENAI_API_VERSION", "2024-10-21"), @@ -76,22 +76,22 @@ def main(): ) # Create agents - weather_agent = ChatAgent( + weather_agent = Agent( name="weather-assistant", description="Provides weather information and time", instructions=( "You are a helpful weather and time assistant. Use the available tools to " "provide accurate weather information and current time for any location." ), - chat_client=chat_client, + client=client, tools=[get_weather, get_time], ) - simple_agent = ChatAgent( + simple_agent = Agent( name="general-assistant", description="A simple conversational agent", instructions="You are a helpful assistant.", - chat_client=chat_client, + client=client, ) # Create a basic workflow: Input -> UpperCase -> AddExclamation -> Output diff --git a/python/samples/getting_started/devui/spam_workflow/__init__.py b/python/samples/02-agents/devui/spam_workflow/__init__.py similarity index 100% rename from python/samples/getting_started/devui/spam_workflow/__init__.py rename to python/samples/02-agents/devui/spam_workflow/__init__.py diff --git a/python/samples/getting_started/devui/spam_workflow/workflow.py b/python/samples/02-agents/devui/spam_workflow/workflow.py similarity index 100% rename from python/samples/getting_started/devui/spam_workflow/workflow.py rename to python/samples/02-agents/devui/spam_workflow/workflow.py diff --git a/python/samples/getting_started/devui/weather_agent_azure/.env.example b/python/samples/02-agents/devui/weather_agent_azure/.env.example similarity index 100% rename from python/samples/getting_started/devui/weather_agent_azure/.env.example rename to python/samples/02-agents/devui/weather_agent_azure/.env.example diff --git a/python/samples/getting_started/devui/weather_agent_azure/__init__.py b/python/samples/02-agents/devui/weather_agent_azure/__init__.py similarity index 100% rename from python/samples/getting_started/devui/weather_agent_azure/__init__.py rename to python/samples/02-agents/devui/weather_agent_azure/__init__.py diff --git a/python/samples/getting_started/devui/weather_agent_azure/agent.py b/python/samples/02-agents/devui/weather_agent_azure/agent.py similarity index 84% rename from python/samples/getting_started/devui/weather_agent_azure/agent.py rename to python/samples/02-agents/devui/weather_agent_azure/agent.py index 0ebf985913..38e7e11ce3 100644 --- a/python/samples/getting_started/devui/weather_agent_azure/agent.py +++ b/python/samples/02-agents/devui/weather_agent_azure/agent.py @@ -7,15 +7,16 @@ from collections.abc import AsyncIterable, Awaitable, Callable from typing import Annotated from agent_framework import ( - ChatAgent, + Agent, ChatContext, - ChatMessage, ChatResponse, ChatResponseUpdate, Content, FunctionInvocationContext, + Message, MiddlewareTermination, ResponseStream, + Role, chat_middleware, function_middleware, tool, @@ -37,14 +38,14 @@ def cleanup_resources(): @chat_middleware async def security_filter_middleware( context: ChatContext, - call_next: Callable[[ChatContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """Chat middleware that blocks requests containing sensitive information.""" blocked_terms = ["password", "secret", "api_key", "token"] # Check only the last message (most recent user input) last_message = context.messages[-1] if context.messages else None - if last_message and last_message.role == "user" and last_message.text: + if last_message and last_message.role == Role.USER and last_message.text: message_lower = last_message.text.lower() for term in blocked_terms: if term in message_lower: @@ -55,34 +56,37 @@ async def security_filter_middleware( ) if context.stream: - # Streaming mode: return async generator + # Streaming mode: wrap in ResponseStream async def blocked_stream(msg: str = error_message) -> AsyncIterable[ChatResponseUpdate]: yield ChatResponseUpdate( contents=[Content.from_text(text=msg)], - role="assistant", + role=Role.ASSISTANT, ) - context.result = ResponseStream(blocked_stream(), finalizer=ChatResponse.from_updates) + response = ChatResponse( + messages=[Message(role=Role.ASSISTANT, text=error_message)] + ) + context.result = ResponseStream(blocked_stream(), finalizer=lambda _, r=response: r) else: # Non-streaming mode: return complete response context.result = ChatResponse( messages=[ - ChatMessage( - role="assistant", + Message( + role=Role.ASSISTANT, text=error_message, ) ] ) - raise MiddlewareTermination + raise MiddlewareTermination(result=context.result) - await call_next(context) + await call_next() @function_middleware async def atlantis_location_filter_middleware( context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """Function middleware that blocks weather requests for Atlantis.""" # Check if location parameter is "atlantis" @@ -92,12 +96,12 @@ async def atlantis_location_filter_middleware( "Blocked! Hold up right there!! Tell the user that " "'Atlantis is a special place, we must never ask about the weather there!!'" ) - raise MiddlewareTermination + raise MiddlewareTermination(result=context.result) - await call_next(context) + await call_next() -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, "The location to get the weather for."], @@ -136,7 +140,7 @@ def send_email( # Agent instance following Agent Framework conventions -agent = ChatAgent( +agent = Agent( name="AzureWeatherAgent", description="A helpful agent that provides weather information and forecasts", instructions=""" @@ -144,7 +148,7 @@ agent = ChatAgent( and forecasts for any location. Always be helpful and provide detailed weather information when asked. """, - chat_client=AzureOpenAIChatClient( + client=AzureOpenAIChatClient( api_key=os.environ.get("AZURE_OPENAI_API_KEY", ""), ), tools=[get_weather, get_forecast, send_email], diff --git a/python/samples/getting_started/devui/workflow_agents/.env.example b/python/samples/02-agents/devui/workflow_agents/.env.example similarity index 100% rename from python/samples/getting_started/devui/workflow_agents/.env.example rename to python/samples/02-agents/devui/workflow_agents/.env.example diff --git a/python/samples/getting_started/devui/workflow_agents/__init__.py b/python/samples/02-agents/devui/workflow_agents/__init__.py similarity index 100% rename from python/samples/getting_started/devui/workflow_agents/__init__.py rename to python/samples/02-agents/devui/workflow_agents/__init__.py diff --git a/python/samples/getting_started/devui/workflow_agents/workflow.py b/python/samples/02-agents/devui/workflow_agents/workflow.py similarity index 95% rename from python/samples/getting_started/devui/workflow_agents/workflow.py rename to python/samples/02-agents/devui/workflow_agents/workflow.py index 288c9d5279..4331650bf1 100644 --- a/python/samples/getting_started/devui/workflow_agents/workflow.py +++ b/python/samples/02-agents/devui/workflow_agents/workflow.py @@ -59,10 +59,10 @@ def is_approved(message: Any) -> bool: # Create Azure OpenAI chat client -chat_client = AzureOpenAIChatClient(api_key=os.environ.get("AZURE_OPENAI_API_KEY", "")) +client = AzureOpenAIChatClient(api_key=os.environ.get("AZURE_OPENAI_API_KEY", "")) # Create Writer agent - generates content -writer = chat_client.as_agent( +writer = client.as_agent( name="Writer", instructions=( "You are an excellent content writer. " @@ -72,7 +72,7 @@ writer = chat_client.as_agent( ) # Create Reviewer agent - evaluates and provides structured feedback -reviewer = chat_client.as_agent( +reviewer = client.as_agent( name="Reviewer", instructions=( "You are an expert content reviewer. " @@ -90,7 +90,7 @@ reviewer = chat_client.as_agent( ) # Create Editor agent - improves content based on feedback -editor = chat_client.as_agent( +editor = client.as_agent( name="Editor", instructions=( "You are a skilled editor. " @@ -101,7 +101,7 @@ editor = chat_client.as_agent( ) # Create Publisher agent - formats content for publication -publisher = chat_client.as_agent( +publisher = client.as_agent( name="Publisher", instructions=( "You are a publishing agent. " @@ -111,7 +111,7 @@ publisher = chat_client.as_agent( ) # Create Summarizer agent - creates final publication report -summarizer = chat_client.as_agent( +summarizer = client.as_agent( name="Summarizer", instructions=( "You are a summarizer agent. " diff --git a/python/samples/getting_started/mcp/README.md b/python/samples/02-agents/mcp/README.md similarity index 100% rename from python/samples/getting_started/mcp/README.md rename to python/samples/02-agents/mcp/README.md diff --git a/python/samples/getting_started/mcp/agent_as_mcp_server.py b/python/samples/02-agents/mcp/agent_as_mcp_server.py similarity index 90% rename from python/samples/getting_started/mcp/agent_as_mcp_server.py rename to python/samples/02-agents/mcp/agent_as_mcp_server.py index 7d09663625..7d9a6ffe91 100644 --- a/python/samples/getting_started/mcp/agent_as_mcp_server.py +++ b/python/samples/02-agents/mcp/agent_as_mcp_server.py @@ -17,7 +17,7 @@ with the following configuration: "agent-framework": { "command": "uv", "args": [ - "--directory=/agent-framework/python/samples/getting_started/mcp", + "--directory=/agent-framework/python/samples/02-agents/mcp", "run", "agent_as_mcp_server.py" ], @@ -32,7 +32,7 @@ with the following configuration: """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_specials() -> Annotated[str, "Returns the specials from the menu."]: return """ diff --git a/python/samples/getting_started/mcp/mcp_api_key_auth.py b/python/samples/02-agents/mcp/mcp_api_key_auth.py similarity index 93% rename from python/samples/getting_started/mcp/mcp_api_key_auth.py rename to python/samples/02-agents/mcp/mcp_api_key_auth.py index d80d92d4fa..5790580116 100644 --- a/python/samples/getting_started/mcp/mcp_api_key_auth.py +++ b/python/samples/02-agents/mcp/mcp_api_key_auth.py @@ -2,7 +2,7 @@ import os -from agent_framework import ChatAgent, MCPStreamableHTTPTool +from agent_framework import Agent, MCPStreamableHTTPTool from agent_framework.openai import OpenAIResponsesClient from httpx import AsyncClient @@ -43,8 +43,8 @@ async def api_key_auth_example() -> None: url=mcp_server_url, http_client=http_client, # Pass HTTP client with authentication headers ) as mcp_tool, - ChatAgent( - chat_client=OpenAIResponsesClient(), + Agent( + client=OpenAIResponsesClient(), name="Agent", instructions="You are a helpful assistant.", tools=mcp_tool, diff --git a/python/samples/getting_started/mcp/mcp_github_pat.py b/python/samples/02-agents/mcp/mcp_github_pat.py similarity index 84% rename from python/samples/getting_started/mcp/mcp_github_pat.py rename to python/samples/02-agents/mcp/mcp_github_pat.py index 3d9d8c4916..85f514867e 100644 --- a/python/samples/getting_started/mcp/mcp_github_pat.py +++ b/python/samples/02-agents/mcp/mcp_github_pat.py @@ -3,7 +3,7 @@ import asyncio import os -from agent_framework import ChatAgent, HostedMCPTool +from agent_framework import Agent from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv @@ -42,20 +42,20 @@ async def github_mcp_example() -> None: "Authorization": f"Bearer {github_pat}", } - # 4. Create MCP tool with authentication - # HostedMCPTool manages the connection to the MCP server and makes its tools available + # 4. Create agent with the GitHub MCP tool using instance method + # The MCP tool manages the connection to the MCP server and makes its tools available # Set approval_mode="never_require" to allow the MCP tool to execute without approval - github_mcp_tool = HostedMCPTool( - name="GitHub", - description="Tool for interacting with GitHub.", - url="https://api.githubcopilot.com/mcp/", + client = OpenAIResponsesClient() + github_mcp_tool = client.get_mcp_tool( + server_label="GitHub", + server_url="https://api.githubcopilot.com/mcp/", headers=auth_headers, - approval_mode="never_require", + require_approval="never", ) # 5. Create agent with the GitHub MCP tool - async with ChatAgent( - chat_client=OpenAIResponsesClient(), + async with Agent( + client=client, name="GitHubAgent", instructions=( "You are a helpful assistant that can help users interact with GitHub. " diff --git a/python/samples/getting_started/middleware/agent_and_run_level_middleware.py b/python/samples/02-agents/middleware/agent_and_run_level_middleware.py similarity index 84% rename from python/samples/getting_started/middleware/agent_and_run_level_middleware.py rename to python/samples/02-agents/middleware/agent_and_run_level_middleware.py index b76e0ac520..cf1d97a586 100644 --- a/python/samples/getting_started/middleware/agent_and_run_level_middleware.py +++ b/python/samples/02-agents/middleware/agent_and_run_level_middleware.py @@ -31,11 +31,30 @@ The example shows: 3. Run-level context middleware for specific use cases (high priority, debugging) 4. Run-level caching middleware for expensive operations -Execution order: Agent middleware (outermost) -> Run middleware (innermost) -> Agent execution +Agent Middleware Execution Order: + When both agent-level and run-level *agent* middleware are configured, they execute + in this order: + + 1. Agent-level middleware (outermost) - executes first, in the order they were registered + 2. Run-level middleware (innermost) - executes next, in the order they were passed to run() + 3. Agent execution - the actual agent logic runs last + + For example, with agent middleware [A1, A2] and run middleware [R1, R2]: + Request -> A1 -> A2 -> R1 -> R2 -> Agent -> R2 -> R1 -> A2 -> A1 -> Response + + This means: + - Agent middleware wraps ALL run middleware and the agent + - Run middleware wraps only the agent for that specific run + - Each middleware can modify the context before AND after calling next() + + Note: Function and chat middleware (e.g., ``function_logging_middleware``) execute + during tool invocation *inside* the agent execution, not in the outer agent-middleware + chain shown above. They follow the same ordering principle: agent-level function/chat + middleware runs before run-level function/chat middleware. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -49,7 +68,7 @@ def get_weather( class SecurityAgentMiddleware(AgentMiddleware): """Agent-level security middleware that validates all requests.""" - async def process(self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: print("[SecurityMiddleware] Checking security for all requests...") # Check for security violations in the last user message @@ -62,18 +81,18 @@ class SecurityAgentMiddleware(AgentMiddleware): print("[SecurityMiddleware] Security check passed.") context.metadata["security_validated"] = True - await call_next(context) + await call_next() async def performance_monitor_middleware( context: AgentContext, - call_next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """Agent-level performance monitoring for all runs.""" print("[PerformanceMonitor] Starting performance monitoring...") start_time = time.time() - await call_next(context) + await call_next() end_time = time.time() duration = end_time - start_time @@ -85,7 +104,7 @@ async def performance_monitor_middleware( class HighPriorityMiddleware(AgentMiddleware): """Run-level middleware for high priority requests.""" - async def process(self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: print("[HighPriority] Processing high priority request with expedited handling...") # Read metadata set by agent-level middleware @@ -96,13 +115,13 @@ class HighPriorityMiddleware(AgentMiddleware): context.metadata["priority"] = "high" context.metadata["expedited"] = True - await call_next(context) + await call_next() print("[HighPriority] High priority processing completed") async def debugging_middleware( context: AgentContext, - call_next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """Run-level debugging middleware for troubleshooting specific runs.""" print("[Debug] Debug mode enabled for this run") @@ -115,7 +134,7 @@ async def debugging_middleware( context.metadata["debug_enabled"] = True - await call_next(context) + await call_next() print("[Debug] Debug information collected") @@ -126,7 +145,7 @@ class CachingMiddleware(AgentMiddleware): def __init__(self) -> None: self.cache: dict[str, AgentResponse] = {} - async def process(self, context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: # Create a simple cache key from the last message last_message = context.messages[-1] if context.messages else None cache_key: str = last_message.text if last_message and last_message.text else "no_message" @@ -139,7 +158,7 @@ class CachingMiddleware(AgentMiddleware): print(f"[Cache] Cache MISS for: '{cache_key[:30]}...'") context.metadata["cache_key"] = cache_key - await call_next(context) + await call_next() # Cache the result if we have one if context.result: @@ -149,14 +168,14 @@ class CachingMiddleware(AgentMiddleware): async def function_logging_middleware( context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """Function middleware that logs all function calls.""" function_name = context.function.name args = context.arguments print(f"[FunctionLog] Calling function: {function_name} with args: {args}") - await call_next(context) + await call_next() print(f"[FunctionLog] Function {function_name} completed") @@ -256,7 +275,7 @@ async def main() -> None: query = "What's the secret weather password for Berlin?" print(f"User: {query}") result = await agent.run(query) - print(f"Agent: {result.text if result.text else 'Request was blocked by security middleware'}") + print(f"Agent: {result.text if result and result.text else 'Request was blocked by security middleware'}") print() # Run 7: Normal query again (no run-level middleware interference) diff --git a/python/samples/getting_started/middleware/chat_middleware.py b/python/samples/02-agents/middleware/chat_middleware.py similarity index 94% rename from python/samples/getting_started/middleware/chat_middleware.py rename to python/samples/02-agents/middleware/chat_middleware.py index e35ba5981f..f139f11a9e 100644 --- a/python/samples/getting_started/middleware/chat_middleware.py +++ b/python/samples/02-agents/middleware/chat_middleware.py @@ -7,9 +7,9 @@ from typing import Annotated from agent_framework import ( ChatContext, - ChatMessage, ChatMiddleware, ChatResponse, + Message, MiddlewareTermination, chat_middleware, tool, @@ -37,7 +37,7 @@ The example covers: """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -57,7 +57,7 @@ class InputObserverMiddleware(ChatMiddleware): async def process( self, context: ChatContext, - call_next: Callable[[ChatContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """Observe and modify input messages before they are sent to AI.""" print("[InputObserverMiddleware] Observing input messages:") @@ -69,7 +69,7 @@ class InputObserverMiddleware(ChatMiddleware): print(f"[InputObserverMiddleware] Total messages: {len(context.messages)}") # Modify user messages by creating new messages with enhanced text - modified_messages: list[ChatMessage] = [] + modified_messages: list[Message] = [] modified_count = 0 for message in context.messages: @@ -81,7 +81,7 @@ class InputObserverMiddleware(ChatMiddleware): updated_text = self.replacement print(f"[InputObserverMiddleware] Updated: '{original_text}' -> '{updated_text}'") - modified_message = ChatMessage(message.role, [updated_text]) + modified_message = Message(message.role, [updated_text]) modified_messages.append(modified_message) modified_count += 1 else: @@ -91,7 +91,7 @@ class InputObserverMiddleware(ChatMiddleware): context.messages[:] = modified_messages # Continue to next middleware or AI execution - await call_next(context) + await call_next() # Observe that processing is complete print("[InputObserverMiddleware] Processing completed") @@ -100,7 +100,7 @@ class InputObserverMiddleware(ChatMiddleware): @chat_middleware async def security_and_override_middleware( context: ChatContext, - call_next: Callable[[ChatContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """Function-based middleware that implements security filtering and response override.""" print("[SecurityMiddleware] Processing input...") @@ -118,7 +118,7 @@ async def security_and_override_middleware( # Override the response instead of calling AI context.result = ChatResponse( messages=[ - ChatMessage( + Message( role="assistant", text="I cannot process requests containing sensitive information. " "Please rephrase your question without including passwords, secrets, or other " @@ -131,7 +131,7 @@ async def security_and_override_middleware( raise MiddlewareTermination # Continue to next middleware or AI execution - await call_next(context) + await call_next() async def class_based_chat_middleware() -> None: diff --git a/python/samples/getting_started/middleware/class_based_middleware.py b/python/samples/02-agents/middleware/class_based_middleware.py similarity index 89% rename from python/samples/getting_started/middleware/class_based_middleware.py rename to python/samples/02-agents/middleware/class_based_middleware.py index ab6bfd5ab4..7bdb02cc69 100644 --- a/python/samples/getting_started/middleware/class_based_middleware.py +++ b/python/samples/02-agents/middleware/class_based_middleware.py @@ -10,9 +10,9 @@ from agent_framework import ( AgentContext, AgentMiddleware, AgentResponse, - ChatMessage, FunctionInvocationContext, FunctionMiddleware, + Message, tool, ) from agent_framework.azure import AzureAIAgentClient @@ -34,7 +34,7 @@ from object-oriented design patterns. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -50,7 +50,7 @@ class SecurityAgentMiddleware(AgentMiddleware): async def process( self, context: AgentContext, - call_next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: # Check for potential security violations in the query # Look at the last user message @@ -61,13 +61,13 @@ class SecurityAgentMiddleware(AgentMiddleware): print("[SecurityAgentMiddleware] Security Warning: Detected sensitive information, blocking request.") # Override the result with warning message context.result = AgentResponse( - messages=[ChatMessage("assistant", ["Detected sensitive information, the request is blocked."])] + messages=[Message("assistant", ["Detected sensitive information, the request is blocked."])] ) # Simply don't call call_next() to prevent execution return print("[SecurityAgentMiddleware] Security check passed.") - await call_next(context) + await call_next() class LoggingFunctionMiddleware(FunctionMiddleware): @@ -76,14 +76,14 @@ class LoggingFunctionMiddleware(FunctionMiddleware): async def process( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: function_name = context.function.name print(f"[LoggingFunctionMiddleware] About to call function: {function_name}.") start_time = time.time() - await call_next(context) + await call_next() end_time = time.time() duration = end_time - start_time diff --git a/python/samples/getting_started/middleware/decorator_middleware.py b/python/samples/02-agents/middleware/decorator_middleware.py similarity index 93% rename from python/samples/getting_started/middleware/decorator_middleware.py rename to python/samples/02-agents/middleware/decorator_middleware.py index 3f5e57e48e..5b22b80cc0 100644 --- a/python/samples/getting_started/middleware/decorator_middleware.py +++ b/python/samples/02-agents/middleware/decorator_middleware.py @@ -42,7 +42,7 @@ Key benefits of decorator approach: """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_current_time() -> str: """Get the current time.""" @@ -53,7 +53,7 @@ def get_current_time() -> str: async def simple_agent_middleware(context, call_next): # type: ignore - parameters intentionally untyped to demonstrate decorator functionality """Agent middleware that runs before and after agent execution.""" print("[Agent MiddlewareTypes] Before agent execution") - await call_next(context) + await call_next() print("[Agent MiddlewareTypes] After agent execution") @@ -61,7 +61,7 @@ async def simple_agent_middleware(context, call_next): # type: ignore - paramet async def simple_function_middleware(context, call_next): # type: ignore - parameters intentionally untyped to demonstrate decorator functionality """Function middleware that runs before and after function calls.""" print(f"[Function MiddlewareTypes] Before calling: {context.function.name}") # type: ignore - await call_next(context) + await call_next() print(f"[Function MiddlewareTypes] After calling: {context.function.name}") # type: ignore diff --git a/python/samples/getting_started/middleware/exception_handling_with_middleware.py b/python/samples/02-agents/middleware/exception_handling_with_middleware.py similarity index 90% rename from python/samples/getting_started/middleware/exception_handling_with_middleware.py rename to python/samples/02-agents/middleware/exception_handling_with_middleware.py index b929af4c94..d8626a095e 100644 --- a/python/samples/getting_started/middleware/exception_handling_with_middleware.py +++ b/python/samples/02-agents/middleware/exception_handling_with_middleware.py @@ -24,7 +24,7 @@ a helpful message for the user, preventing raw exceptions from reaching the end """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def unstable_data_service( query: Annotated[str, Field(description="The data query to execute.")], @@ -35,13 +35,13 @@ def unstable_data_service( async def exception_handling_middleware( - context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] ) -> None: function_name = context.function.name try: print(f"[ExceptionHandlingMiddleware] Executing function: {function_name}") - await call_next(context) + await call_next() print(f"[ExceptionHandlingMiddleware] Function {function_name} completed successfully.") except TimeoutError as e: print(f"[ExceptionHandlingMiddleware] Caught TimeoutError: {e}") diff --git a/python/samples/getting_started/middleware/function_based_middleware.py b/python/samples/02-agents/middleware/function_based_middleware.py similarity index 89% rename from python/samples/getting_started/middleware/function_based_middleware.py rename to python/samples/02-agents/middleware/function_based_middleware.py index d9b9062003..ad0679219a 100644 --- a/python/samples/getting_started/middleware/function_based_middleware.py +++ b/python/samples/02-agents/middleware/function_based_middleware.py @@ -31,7 +31,7 @@ can be implemented as async functions that accept context and call_next paramete """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -43,7 +43,7 @@ def get_weather( async def security_agent_middleware( context: AgentContext, - call_next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """Agent middleware that checks for security violations.""" # Check for potential security violations in the query @@ -57,12 +57,12 @@ async def security_agent_middleware( return print("[SecurityAgentMiddleware] Security check passed.") - await call_next(context) + await call_next() async def logging_function_middleware( context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """Function middleware that logs function calls.""" function_name = context.function.name @@ -70,7 +70,7 @@ async def logging_function_middleware( start_time = time.time() - await call_next(context) + await call_next() end_time = time.time() duration = end_time - start_time @@ -105,7 +105,7 @@ async def main() -> None: query = "What's the secret weather password?" print(f"User: {query}") result = await agent.run(query) - print(f"Agent: {result.text if result.text else 'No response'}\n") + print(f"Agent: {result.text if result and result.text else 'No response'}\n") if __name__ == "__main__": diff --git a/python/samples/getting_started/middleware/middleware_termination.py b/python/samples/02-agents/middleware/middleware_termination.py similarity index 89% rename from python/samples/getting_started/middleware/middleware_termination.py rename to python/samples/02-agents/middleware/middleware_termination.py index 96c5917f58..47be212dda 100644 --- a/python/samples/getting_started/middleware/middleware_termination.py +++ b/python/samples/02-agents/middleware/middleware_termination.py @@ -9,7 +9,7 @@ from agent_framework import ( AgentContext, AgentMiddleware, AgentResponse, - ChatMessage, + Message, MiddlewareTermination, tool, ) @@ -30,7 +30,7 @@ This is useful for implementing security checks, rate limiting, or early exit co """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -49,7 +49,7 @@ class PreTerminationMiddleware(AgentMiddleware): async def process( self, context: AgentContext, - call_next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: # Check if the user message contains any blocked words last_message = context.messages[-1] if context.messages else None @@ -62,7 +62,7 @@ class PreTerminationMiddleware(AgentMiddleware): # Set a custom response context.result = AgentResponse( messages=[ - ChatMessage( + Message( role="assistant", text=( f"Sorry, I cannot process requests containing '{blocked_word}'. " @@ -72,10 +72,10 @@ class PreTerminationMiddleware(AgentMiddleware): ] ) - # Set terminate flag to prevent further processing - raise MiddlewareTermination + # Terminate to prevent further processing + raise MiddlewareTermination(result=context.result) - await call_next(context) + await call_next() class PostTerminationMiddleware(AgentMiddleware): @@ -88,7 +88,7 @@ class PostTerminationMiddleware(AgentMiddleware): async def process( self, context: AgentContext, - call_next: Callable[[AgentContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: print(f"[PostTerminationMiddleware] Processing request (response count: {self.response_count})") @@ -101,7 +101,7 @@ class PostTerminationMiddleware(AgentMiddleware): raise MiddlewareTermination # Allow the agent to process normally - await call_next(context) + await call_next() # Increment response count after processing self.response_count += 1 @@ -158,14 +158,14 @@ async def post_termination_middleware() -> None: query = "What about the weather in London?" print(f"User: {query}") result = await agent.run(query) - print(f"Agent: {result.text if result.text else 'No response (terminated)'}") + print(f"Agent: {result.text if result and result.text else 'No response (terminated)'}") # Third run (should also be terminated) print("\n3. Third run (should also be terminated):") query = "And New York?" print(f"User: {query}") result = await agent.run(query) - print(f"Agent: {result.text if result.text else 'No response (terminated)'}") + print(f"Agent: {result.text if result and result.text else 'No response (terminated)'}") async def main() -> None: diff --git a/python/samples/getting_started/middleware/override_result_with_middleware.py b/python/samples/02-agents/middleware/override_result_with_middleware.py similarity index 83% rename from python/samples/getting_started/middleware/override_result_with_middleware.py rename to python/samples/02-agents/middleware/override_result_with_middleware.py index 6f83c4bee2..efaae28a9b 100644 --- a/python/samples/getting_started/middleware/override_result_with_middleware.py +++ b/python/samples/02-agents/middleware/override_result_with_middleware.py @@ -11,10 +11,11 @@ from agent_framework import ( AgentResponse, AgentResponseUpdate, ChatContext, - ChatMessage, ChatResponse, ChatResponseUpdate, + Message, ResponseStream, + Role, tool, ) from agent_framework.openai import OpenAIResponsesClient @@ -38,7 +39,7 @@ it creates a custom async generator that yields the override message in chunks. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -48,11 +49,11 @@ def get_weather( return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." -async def weather_override_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: +async def weather_override_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: """Chat middleware that overrides weather results for both streaming and non-streaming cases.""" # Let the original agent execution complete first - await call_next(context) + await call_next() # Check if there's a result to override (agent called weather function) if context.result is not None: @@ -78,14 +79,14 @@ async def weather_override_middleware(context: ChatContext, call_next: Callable[ context.result.with_transform_hook(_update_hook) else: # For non-streaming: just replace with a new message - current_text = context.result.text or "" # type: ignore + current_text = context.result.text if isinstance(context.result, ChatResponse) else "" custom_message = f"Weather Advisory: [0] {''.join(chunks)} Original message was: {current_text}" - context.result = ChatResponse(messages=[ChatMessage(role="assistant", text=custom_message)]) + context.result = ChatResponse(messages=[Message(role=Role.ASSISTANT, text=custom_message)]) -async def validate_weather_middleware(context: ChatContext, call_next: Callable[[ChatContext], Awaitable[None]]) -> None: +async def validate_weather_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: """Chat middleware that simulates result validation for both streaming and non-streaming cases.""" - await call_next(context) + await call_next() validation_note = "Validation: weather data verified." @@ -95,17 +96,17 @@ async def validate_weather_middleware(context: ChatContext, call_next: Callable[ if context.stream and isinstance(context.result, ResponseStream): def _append_validation_note(response: ChatResponse) -> ChatResponse: - response.messages.append(ChatMessage(role="assistant", text=validation_note)) + response.messages.append(Message(role=Role.ASSISTANT, text=validation_note)) return response - context.result.with_result_hook(_append_validation_note) + context.result.with_finalizer(_append_validation_note) elif isinstance(context.result, ChatResponse): - context.result.messages.append(ChatMessage(role="assistant", text=validation_note)) + context.result.messages.append(Message(role=Role.ASSISTANT, text=validation_note)) -async def agent_cleanup_middleware(context: AgentContext, call_next: Callable[[AgentContext], Awaitable[None]]) -> None: +async def agent_cleanup_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: """Agent middleware that validates chat middleware effects and cleans the result.""" - await call_next(context) + await call_next() if context.result is None: return @@ -117,7 +118,7 @@ async def agent_cleanup_middleware(context: AgentContext, call_next: Callable[[A def _sanitize(response: AgentResponse) -> AgentResponse: found_prefix = state["found_prefix"] found_validation = False - cleaned_messages: list[ChatMessage] = [] + cleaned_messages: list[Message] = [] for message in response.messages: text = message.text @@ -138,7 +139,7 @@ async def agent_cleanup_middleware(context: AgentContext, call_next: Callable[[A text = re.sub(r"\[\d+\]\s*", "", text) cleaned_messages.append( - ChatMessage( + Message( role=message.role, text=text.strip(), author_name=message.author_name, @@ -153,7 +154,7 @@ async def agent_cleanup_middleware(context: AgentContext, call_next: Callable[[A if not found_validation: raise RuntimeError("Expected validation note not found in agent response.") - cleaned_messages.append(ChatMessage(role="assistant", text=" Agent: OK")) + cleaned_messages.append(Message(role=Role.ASSISTANT, text=" Agent: OK")) response.messages = cleaned_messages return response @@ -172,7 +173,7 @@ async def agent_cleanup_middleware(context: AgentContext, call_next: Callable[[A return update context.result.with_transform_hook(_clean_update) - context.result.with_result_hook(_sanitize) + context.result.with_finalizer(_sanitize) elif isinstance(context.result, AgentResponse): context.result = _sanitize(context.result) @@ -191,19 +192,6 @@ async def main() -> None: tools=get_weather, middleware=[agent_cleanup_middleware], ) - # Streaming example - print("\n--- Streaming Example ---") - query = "What's the weather like in Portland?" - print(f"User: {query}") - print("Agent: ", end="", flush=True) - response = agent.run(query, stream=True) - # add the hooks to print what you want to see - response.with_transform_hook(lambda chunk: print(chunk.text, end="", flush=True)).with_result_hook( - lambda final: print(f"\nFinal streamed response: {final.text}", flush=True) - ) - # consume the stream to trigger the hooks - await response.get_final_response() - # Non-streaming example print("\n--- Non-streaming Example ---") query = "What's the weather like in Seattle?" @@ -211,6 +199,18 @@ async def main() -> None: result = await agent.run(query) print(f"Agent: {result}") + # Streaming example + print("\n--- Streaming Example ---") + query = "What's the weather like in Portland?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + response = agent.run(query, stream=True) + async for chunk in response: + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + print(f"Final Result: {(await response.get_final_response()).text}") + if __name__ == "__main__": asyncio.run(main()) diff --git a/python/samples/getting_started/middleware/runtime_context_delegation.py b/python/samples/02-agents/middleware/runtime_context_delegation.py similarity index 96% rename from python/samples/getting_started/middleware/runtime_context_delegation.py rename to python/samples/02-agents/middleware/runtime_context_delegation.py index 700b1da6f5..a27e945a8a 100644 --- a/python/samples/getting_started/middleware/runtime_context_delegation.py +++ b/python/samples/02-agents/middleware/runtime_context_delegation.py @@ -54,7 +54,7 @@ class SessionContextContainer: async def inject_context_middleware( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """MiddlewareTypes that extracts runtime context from kwargs and stores in container. @@ -74,14 +74,14 @@ class SessionContextContainer: print(f" - Session Metadata Keys: {list(self.session_metadata.keys())}") # Continue to tool execution - await call_next(context) + await call_next() # Create a container instance that will be shared via closure runtime_context = SessionContextContainer() -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") async def send_email( to: Annotated[str, Field(description="Recipient email address")], @@ -278,19 +278,19 @@ async def pattern_2_hierarchical_with_kwargs_propagation() -> None: @function_middleware async def email_kwargs_tracker( - context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] ) -> None: email_agent_kwargs.update(context.kwargs) print(f"[EmailAgent] Received runtime context: {list(context.kwargs.keys())}") - await call_next(context) + await call_next() @function_middleware async def sms_kwargs_tracker( - context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] ) -> None: sms_agent_kwargs.update(context.kwargs) print(f"[SMSAgent] Received runtime context: {list(context.kwargs.keys())}") - await call_next(context) + await call_next() client = OpenAIChatClient(model_id="gpt-4o-mini") @@ -359,7 +359,7 @@ class AuthContextMiddleware: self.validated_tokens: list[str] = [] async def validate_and_track( - self, context: FunctionInvocationContext, call_next: Callable[[FunctionInvocationContext], Awaitable[None]] + self, context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] ) -> None: """Validate API token and track usage.""" api_token = context.kwargs.get("api_token") @@ -375,7 +375,7 @@ class AuthContextMiddleware: else: print("[AuthMiddleware] No API token provided") - await call_next(context) + await call_next() @tool(approval_mode="never_require") diff --git a/python/samples/02-agents/middleware/session_behavior_middleware.py b/python/samples/02-agents/middleware/session_behavior_middleware.py new file mode 100644 index 0000000000..02f50c98b8 --- /dev/null +++ b/python/samples/02-agents/middleware/session_behavior_middleware.py @@ -0,0 +1,101 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from collections.abc import Awaitable, Callable +from typing import Annotated + +from agent_framework import ( + AgentContext, + tool, +) +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential +from pydantic import Field + +""" +Thread Behavior MiddlewareTypes Example + +This sample demonstrates how middleware can access and track session state across multiple agent runs. +The example shows: + +- How AgentContext.session property behaves across multiple runs +- How middleware can access conversation history through the session +- The timing of when session messages are populated (before vs after call_next() call) +- How to track session state changes across runs + +Key behaviors demonstrated: +1. First run: context.messages is populated, context.session is initially empty (before call_next()) +2. After call_next(): session contains input message + response from agent +3. Second run: context.messages contains only current input, session contains previous history +4. After call_next(): session contains full conversation history (all previous + current messages) +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + from random import randint + + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def thread_tracking_middleware( + context: AgentContext, + call_next: Callable[[], Awaitable[None]], +) -> None: + """MiddlewareTypes that tracks and logs session behavior across runs.""" + session_message_count = 0 + if context.session: + memory_state = context.session.state.get("memory", {}) + session_message_count = len(memory_state.get("messages", [])) + + print(f"[MiddlewareTypes pre-execution] Current input messages: {len(context.messages)}") + print(f"[MiddlewareTypes pre-execution] Session history messages: {session_message_count}") + + # Call call_next to execute the agent + await call_next() + + # Check session state after agent execution + updated_session_message_count = 0 + if context.session: + memory_state = context.session.state.get("memory", {}) + updated_session_message_count = len(memory_state.get("messages", [])) + + print(f"[MiddlewareTypes post-execution] Updated session messages: {updated_session_message_count}") + + +async def main() -> None: + """Example demonstrating session behavior in middleware across multiple runs.""" + print("=== Session Behavior MiddlewareTypes Example ===") + + # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred + # authentication option. + agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + name="WeatherAgent", + instructions="You are a helpful weather assistant.", + tools=get_weather, + middleware=[thread_tracking_middleware], + ) + + # Create a session that will persist messages between runs + session = agent.create_session() + + print("\nFirst Run:") + query1 = "What's the weather like in Tokyo?" + print(f"User: {query1}") + result1 = await agent.run(query1, session=session) + print(f"Agent: {result1.text}") + + print("\nSecond Run:") + query2 = "How about in London?" + print(f"User: {query2}") + result2 = await agent.run(query2, session=session) + print(f"Agent: {result2.text}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/middleware/shared_state_middleware.py b/python/samples/02-agents/middleware/shared_state_middleware.py similarity index 92% rename from python/samples/getting_started/middleware/shared_state_middleware.py rename to python/samples/02-agents/middleware/shared_state_middleware.py index a377d7dfd3..3fe80f47a4 100644 --- a/python/samples/getting_started/middleware/shared_state_middleware.py +++ b/python/samples/02-agents/middleware/shared_state_middleware.py @@ -27,7 +27,7 @@ This approach shows how middleware can work together by sharing state within the """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -57,7 +57,7 @@ class MiddlewareContainer: async def call_counter_middleware( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """First middleware: increments call count in shared state.""" # Increment the shared call count @@ -66,18 +66,18 @@ class MiddlewareContainer: print(f"[CallCounter] This is function call #{self.call_count}") # Call the next middleware/function - await call_next(context) + await call_next() async def result_enhancer_middleware( self, context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """Second middleware: uses shared call count to enhance function results.""" print(f"[ResultEnhancer] Current total calls so far: {self.call_count}") # Call the next middleware/function - await call_next(context) + await call_next() # After function execution, enhance the result using shared state if context.result: diff --git a/python/samples/getting_started/multimodal_input/README.md b/python/samples/02-agents/multimodal_input/README.md similarity index 100% rename from python/samples/getting_started/multimodal_input/README.md rename to python/samples/02-agents/multimodal_input/README.md diff --git a/python/samples/getting_started/multimodal_input/azure_chat_multimodal.py b/python/samples/02-agents/multimodal_input/azure_chat_multimodal.py similarity index 95% rename from python/samples/getting_started/multimodal_input/azure_chat_multimodal.py rename to python/samples/02-agents/multimodal_input/azure_chat_multimodal.py index 826afcd28d..369221ac36 100644 --- a/python/samples/getting_started/multimodal_input/azure_chat_multimodal.py +++ b/python/samples/02-agents/multimodal_input/azure_chat_multimodal.py @@ -2,7 +2,7 @@ import asyncio -from agent_framework import ChatMessage, Content +from agent_framework import Content, Message from agent_framework.azure import AzureOpenAIChatClient from azure.identity import AzureCliCredential @@ -24,7 +24,7 @@ async def test_image() -> None: client = AzureOpenAIChatClient(credential=AzureCliCredential()) image_uri = create_sample_image() - message = ChatMessage( + message = Message( role="user", contents=[ Content.from_text(text="What's in this image?"), diff --git a/python/samples/getting_started/multimodal_input/azure_responses_multimodal.py b/python/samples/02-agents/multimodal_input/azure_responses_multimodal.py similarity index 93% rename from python/samples/getting_started/multimodal_input/azure_responses_multimodal.py rename to python/samples/02-agents/multimodal_input/azure_responses_multimodal.py index af9bdb0f0a..7a71553f1e 100644 --- a/python/samples/getting_started/multimodal_input/azure_responses_multimodal.py +++ b/python/samples/02-agents/multimodal_input/azure_responses_multimodal.py @@ -3,11 +3,11 @@ import asyncio from pathlib import Path -from agent_framework import ChatMessage, Content +from agent_framework import Content, Message from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential -ASSETS_DIR = Path(__file__).resolve().parent.parent / "sample_assets" +ASSETS_DIR = Path(__file__).resolve().parents[2] / "shared" / "sample_assets" def load_sample_pdf() -> bytes: @@ -33,7 +33,7 @@ async def test_image() -> None: client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) image_uri = create_sample_image() - message = ChatMessage( + message = Message( role="user", contents=[ Content.from_text(text="What's in this image?"), @@ -50,7 +50,7 @@ async def test_pdf() -> None: client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) pdf_bytes = load_sample_pdf() - message = ChatMessage( + message = Message( role="user", contents=[ Content.from_text(text="What information can you extract from this document?"), diff --git a/python/samples/getting_started/multimodal_input/openai_chat_multimodal.py b/python/samples/02-agents/multimodal_input/openai_chat_multimodal.py similarity index 93% rename from python/samples/getting_started/multimodal_input/openai_chat_multimodal.py rename to python/samples/02-agents/multimodal_input/openai_chat_multimodal.py index 669b963609..f752d8a52c 100644 --- a/python/samples/getting_started/multimodal_input/openai_chat_multimodal.py +++ b/python/samples/02-agents/multimodal_input/openai_chat_multimodal.py @@ -5,10 +5,10 @@ import base64 import struct from pathlib import Path -from agent_framework import ChatMessage, Content +from agent_framework import Content, Message from agent_framework.openai import OpenAIChatClient -ASSETS_DIR = Path(__file__).resolve().parent.parent / "sample_assets" +ASSETS_DIR = Path(__file__).resolve().parents[2] / "shared" / "sample_assets" def load_sample_pdf() -> bytes: @@ -45,7 +45,7 @@ async def test_image() -> None: client = OpenAIChatClient(model_id="gpt-4o") image_uri = create_sample_image() - message = ChatMessage( + message = Message( role="user", contents=[ Content.from_text(text="What's in this image?"), @@ -62,7 +62,7 @@ async def test_audio() -> None: client = OpenAIChatClient(model_id="gpt-4o-audio-preview") audio_uri = create_sample_audio() - message = ChatMessage( + message = Message( role="user", contents=[ Content.from_text(text="What do you hear in this audio?"), @@ -79,7 +79,7 @@ async def test_pdf() -> None: client = OpenAIChatClient(model_id="gpt-4o") pdf_bytes = load_sample_pdf() - message = ChatMessage( + message = Message( role="user", contents=[ Content.from_text(text="What information can you extract from this document?"), diff --git a/python/samples/getting_started/observability/.env.example b/python/samples/02-agents/observability/.env.example similarity index 100% rename from python/samples/getting_started/observability/.env.example rename to python/samples/02-agents/observability/.env.example diff --git a/python/samples/getting_started/observability/README.md b/python/samples/02-agents/observability/README.md similarity index 99% rename from python/samples/getting_started/observability/README.md rename to python/samples/02-agents/observability/README.md index d42162b23c..d0e4ea06d1 100644 --- a/python/samples/getting_started/observability/README.md +++ b/python/samples/02-agents/observability/README.md @@ -212,7 +212,7 @@ This folder contains different samples demonstrating how to use telemetry in var ### Running the samples -1. Open a terminal and navigate to this folder: `python/samples/getting_started/observability/`. This is necessary for the `.env` file to be read correctly. +1. Open a terminal and navigate to this folder: `python/samples/02-agents/observability/`. This is necessary for the `.env` file to be read correctly. 2. Create a `.env` file if one doesn't already exist in this folder. Please refer to the [example file](./.env.example). > **Note**: You can start with just `ENABLE_INSTRUMENTATION=true` and add `OTEL_EXPORTER_OTLP_ENDPOINT` or other configuration as needed. If no exporters are configured, you can set `ENABLE_CONSOLE_EXPORTERS=true` for console output. 3. Activate your python virtual environment, and then run `python configure_otel_providers_with_env_var.py` or others. diff --git a/python/samples/getting_started/observability/__init__.py b/python/samples/02-agents/observability/__init__.py similarity index 100% rename from python/samples/getting_started/observability/__init__.py rename to python/samples/02-agents/observability/__init__.py diff --git a/python/samples/getting_started/observability/advanced_manual_setup_console_output.py b/python/samples/02-agents/observability/advanced_manual_setup_console_output.py similarity index 96% rename from python/samples/getting_started/observability/advanced_manual_setup_console_output.py rename to python/samples/02-agents/observability/advanced_manual_setup_console_output.py index 0b6a908b0d..c0bfd7473e 100644 --- a/python/samples/getting_started/observability/advanced_manual_setup_console_output.py +++ b/python/samples/02-agents/observability/advanced_manual_setup_console_output.py @@ -66,7 +66,7 @@ def setup_metrics(): set_meter_provider(meter_provider) -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") async def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/observability/advanced_zero_code.py b/python/samples/02-agents/observability/advanced_zero_code.py similarity index 86% rename from python/samples/getting_started/observability/advanced_zero_code.py rename to python/samples/02-agents/observability/advanced_zero_code.py index 5ac0c70c22..650a838da8 100644 --- a/python/samples/getting_started/observability/advanced_zero_code.py +++ b/python/samples/02-agents/observability/advanced_zero_code.py @@ -12,7 +12,7 @@ from opentelemetry.trace.span import format_trace_id from pydantic import Field if TYPE_CHECKING: - from agent_framework import ChatClientProtocol + from agent_framework import SupportsChatGetResponse """ @@ -31,16 +31,16 @@ opentelemetry-enable_instrumentation \ --metrics_exporter otlp \ --service_name agent_framework \ --exporter_otlp_endpoint http://localhost:4317 \ - python samples/getting_started/observability/advanced_zero_code.py + python python/samples/02-agents/observability/advanced_zero_code.py ``` -(or use uv run in front when you have did the install within your uv virtual environment) +(or use uv run in front when you've done the install within your uv virtual environment) You can also set the environment variables instead of passing them as CLI arguments. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") async def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -51,7 +51,7 @@ async def get_weather( return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." -async def run_chat_client(client: "ChatClientProtocol", stream: bool = False) -> None: +async def run_chat_client(client: "SupportsChatGetResponse", stream: bool = False) -> None: """Run an AI service. This function runs an AI service and prints the output. @@ -65,7 +65,7 @@ async def run_chat_client(client: "ChatClientProtocol", stream: bool = False) -> Remarks: When function calling is outside the open telemetry loop - each of the call to the model is handled as a seperate span, + each of the call to the model is handled as a separate span, while when the open telemetry is put last, a single span is shown, which might include one or more rounds of function calling. diff --git a/python/samples/getting_started/observability/agent_observability.py b/python/samples/02-agents/observability/agent_observability.py similarity index 85% rename from python/samples/getting_started/observability/agent_observability.py rename to python/samples/02-agents/observability/agent_observability.py index 278b508de6..f7cf74c66f 100644 --- a/python/samples/getting_started/observability/agent_observability.py +++ b/python/samples/02-agents/observability/agent_observability.py @@ -4,7 +4,7 @@ import asyncio from random import randint from typing import Annotated -from agent_framework import ChatAgent, tool +from agent_framework import Agent, tool from agent_framework.observability import configure_otel_providers, get_tracer from agent_framework.openai import OpenAIChatClient from opentelemetry.trace import SpanKind @@ -17,7 +17,7 @@ same observability setup function. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") async def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -39,20 +39,20 @@ async def main(): with get_tracer().start_as_current_span("Scenario: Agent Chat", kind=SpanKind.CLIENT) as current_span: print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - agent = ChatAgent( - chat_client=OpenAIChatClient(), + agent = Agent( + client=OpenAIChatClient(), tools=get_weather, name="WeatherAgent", instructions="You are a weather assistant.", id="weather-agent", ) - thread = agent.get_new_thread() + session = agent.create_session() for question in questions: print(f"\nUser: {question}") print(f"{agent.name}: ", end="") async for update in agent.run( question, - thread=thread, + session=session, stream=True, ): if update.text: diff --git a/python/samples/getting_started/observability/agent_with_foundry_tracing.py b/python/samples/02-agents/observability/agent_with_foundry_tracing.py similarity index 86% rename from python/samples/getting_started/observability/agent_with_foundry_tracing.py rename to python/samples/02-agents/observability/agent_with_foundry_tracing.py index 431c5b7868..345da453b7 100644 --- a/python/samples/getting_started/observability/agent_with_foundry_tracing.py +++ b/python/samples/02-agents/observability/agent_with_foundry_tracing.py @@ -5,7 +5,7 @@ # ] # /// # Run with any PEP 723 compatible runner, e.g.: -# uv run samples/getting_started/observability/agent_with_foundry_tracing.py +# uv run python/samples/02-agents/observability/agent_with_foundry_tracing.py # Copyright (c) Microsoft. All rights reserved. @@ -16,7 +16,7 @@ from random import randint from typing import Annotated import dotenv -from agent_framework import ChatAgent, tool +from agent_framework import Agent, tool from agent_framework.observability import create_resource, enable_instrumentation, get_tracer from agent_framework.openai import OpenAIResponsesClient from azure.ai.projects.aio import AIProjectClient @@ -30,7 +30,7 @@ from pydantic import Field This sample shows you can can setup telemetry in Microsoft Foundry for a custom agent. First ensure you have a Foundry workspace with Application Insights enabled. And use the Operate tab to Register an Agent. -Set the OpenTelemetry agent ID to the value used below in the ChatAgent creation: `weather-agent` (or change both). +Set the OpenTelemetry agent ID to the value used below in the Agent creation: `weather-agent` (or change both). The sample uses the Azure Monitor OpenTelemetry exporter to send traces to Application Insights. So ensure you have the `azure-monitor-opentelemetry` package installed. """ @@ -41,7 +41,7 @@ dotenv.load_dotenv() logger = logging.getLogger(__name__) -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") async def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -85,18 +85,18 @@ async def main(): with get_tracer().start_as_current_span("Weather Agent Chat", kind=SpanKind.CLIENT) as current_span: print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - agent = ChatAgent( - chat_client=OpenAIResponsesClient(), + agent = Agent( + client=OpenAIResponsesClient(), tools=get_weather, name="WeatherAgent", instructions="You are a weather assistant.", id="weather-agent", ) - thread = agent.get_new_thread() + session = agent.create_session() for question in questions: print(f"\nUser: {question}") print(f"{agent.name}: ", end="") - async for update in agent.run(question, thread=thread, stream=True): + async for update in agent.run(question, session=session, stream=True): if update.text: print(update.text, end="") diff --git a/python/samples/getting_started/observability/azure_ai_agent_observability.py b/python/samples/02-agents/observability/azure_ai_agent_observability.py similarity index 84% rename from python/samples/getting_started/observability/azure_ai_agent_observability.py rename to python/samples/02-agents/observability/azure_ai_agent_observability.py index 08ac327913..d3860f39af 100644 --- a/python/samples/getting_started/observability/azure_ai_agent_observability.py +++ b/python/samples/02-agents/observability/azure_ai_agent_observability.py @@ -6,7 +6,7 @@ from random import randint from typing import Annotated import dotenv -from agent_framework import ChatAgent, tool +from agent_framework import Agent, tool from agent_framework.azure import AzureAIClient from agent_framework.observability import get_tracer from azure.ai.projects.aio import AIProjectClient @@ -16,7 +16,7 @@ from opentelemetry.trace.span import format_trace_id from pydantic import Field """ -This sample shows you can can setup telemetry for an Azure AI agent. +This sample shows you can setup telemetry for an Azure AI agent. It uses the Azure AI client to setup the telemetry, this calls out to Azure AI for the connection string of the attached Application Insights instance. @@ -29,7 +29,7 @@ for this sample to work. dotenv.load_dotenv() -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") async def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -56,18 +56,18 @@ async def main(): with get_tracer().start_as_current_span("Single Agent Chat", kind=SpanKind.CLIENT) as current_span: print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - agent = ChatAgent( - chat_client=client, + agent = Agent( + client=client, tools=get_weather, name="WeatherAgent", instructions="You are a weather assistant.", id="edvan-weather-agent", ) - thread = agent.get_new_thread() + session = agent.create_session() for question in questions: print(f"\nUser: {question}") print(f"{agent.name}: ", end="") - async for update in agent.run(question, thread=thread, stream=True): + async for update in agent.run(question, session=session, stream=True): if update.text: print(update.text, end="") diff --git a/python/samples/getting_started/observability/configure_otel_providers_with_env_var.py b/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py similarity index 86% rename from python/samples/getting_started/observability/configure_otel_providers_with_env_var.py rename to python/samples/02-agents/observability/configure_otel_providers_with_env_var.py index 014f387033..2b79435df5 100644 --- a/python/samples/getting_started/observability/configure_otel_providers_with_env_var.py +++ b/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py @@ -14,10 +14,10 @@ from opentelemetry.trace.span import format_trace_id from pydantic import Field if TYPE_CHECKING: - from agent_framework import ChatClientProtocol + from agent_framework import SupportsChatGetResponse """ -This sample, show how you can configure observability of an application via the +This sample shows how you can configure observability of an application via the `configure_otel_providers` function with environment variables. When you run this sample with an OTLP endpoint or an Application Insights connection string, @@ -28,10 +28,10 @@ output traces, logs, and metrics to the console. """ # Define the scenarios that can be run to show the telemetry data collected by the SDK -SCENARIOS = ["chat_client", "chat_client_stream", "tool", "all"] +SCENARIOS = ["client", "client_stream", "tool", "all"] -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") async def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -42,7 +42,7 @@ async def get_weather( return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." -async def run_chat_client(client: "ChatClientProtocol", stream: bool = False) -> None: +async def run_chat_client(client: "SupportsChatGetResponse", stream: bool = False) -> None: """Run an AI service. This function runs an AI service and prints the output. @@ -97,14 +97,14 @@ async def run_tool() -> None: print(f"Weather in Amsterdam:\n{weather}") -async def main(scenario: Literal["chat_client", "chat_client_stream", "tool", "all"] = "all"): +async def main(scenario: Literal["client", "client_stream", "tool", "all"] = "all"): """Run the selected scenario(s).""" # This will enable tracing and create the necessary tracing, logging and metrics providers # based on environment variables. See the .env.example file for the available configuration options. configure_otel_providers() - with get_tracer().start_as_current_span("Sample Scenario's", kind=trace.SpanKind.CLIENT) as current_span: + with get_tracer().start_as_current_span("Sample Scenarios", kind=trace.SpanKind.CLIENT) as current_span: print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") client = OpenAIResponsesClient() @@ -113,10 +113,10 @@ async def main(scenario: Literal["chat_client", "chat_client_stream", "tool", "a if scenario == "tool" or scenario == "all": with suppress(Exception): await run_tool() - if scenario == "chat_client_stream" or scenario == "all": + if scenario == "client_stream" or scenario == "all": with suppress(Exception): await run_chat_client(client, stream=True) - if scenario == "chat_client" or scenario == "all": + if scenario == "client" or scenario == "all": with suppress(Exception): await run_chat_client(client, stream=False) diff --git a/python/samples/getting_started/observability/configure_otel_providers_with_parameters.py b/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py similarity index 86% rename from python/samples/getting_started/observability/configure_otel_providers_with_parameters.py rename to python/samples/02-agents/observability/configure_otel_providers_with_parameters.py index a5b0b3d7a8..5ec2698607 100644 --- a/python/samples/getting_started/observability/configure_otel_providers_with_parameters.py +++ b/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py @@ -14,7 +14,7 @@ from opentelemetry.trace.span import format_trace_id from pydantic import Field if TYPE_CHECKING: - from agent_framework import ChatClientProtocol + from agent_framework import SupportsChatGetResponse """ This sample shows how you can configure observability with custom exporters passed directly @@ -28,10 +28,10 @@ Use this approach when you need custom exporter configuration beyond what enviro """ # Define the scenarios that can be run to show the telemetry data collected by the SDK -SCENARIOS = ["chat_client", "chat_client_stream", "tool", "all"] +SCENARIOS = ["client", "client_stream", "tool", "all"] -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") async def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -42,7 +42,7 @@ async def get_weather( return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." -async def run_chat_client(client: "ChatClientProtocol", stream: bool = False) -> None: +async def run_chat_client(client: "SupportsChatGetResponse", stream: bool = False) -> None: """Run an AI service. This function runs an AI service and prints the output. @@ -97,7 +97,7 @@ async def run_tool() -> None: print(f"Weather in Amsterdam:\n{weather}") -async def main(scenario: Literal["chat_client", "chat_client_stream", "tool", "all"] = "all"): +async def main(scenario: Literal["client", "client_stream", "tool", "all"] = "all"): """Run the selected scenario(s).""" # Setup the logging with the more complete format @@ -106,9 +106,15 @@ async def main(scenario: Literal["chat_client", "chat_client_stream", "tool", "a # Create custom OTLP exporters with specific configuration # Note: You need to install opentelemetry-exporter-otlp-proto-grpc or -http separately try: - from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter - from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( # pyright: ignore[reportMissingImports] + OTLPLogExporter, + ) + from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( # pyright: ignore[reportMissingImports] + OTLPMetricExporter, + ) + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( # pyright: ignore[reportMissingImports] + OTLPSpanExporter, + ) # Create exporters with custom configuration # These will be added to any exporters configured via environment variables @@ -133,7 +139,7 @@ async def main(scenario: Literal["chat_client", "chat_client_stream", "tool", "a exporters=custom_exporters, ) - with get_tracer().start_as_current_span("Sample Scenario's", kind=trace.SpanKind.CLIENT) as current_span: + with get_tracer().start_as_current_span("Sample Scenarios", kind=trace.SpanKind.CLIENT) as current_span: print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") client = OpenAIResponsesClient() @@ -142,10 +148,10 @@ async def main(scenario: Literal["chat_client", "chat_client_stream", "tool", "a if scenario == "tool" or scenario == "all": with suppress(Exception): await run_tool() - if scenario == "chat_client_stream" or scenario == "all": + if scenario == "client_stream" or scenario == "all": with suppress(Exception): await run_chat_client(client, stream=True) - if scenario == "chat_client" or scenario == "all": + if scenario == "client" or scenario == "all": with suppress(Exception): await run_chat_client(client, stream=False) diff --git a/python/samples/getting_started/observability/workflow_observability.py b/python/samples/02-agents/observability/workflow_observability.py similarity index 100% rename from python/samples/getting_started/observability/workflow_observability.py rename to python/samples/02-agents/observability/workflow_observability.py diff --git a/python/samples/getting_started/agents/anthropic/README.md b/python/samples/02-agents/providers/anthropic/README.md similarity index 100% rename from python/samples/getting_started/agents/anthropic/README.md rename to python/samples/02-agents/providers/anthropic/README.md diff --git a/python/samples/getting_started/agents/anthropic/anthropic_advanced.py b/python/samples/02-agents/providers/anthropic/anthropic_advanced.py similarity index 78% rename from python/samples/getting_started/agents/anthropic/anthropic_advanced.py rename to python/samples/02-agents/providers/anthropic/anthropic_advanced.py index 8d15c2d91e..3918005b5d 100644 --- a/python/samples/getting_started/agents/anthropic/anthropic_advanced.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_advanced.py @@ -2,7 +2,6 @@ import asyncio -from agent_framework import HostedMCPTool, HostedWebSearchTool from agent_framework.anthropic import AnthropicChatOptions, AnthropicClient """ @@ -17,16 +16,21 @@ This sample demonstrates using Anthropic with: async def main() -> None: """Example of streaming response (get results as they are generated).""" - agent = AnthropicClient[AnthropicChatOptions]().as_agent( + client = AnthropicClient[AnthropicChatOptions]() + + # Create MCP tool configuration using instance method + mcp_tool = client.get_mcp_tool( + name="Microsoft_Learn_MCP", + url="https://learn.microsoft.com/api/mcp", + ) + + # Create web search tool configuration using instance method + web_search_tool = client.get_web_search_tool() + + agent = client.as_agent( name="DocsAgent", instructions="You are a helpful agent for both Microsoft docs questions and general questions.", - tools=[ - HostedMCPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - ), - HostedWebSearchTool(), - ], + tools=[mcp_tool, web_search_tool], default_options={ # anthropic needs a value for the max_tokens parameter # we set it to 1024, but you can override like this: diff --git a/python/samples/getting_started/agents/anthropic/anthropic_basic.py b/python/samples/02-agents/providers/anthropic/anthropic_basic.py similarity index 91% rename from python/samples/getting_started/agents/anthropic/anthropic_basic.py rename to python/samples/02-agents/providers/anthropic/anthropic_basic.py index 1600d725b6..408e129a43 100644 --- a/python/samples/getting_started/agents/anthropic/anthropic_basic.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_basic.py @@ -14,7 +14,7 @@ This sample demonstrates using Anthropic with an agent and a single custom tool. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, "The location to get the weather for."], diff --git a/python/samples/getting_started/agents/anthropic/anthropic_claude_basic.py b/python/samples/02-agents/providers/anthropic/anthropic_claude_basic.py similarity index 100% rename from python/samples/getting_started/agents/anthropic/anthropic_claude_basic.py rename to python/samples/02-agents/providers/anthropic/anthropic_claude_basic.py diff --git a/python/samples/getting_started/agents/anthropic/anthropic_claude_with_mcp.py b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_mcp.py similarity index 100% rename from python/samples/getting_started/agents/anthropic/anthropic_claude_with_mcp.py rename to python/samples/02-agents/providers/anthropic/anthropic_claude_with_mcp.py diff --git a/python/samples/getting_started/agents/anthropic/anthropic_claude_with_multiple_permissions.py b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_multiple_permissions.py similarity index 100% rename from python/samples/getting_started/agents/anthropic/anthropic_claude_with_multiple_permissions.py rename to python/samples/02-agents/providers/anthropic/anthropic_claude_with_multiple_permissions.py diff --git a/python/samples/getting_started/agents/anthropic/anthropic_claude_with_session.py b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_session.py similarity index 85% rename from python/samples/getting_started/agents/anthropic/anthropic_claude_with_session.py rename to python/samples/02-agents/providers/anthropic/anthropic_claude_with_session.py index 2549457800..623be4f299 100644 --- a/python/samples/getting_started/agents/anthropic/anthropic_claude_with_session.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_session.py @@ -66,31 +66,31 @@ async def example_with_session_persistence() -> None: ) async with agent: - # Create a thread to maintain conversation context - thread = agent.get_new_thread() + # Create a session to maintain conversation context + session = agent.create_session() # First query query1 = "What's the weather like in Tokyo?" print(f"User: {query1}") - result1 = await agent.run(query1, thread=thread) + result1 = await agent.run(query1, session=session) print(f"Agent: {result1.text}") # Second query - using same thread maintains context query2 = "How about London?" print(f"\nUser: {query2}") - result2 = await agent.run(query2, thread=thread) + result2 = await agent.run(query2, session=session) print(f"Agent: {result2.text}") # Third query - agent should remember both previous cities query3 = "Which of the cities I asked about has better weather?" print(f"\nUser: {query3}") - result3 = await agent.run(query3, thread=thread) + result3 = await agent.run(query3, session=session) print(f"Agent: {result3.text}") print("Note: The agent remembers context from previous messages in the same session.\n") async def example_with_existing_session_id() -> None: - """Resume session in new agent instance using service_thread_id.""" + """Resume session in new agent instance using service_session_id.""" print("=== Existing Session ID Example ===") existing_session_id = None @@ -102,15 +102,15 @@ async def example_with_existing_session_id() -> None: ) async with agent1: - thread = agent1.get_new_thread() + session = agent1.create_session() query1 = "What's the weather in Paris?" print(f"User: {query1}") - result1 = await agent1.run(query1, thread=thread) + result1 = await agent1.run(query1, session=session) print(f"Agent: {result1.text}") # Capture the session ID for later use - existing_session_id = thread.service_thread_id + existing_session_id = session.service_session_id print(f"Session ID: {existing_session_id}") if existing_session_id: @@ -123,12 +123,12 @@ async def example_with_existing_session_id() -> None: ) async with agent2: - # Create thread with existing session ID - thread = agent2.get_new_thread(service_thread_id=existing_session_id) + # Create session with existing session ID + session = agent2.create_session(service_session_id=existing_session_id) query2 = "What was the last city I asked about?" print(f"User: {query2}") - result2 = await agent2.run(query2, thread=thread) + result2 = await agent2.run(query2, session=session) print(f"Agent: {result2.text}") print("Note: The agent continues the conversation using the session ID.\n") diff --git a/python/samples/getting_started/agents/anthropic/anthropic_claude_with_shell.py b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_shell.py similarity index 100% rename from python/samples/getting_started/agents/anthropic/anthropic_claude_with_shell.py rename to python/samples/02-agents/providers/anthropic/anthropic_claude_with_shell.py diff --git a/python/samples/getting_started/agents/anthropic/anthropic_claude_with_tools.py b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_tools.py similarity index 100% rename from python/samples/getting_started/agents/anthropic/anthropic_claude_with_tools.py rename to python/samples/02-agents/providers/anthropic/anthropic_claude_with_tools.py diff --git a/python/samples/getting_started/agents/anthropic/anthropic_claude_with_url.py b/python/samples/02-agents/providers/anthropic/anthropic_claude_with_url.py similarity index 100% rename from python/samples/getting_started/agents/anthropic/anthropic_claude_with_url.py rename to python/samples/02-agents/providers/anthropic/anthropic_claude_with_url.py diff --git a/python/samples/getting_started/agents/anthropic/anthropic_foundry.py b/python/samples/02-agents/providers/anthropic/anthropic_foundry.py similarity index 82% rename from python/samples/getting_started/agents/anthropic/anthropic_foundry.py rename to python/samples/02-agents/providers/anthropic/anthropic_foundry.py index c9064dbe57..00f5c5f2e0 100644 --- a/python/samples/getting_started/agents/anthropic/anthropic_foundry.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_foundry.py @@ -2,7 +2,6 @@ import asyncio -from agent_framework import HostedMCPTool, HostedWebSearchTool from agent_framework.anthropic import AnthropicClient from anthropic import AsyncAnthropicFoundry @@ -28,16 +27,21 @@ To use the Foundry integration ensure you have the following environment variabl async def main() -> None: """Example of streaming response (get results as they are generated).""" - agent = AnthropicClient(anthropic_client=AsyncAnthropicFoundry()).as_agent( + client = AnthropicClient(anthropic_client=AsyncAnthropicFoundry()) + + # Create MCP tool configuration using instance method + mcp_tool = client.get_mcp_tool( + name="Microsoft_Learn_MCP", + url="https://learn.microsoft.com/api/mcp", + ) + + # Create web search tool configuration using instance method + web_search_tool = client.get_web_search_tool() + + agent = client.as_agent( name="DocsAgent", instructions="You are a helpful agent for both Microsoft docs questions and general questions.", - tools=[ - HostedMCPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - ), - HostedWebSearchTool(), - ], + tools=[mcp_tool, web_search_tool], default_options={ # anthropic needs a value for the max_tokens parameter # we set it to 1024, but you can override like this: diff --git a/python/samples/getting_started/agents/anthropic/anthropic_skills.py b/python/samples/02-agents/providers/anthropic/anthropic_skills.py similarity index 97% rename from python/samples/getting_started/agents/anthropic/anthropic_skills.py rename to python/samples/02-agents/providers/anthropic/anthropic_skills.py index 108646543a..3b014f9b6a 100644 --- a/python/samples/getting_started/agents/anthropic/anthropic_skills.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_skills.py @@ -4,7 +4,7 @@ import asyncio import logging from pathlib import Path -from agent_framework import Content, HostedCodeInterpreterTool +from agent_framework import Content from agent_framework.anthropic import AnthropicChatOptions, AnthropicClient logger = logging.getLogger(__name__) @@ -34,7 +34,7 @@ async def main() -> None: agent = client.as_agent( name="DocsAgent", instructions="You are a helpful agent for creating powerpoint presentations.", - tools=HostedCodeInterpreterTool(), + tools=client.get_code_interpreter_tool(), default_options={ "max_tokens": 20000, "thinking": {"type": "enabled", "budget_tokens": 10000}, diff --git a/python/samples/getting_started/agents/azure_ai/README.md b/python/samples/02-agents/providers/azure_ai/README.md similarity index 88% rename from python/samples/getting_started/agents/azure_ai/README.md rename to python/samples/02-agents/providers/azure_ai/README.md index df20485ce1..d49147989f 100644 --- a/python/samples/getting_started/agents/azure_ai/README.md +++ b/python/samples/02-agents/providers/azure_ai/README.md @@ -15,29 +15,29 @@ This folder contains examples demonstrating different ways to create and use age | [`azure_ai_with_bing_grounding.py`](azure_ai_with_bing_grounding.py) | Shows how to use Bing Grounding search with Azure AI agents to search the web for current information and provide grounded responses with citations. Requires a Bing connection configured in your Azure AI project. | | [`azure_ai_with_bing_custom_search.py`](azure_ai_with_bing_custom_search.py) | Shows how to use Bing Custom Search with Azure AI agents to search custom search instances and provide responses with relevant results. Requires a Bing Custom Search connection and instance configured in your Azure AI project. | | [`azure_ai_with_browser_automation.py`](azure_ai_with_browser_automation.py) | Shows how to use Browser Automation with Azure AI agents to perform automated web browsing tasks and provide responses based on web interactions. Requires a Browser Automation connection configured in your Azure AI project. | -| [`azure_ai_with_code_interpreter.py`](azure_ai_with_code_interpreter.py) | Shows how to use the `HostedCodeInterpreterTool` with Azure AI agents to write and execute Python code for mathematical problem solving and data analysis. | +| [`azure_ai_with_code_interpreter.py`](azure_ai_with_code_interpreter.py) | Shows how to use `AzureAIClient.get_code_interpreter_tool()` with Azure AI agents to write and execute Python code for mathematical problem solving and data analysis. | | [`azure_ai_with_code_interpreter_file_generation.py`](azure_ai_with_code_interpreter_file_generation.py) | Shows how to retrieve file IDs from code interpreter generated files using both streaming and non-streaming approaches. | | [`azure_ai_with_code_interpreter_file_download.py`](azure_ai_with_code_interpreter_file_download.py) | Shows how to download files generated by code interpreter using the OpenAI containers API. | | [`azure_ai_with_content_filtering.py`](azure_ai_with_content_filtering.py) | Shows how to enable content filtering (RAI policy) on Azure AI agents using `RaiConfig`. Requires creating an RAI policy in Azure AI Foundry portal first. | | [`azure_ai_with_existing_agent.py`](azure_ai_with_existing_agent.py) | Shows how to work with a pre-existing agent by providing the agent name and version to the Azure AI client. Demonstrates agent reuse patterns for production scenarios. | -| [`azure_ai_with_existing_conversation.py`](azure_ai_with_existing_conversation.py) | Demonstrates how to use an existing conversation created on the service side with Azure AI agents. Shows two approaches: specifying conversation ID at the client level and using AgentThread with an existing conversation ID. | +| [`azure_ai_with_existing_conversation.py`](azure_ai_with_existing_conversation.py) | Demonstrates how to use an existing conversation created on the service side with Azure AI agents. Shows two approaches: specifying conversation ID at the client level and using AgentSession with an existing conversation ID. | | [`azure_ai_with_application_endpoint.py`](azure_ai_with_application_endpoint.py) | Demonstrates calling the Azure AI application-scoped endpoint. | | [`azure_ai_with_explicit_settings.py`](azure_ai_with_explicit_settings.py) | Shows how to create an agent with explicitly configured `AzureAIClient` settings, including project endpoint, model deployment, and credentials rather than relying on environment variable defaults. | -| [`azure_ai_with_file_search.py`](azure_ai_with_file_search.py) | Shows how to use the `HostedFileSearchTool` with Azure AI agents to upload files, create vector stores, and enable agents to search through uploaded documents to answer user questions. | -| [`azure_ai_with_hosted_mcp.py`](azure_ai_with_hosted_mcp.py) | Shows how to integrate hosted Model Context Protocol (MCP) tools with Azure AI Agent. | +| [`azure_ai_with_file_search.py`](azure_ai_with_file_search.py) | Shows how to use `AzureAIClient.get_file_search_tool()` with Azure AI agents to upload files, create vector stores, and enable agents to search through uploaded documents to answer user questions. | +| [`azure_ai_with_hosted_mcp.py`](azure_ai_with_hosted_mcp.py) | Shows how to integrate hosted Model Context Protocol (MCP) tools with Azure AI Agent using `AzureAIClient.get_mcp_tool()`. | | [`azure_ai_with_local_mcp.py`](azure_ai_with_local_mcp.py) | Shows how to integrate local Model Context Protocol (MCP) tools with Azure AI agents. | | [`azure_ai_with_response_format.py`](azure_ai_with_response_format.py) | Shows how to use structured outputs (response format) with Azure AI agents using Pydantic models to enforce specific response schemas. | | [`azure_ai_with_runtime_json_schema.py`](azure_ai_with_runtime_json_schema.py) | Shows how to use structured outputs (response format) with Azure AI agents using a JSON schema to enforce specific response schemas. | | [`azure_ai_with_search_context_agentic.py`](../../context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py) | Shows how to use AzureAISearchContextProvider with agentic mode. Uses Knowledge Bases for multi-hop reasoning across documents with query planning. Recommended for most scenarios - slightly slower with more token consumption for query planning, but more accurate results. | | [`azure_ai_with_search_context_semantic.py`](../../context_providers/azure_ai_search/azure_ai_with_search_context_semantic.py) | Shows how to use AzureAISearchContextProvider with semantic mode. Fast hybrid search with vector + keyword search and semantic ranking for RAG. Best for simple queries where speed is critical. | | [`azure_ai_with_sharepoint.py`](azure_ai_with_sharepoint.py) | Shows how to use SharePoint grounding with Azure AI agents to search through SharePoint content and answer user questions with proper citations. Requires a SharePoint connection configured in your Azure AI project. | -| [`azure_ai_with_thread.py`](azure_ai_with_thread.py) | Demonstrates thread management with Azure AI agents, including automatic thread creation for stateless conversations and explicit thread management for maintaining conversation context across multiple interactions. | -| [`azure_ai_with_image_generation.py`](azure_ai_with_image_generation.py) | Shows how to use the `ImageGenTool` with Azure AI agents to generate images based on text prompts. | +| [`azure_ai_with_session.py`](azure_ai_with_session.py) | Demonstrates session management with Azure AI agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | +| [`azure_ai_with_image_generation.py`](azure_ai_with_image_generation.py) | Shows how to use `AzureAIClient.get_image_generation_tool()` with Azure AI agents to generate images based on text prompts. | | [`azure_ai_with_memory_search.py`](azure_ai_with_memory_search.py) | Shows how to use memory search functionality with Azure AI agents for conversation persistence. Demonstrates creating memory stores and enabling agents to search through conversation history. | | [`azure_ai_with_microsoft_fabric.py`](azure_ai_with_microsoft_fabric.py) | Shows how to use Microsoft Fabric with Azure AI agents to query Fabric data sources and provide responses based on data analysis. Requires a Microsoft Fabric connection configured in your Azure AI project. | | [`azure_ai_with_openapi.py`](azure_ai_with_openapi.py) | Shows how to integrate OpenAPI specifications with Azure AI agents using dictionary-based tool configuration. Demonstrates using external REST APIs for dynamic data lookup. | | [`azure_ai_with_reasoning.py`](azure_ai_with_reasoning.py) | Shows how to enable reasoning for a model that supports it. | -| [`azure_ai_with_web_search.py`](azure_ai_with_web_search.py) | Shows how to use the `HostedWebSearchTool` with Azure AI agents to perform web searches and retrieve up-to-date information from the internet. | +| [`azure_ai_with_web_search.py`](azure_ai_with_web_search.py) | Shows how to use `AzureAIClient.get_web_search_tool()` with Azure AI agents to perform web searches and retrieve up-to-date information from the internet. | ## Environment Variables @@ -92,4 +92,4 @@ python azure_ai_with_code_interpreter.py # ... etc ``` -The examples demonstrate various patterns for working with Azure AI agents, from basic usage to advanced scenarios like thread management and structured outputs. +The examples demonstrate various patterns for working with Azure AI agents, from basic usage to advanced scenarios like session management and structured outputs. diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_basic.py b/python/samples/02-agents/providers/azure_ai/azure_ai_basic.py similarity index 94% rename from python/samples/getting_started/agents/azure_ai/azure_ai_basic.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_basic.py index d9a80a3732..3661aa71a4 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_basic.py +++ b/python/samples/02-agents/providers/azure_ai/azure_ai_basic.py @@ -17,7 +17,9 @@ Shows both streaming and non-streaming responses with function tools. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_provider_methods.py b/python/samples/02-agents/providers/azure_ai/azure_ai_provider_methods.py similarity index 96% rename from python/samples/getting_started/agents/azure_ai/azure_ai_provider_methods.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_provider_methods.py index b05ec92f80..0e72530f7d 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_provider_methods.py +++ b/python/samples/02-agents/providers/azure_ai/azure_ai_provider_methods.py @@ -23,11 +23,13 @@ This sample demonstrates the three main methods of AzureAIProjectAgentProvider: It also shows how to use a single provider instance to spawn multiple agents with different configurations, which is efficient for multi-agent scenarios. -Each method returns a ChatAgent that can be used for conversations. +Each method returns a Agent that can be used for conversations. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -41,7 +43,7 @@ async def create_agent_example() -> None: """Example of using provider.create_agent() to create a new agent. This method creates a new agent version on the Azure AI service and returns - a ChatAgent. Use this when you want to create a fresh agent with + a Agent. Use this when you want to create a fresh agent with specific configuration. """ print("=== provider.create_agent() Example ===") @@ -199,7 +201,7 @@ async def multiple_agents_example() -> None: async def as_agent_example() -> None: """Example of using provider.as_agent() to wrap an SDK object without HTTP calls. - This method wraps an existing AgentVersionDetails into a ChatAgent without + This method wraps an existing AgentVersionDetails into a Agent without making additional HTTP calls. Use this when you already have the full AgentVersionDetails from a previous SDK operation. """ diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_use_latest_version.py b/python/samples/02-agents/providers/azure_ai/azure_ai_use_latest_version.py similarity index 93% rename from python/samples/getting_started/agents/azure_ai/azure_ai_use_latest_version.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_use_latest_version.py index b9472c9f1a..4cad79c5cb 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_use_latest_version.py +++ b/python/samples/02-agents/providers/azure_ai/azure_ai_use_latest_version.py @@ -18,7 +18,9 @@ while subsequent calls with `get_agent()` reuse the latest agent version. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_agent_as_tool.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_agent_as_tool.py similarity index 95% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_agent_as_tool.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_agent_as_tool.py index f03fc4beb1..2d873f2930 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_agent_as_tool.py +++ b/python/samples/02-agents/providers/azure_ai/azure_ai_with_agent_as_tool.py @@ -20,13 +20,13 @@ multiple specialized agents, each focusing on specific tasks. async def logging_middleware( context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """MiddlewareTypes that logs tool invocations to show the delegation flow.""" print(f"[Calling tool: {context.function.name}]") print(f"[Request: {context.arguments}]") - await call_next(context) + await call_next() print(f"[Response: {context.result}]") diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_agent_to_agent.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_agent_to_agent.py similarity index 100% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_agent_to_agent.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_agent_to_agent.py diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_application_endpoint.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_application_endpoint.py similarity index 91% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_application_endpoint.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_application_endpoint.py index 89bb77af11..db1c80a597 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_application_endpoint.py +++ b/python/samples/02-agents/providers/azure_ai/azure_ai_with_application_endpoint.py @@ -3,7 +3,7 @@ import asyncio import os -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.azure import AzureAIClient from azure.ai.projects.aio import AIProjectClient from azure.identity.aio import AzureCliCredential @@ -23,8 +23,8 @@ async def main() -> None: # Endpoint here should be application endpoint with format: # /api/projects//applications//protocols AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client, - ChatAgent( - chat_client=AzureAIClient( + Agent( + client=AzureAIClient( project_client=project_client, ), ) as agent, diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_azure_ai_search.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_azure_ai_search.py similarity index 100% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_azure_ai_search.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_azure_ai_search.py diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_bing_custom_search.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_bing_custom_search.py similarity index 100% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_bing_custom_search.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_bing_custom_search.py diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_bing_grounding.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_bing_grounding.py similarity index 100% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_bing_grounding.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_bing_grounding.py diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_browser_automation.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_browser_automation.py similarity index 100% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_browser_automation.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_browser_automation.py diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter.py similarity index 76% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter.py index ad43e21e9c..f91ddc01c1 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter.py +++ b/python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter.py @@ -2,8 +2,8 @@ import asyncio -from agent_framework import ChatResponse, HostedCodeInterpreterTool -from agent_framework.azure import AzureAIProjectAgentProvider +from agent_framework import ChatResponse +from agent_framework.azure import AzureAIClient, AzureAIProjectAgentProvider from azure.identity.aio import AzureCliCredential from openai.types.responses.response import Response as OpenAIResponse from openai.types.responses.response_code_interpreter_tool_call import ResponseCodeInterpreterToolCall @@ -11,22 +11,26 @@ from openai.types.responses.response_code_interpreter_tool_call import ResponseC """ Azure AI Agent Code Interpreter Example -This sample demonstrates using HostedCodeInterpreterTool with AzureAIProjectAgentProvider +This sample demonstrates using get_code_interpreter_tool() with AzureAIProjectAgentProvider for Python code execution and mathematical problem solving. """ async def main() -> None: - """Example showing how to use the HostedCodeInterpreterTool with AzureAIProjectAgentProvider.""" + """Example showing how to use the code interpreter tool with AzureAIProjectAgentProvider.""" async with ( AzureCliCredential() as credential, AzureAIProjectAgentProvider(credential=credential) as provider, ): + # Create a client to access hosted tool factory methods + client = AzureAIClient(credential=credential) + code_interpreter_tool = client.get_code_interpreter_tool() + agent = await provider.create_agent( name="MyCodeInterpreterAgent", instructions="You are a helpful assistant that can write and execute Python code to solve problems.", - tools=HostedCodeInterpreterTool(), + tools=[code_interpreter_tool], ) query = "Use code to get the factorial of 100?" diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_download.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter_file_download.py similarity index 88% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_download.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter_file_download.py index ff0d9df4dc..cb5087b3f6 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_download.py +++ b/python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter_file_download.py @@ -5,13 +5,12 @@ import tempfile from pathlib import Path from agent_framework import ( + Agent, AgentResponseUpdate, Annotation, - ChatAgent, Content, - HostedCodeInterpreterTool, ) -from agent_framework.azure import AzureAIProjectAgentProvider +from agent_framework.azure import AzureAIClient, AzureAIProjectAgentProvider from azure.identity.aio import AzureCliCredential """ @@ -33,7 +32,7 @@ QUERY = ( ) -async def download_container_files(file_contents: list[Annotation | Content], agent: ChatAgent) -> list[Path]: +async def download_container_files(file_contents: list[Annotation | Content], agent: Agent) -> list[Path]: """Download container files using the OpenAI containers API. Code interpreter generates files in containers, which require both file_id @@ -45,7 +44,7 @@ async def download_container_files(file_contents: list[Annotation | Content], ag Args: file_contents: List of Annotation or Content objects containing file_id and container_id. - agent: The ChatAgent instance with access to the AzureAIClient. + agent: The Agent instance with access to the AzureAIClient. Returns: List of Path objects for successfully downloaded files. @@ -61,7 +60,7 @@ async def download_container_files(file_contents: list[Annotation | Content], ag print(f"\nDownloading {len(file_contents)} container file(s) to {output_dir.absolute()}...") # Access the OpenAI client from AzureAIClient - openai_client = agent.chat_client.client # type: ignore[attr-defined] + openai_client = agent.client.client # type: ignore[attr-defined] downloaded_files: list[Path] = [] @@ -119,17 +118,21 @@ async def download_container_files(file_contents: list[Annotation | Content], ag async def non_streaming_example() -> None: - """Example of downloading files from non-streaming response using CitationAnnotation.""" + """Example of downloading files from non-streaming response using Annotation.""" print("=== Non-Streaming Response Example ===") async with ( AzureCliCredential() as credential, AzureAIProjectAgentProvider(credential=credential) as provider, ): + # Create a client to access hosted tool factory methods + client = AzureAIClient(credential=credential) + code_interpreter_tool = client.get_code_interpreter_tool() + agent = await provider.create_agent( name="V2CodeInterpreterFileAgent", instructions="You are a helpful assistant that can write and execute Python code to create files.", - tools=HostedCodeInterpreterTool(), + tools=[code_interpreter_tool], ) print(f"User: {QUERY}\n") @@ -139,7 +142,7 @@ async def non_streaming_example() -> None: # Check for annotations in the response annotations_found: list[Annotation] = [] - # AgentResponse has messages property, which contains ChatMessage objects + # AgentResponse has messages property, which contains Message objects for message in result.messages: for content in message.contents: if content.type == "text" and content.annotations: @@ -154,8 +157,8 @@ async def non_streaming_example() -> None: if annotations_found: print(f"SUCCESS: Found {len(annotations_found)} file annotation(s)") - # Download the container files - downloaded_paths = await download_container_files(annotations_found, agent) + # Download the container files (cast to Sequence for type compatibility) + downloaded_paths = await download_container_files(list(annotations_found), agent) if downloaded_paths: print("\nDownloaded files available at:") @@ -166,17 +169,21 @@ async def non_streaming_example() -> None: async def streaming_example() -> None: - """Example of downloading files from streaming response using HostedFileContent.""" + """Example of downloading files from streaming response using Content with type='hosted_file'.""" print("\n=== Streaming Response Example ===") async with ( AzureCliCredential() as credential, AzureAIProjectAgentProvider(credential=credential) as provider, ): + # Create a client to access hosted tool factory methods + client = AzureAIClient(credential=credential) + code_interpreter_tool = client.get_code_interpreter_tool() + agent = await provider.create_agent( name="V2CodeInterpreterFileAgentStreaming", instructions="You are a helpful assistant that can write and execute Python code to create files.", - tools=HostedCodeInterpreterTool(), + tools=[code_interpreter_tool], ) print(f"User: {QUERY}\n") diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_generation.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter_file_generation.py similarity index 86% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_generation.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter_file_generation.py index 9c9fc48feb..72386aa418 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_generation.py +++ b/python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter_file_generation.py @@ -4,9 +4,8 @@ import asyncio from agent_framework import ( AgentResponseUpdate, - HostedCodeInterpreterTool, ) -from agent_framework.azure import AzureAIProjectAgentProvider +from agent_framework.azure import AzureAIClient, AzureAIProjectAgentProvider from azure.identity.aio import AzureCliCredential """ @@ -31,10 +30,14 @@ async def non_streaming_example() -> None: AzureCliCredential() as credential, AzureAIProjectAgentProvider(credential=credential) as provider, ): + # Create a client to access hosted tool factory methods + client = AzureAIClient(credential=credential) + code_interpreter_tool = client.get_code_interpreter_tool() + agent = await provider.create_agent( - name="V2CodeInterpreterFileAgent", + name="CodeInterpreterFileAgent", instructions="You are a helpful assistant that can write and execute Python code to create files.", - tools=HostedCodeInterpreterTool(), + tools=[code_interpreter_tool], ) print(f"User: {QUERY}\n") @@ -44,7 +47,7 @@ async def non_streaming_example() -> None: # Check for annotations in the response annotations_found: list[str] = [] - # AgentResponse has messages property, which contains ChatMessage objects + # AgentResponse has messages property, which contains Message objects for message in result.messages: for content in message.contents: if content.type == "text" and content.annotations: @@ -67,10 +70,14 @@ async def streaming_example() -> None: AzureCliCredential() as credential, AzureAIProjectAgentProvider(credential=credential) as provider, ): + # Create a client to access hosted tool factory methods + client = AzureAIClient(credential=credential) + code_interpreter_tool = client.get_code_interpreter_tool() + agent = await provider.create_agent( name="V2CodeInterpreterFileAgentStreaming", instructions="You are a helpful assistant that can write and execute Python code to create files.", - tools=HostedCodeInterpreterTool(), + tools=[code_interpreter_tool], ) print(f"User: {QUERY}\n") diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_content_filtering.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_content_filtering.py similarity index 100% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_content_filtering.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_content_filtering.py diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_existing_agent.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_existing_agent.py similarity index 96% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_existing_agent.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_existing_agent.py index 7341068f10..0549c642c2 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_existing_agent.py +++ b/python/samples/02-agents/providers/azure_ai/azure_ai_with_existing_agent.py @@ -36,7 +36,7 @@ async def using_provider_get_agent() -> None: ) try: - # Get newly created agent as ChatAgent by using provider.get_agent() + # Get newly created agent as Agent by using provider.get_agent() provider = AzureAIProjectAgentProvider(project_client=project_client) agent = await provider.get_agent(name=azure_ai_agent.name) diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_existing_conversation.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_existing_conversation.py similarity index 85% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_existing_conversation.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_existing_conversation.py index 0410c00bd7..92a31b2835 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_existing_conversation.py +++ b/python/samples/02-agents/providers/azure_ai/azure_ai_with_existing_conversation.py @@ -17,7 +17,9 @@ This sample demonstrates usage of AzureAIProjectAgentProvider with existing conv """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -59,9 +61,9 @@ async def example_with_conversation_id() -> None: print(f"Agent: {result.text}\n") -async def example_with_thread() -> None: - """This example shows how to specify existing conversation ID with AgentThread.""" - print("=== Azure AI Agent With Existing Conversation and Thread ===") +async def example_with_session() -> None: + """This example shows how to specify existing conversation ID with AgentSession.""" + print("=== Azure AI Agent With Existing Conversation and Session ===") async with ( AzureCliCredential() as credential, AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client, @@ -79,23 +81,23 @@ async def example_with_thread() -> None: conversation_id = conversation.id print(f"Conversation ID: {conversation_id}") - # Create a thread with the existing ID - thread = agent.get_new_thread(service_thread_id=conversation_id) + # Create a session with the existing ID + session = agent.create_session(service_session_id=conversation_id) query = "What's the weather like in Seattle?" print(f"User: {query}") - result = await agent.run(query, thread=thread) + result = await agent.run(query, session=session) print(f"Agent: {result.text}\n") query = "What was my last question?" print(f"User: {query}") - result = await agent.run(query, thread=thread) + result = await agent.run(query, session=session) print(f"Agent: {result.text}\n") async def main() -> None: await example_with_conversation_id() - await example_with_thread() + await example_with_session() if __name__ == "__main__": diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_explicit_settings.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_explicit_settings.py similarity index 91% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_explicit_settings.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_explicit_settings.py index 382205b7cc..c61d5bbb76 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_explicit_settings.py +++ b/python/samples/02-agents/providers/azure_ai/azure_ai_with_explicit_settings.py @@ -18,7 +18,9 @@ settings rather than relying on environment variable defaults. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_file_search.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_file_search.py similarity index 85% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_file_search.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_file_search.py index 6a45aca516..13f238a97e 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_file_search.py +++ b/python/samples/02-agents/providers/azure_ai/azure_ai_with_file_search.py @@ -4,8 +4,7 @@ import asyncio import os from pathlib import Path -from agent_framework import Content, HostedFileSearchTool -from agent_framework.azure import AzureAIProjectAgentProvider +from agent_framework.azure import AzureAIClient, AzureAIProjectAgentProvider from azure.ai.agents.aio import AgentsClient from azure.ai.agents.models import FileInfo, VectorStore from azure.identity.aio import AzureCliCredential @@ -36,7 +35,7 @@ async def main() -> None: ): try: # 1. Upload file and create vector store - pdf_file_path = Path(__file__).parent.parent / "resources" / "employees.pdf" + pdf_file_path = Path(__file__).parents[2] / "shared" / "resources" / "employees.pdf" print(f"Uploading file from: {pdf_file_path}") file = await agents_client.files.upload_and_poll(file_path=str(pdf_file_path), purpose="assistants") @@ -45,8 +44,9 @@ async def main() -> None: vector_store = await agents_client.vector_stores.create_and_poll(file_ids=[file.id], name="my_vectorstore") print(f"Created vector store, vector store ID: {vector_store.id}") - # 2. Create file search tool with uploaded resources - file_search_tool = HostedFileSearchTool(inputs=[Content.from_hosted_vector_store(vector_store_id=vector_store.id)]) + # 2. Create a client to access hosted tool factory methods + client = AzureAIClient(credential=credential) + file_search_tool = client.get_file_search_tool(vector_store_ids=[vector_store.id]) # 3. Create an agent with file search capabilities using the provider agent = await provider.create_agent( @@ -55,7 +55,7 @@ async def main() -> None: "You are a helpful assistant that can search through uploaded employee files " "to answer questions about employees." ), - tools=file_search_tool, + tools=[file_search_tool], ) # 4. Simulate conversation with the agent diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_hosted_mcp.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_hosted_mcp.py similarity index 59% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_hosted_mcp.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_hosted_mcp.py index 7f0660a5e8..02216a3014 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_hosted_mcp.py +++ b/python/samples/02-agents/providers/azure_ai/azure_ai_with_hosted_mcp.py @@ -3,8 +3,8 @@ import asyncio from typing import Any -from agent_framework import AgentResponse, AgentThread, ChatMessage, HostedMCPTool, SupportsAgentRun -from agent_framework.azure import AzureAIProjectAgentProvider +from agent_framework import AgentResponse, AgentSession, Message, SupportsAgentRun +from agent_framework.azure import AzureAIClient, AzureAIProjectAgentProvider from azure.identity.aio import AzureCliCredential """ @@ -14,8 +14,8 @@ This sample demonstrates integrating hosted Model Context Protocol (MCP) tools w """ -async def handle_approvals_without_thread(query: str, agent: "SupportsAgentRun") -> AgentResponse: - """When we don't have a thread, we need to ensure we return with the input, approval request and approval.""" +async def handle_approvals_without_session(query: str, agent: "SupportsAgentRun") -> AgentResponse: + """When we don't have a session, we need to ensure we return with the input, approval request and approval.""" result = await agent.run(query, store=False) while len(result.user_input_requests) > 0: @@ -25,20 +25,20 @@ async def handle_approvals_without_thread(query: str, agent: "SupportsAgentRun") f"User Input Request for function from {agent.name}: {user_input_needed.function_call.name}" f" with arguments: {user_input_needed.function_call.arguments}" ) - new_inputs.append(ChatMessage("assistant", [user_input_needed])) + new_inputs.append(Message("assistant", [user_input_needed])) user_approval = input("Approve function call? (y/n): ") new_inputs.append( - ChatMessage("user", [user_input_needed.to_function_approval_response(user_approval.lower() == "y")]) + Message("user", [user_input_needed.to_function_approval_response(user_approval.lower() == "y")]) ) result = await agent.run(new_inputs, store=False) return result -async def handle_approvals_with_thread(query: str, agent: "SupportsAgentRun", thread: "AgentThread") -> AgentResponse: - """Here we let the thread deal with the previous responses, and we just rerun with the approval.""" +async def handle_approvals_with_session(query: str, agent: "SupportsAgentRun", session: "AgentSession") -> AgentResponse: + """Here we let the session deal with the previous responses, and we just rerun with the approval.""" - result = await agent.run(query, thread=thread) + result = await agent.run(query, session=session) while len(result.user_input_requests) > 0: new_input: list[Any] = [] for user_input_needed in result.user_input_requests: @@ -48,12 +48,12 @@ async def handle_approvals_with_thread(query: str, agent: "SupportsAgentRun", th ) user_approval = input("Approve function call? (y/n): ") new_input.append( - ChatMessage( + Message( role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")], ) ) - result = await agent.run(new_input, thread=thread) + result = await agent.run(new_input, session=session) return result @@ -65,25 +65,30 @@ async def run_hosted_mcp_without_approval() -> None: AzureCliCredential() as credential, AzureAIProjectAgentProvider(credential=credential) as provider, ): + # Create a client to access hosted tool factory methods + client = AzureAIClient(credential=credential) + # Create MCP tool using instance method + mcp_tool = client.get_mcp_tool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", + approval_mode="never_require", + ) + agent = await provider.create_agent( name="MyLearnDocsAgent", instructions="You are a helpful assistant that can help with Microsoft documentation questions.", - tools=HostedMCPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - approval_mode="never_require", - ), + tools=[mcp_tool], ) query = "How to create an Azure storage account using az cli?" print(f"User: {query}") - result = await handle_approvals_without_thread(query, agent) + result = await handle_approvals_without_session(query, agent) print(f"{agent.name}: {result}\n") -async def run_hosted_mcp_with_approval_and_thread() -> None: - """Example showing MCP Tools with approvals using a thread.""" - print("=== MCP with approvals and with thread ===") +async def run_hosted_mcp_with_approval_and_session() -> None: + """Example showing MCP Tools with approvals using a session.""" + print("=== MCP with approvals and with session ===") # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. @@ -91,20 +96,25 @@ async def run_hosted_mcp_with_approval_and_thread() -> None: AzureCliCredential() as credential, AzureAIProjectAgentProvider(credential=credential) as provider, ): + # Create a client to access hosted tool factory methods + client = AzureAIClient(credential=credential) + # Create MCP tool using instance method + mcp_tool = client.get_mcp_tool( + name="api-specs", + url="https://gitmcp.io/Azure/azure-rest-api-specs", + approval_mode="always_require", + ) + agent = await provider.create_agent( name="MyApiSpecsAgent", instructions="You are a helpful agent that can use MCP tools to assist users.", - tools=HostedMCPTool( - name="api-specs", - url="https://gitmcp.io/Azure/azure-rest-api-specs", - approval_mode="always_require", - ), + tools=[mcp_tool], ) - thread = agent.get_new_thread() + session = agent.create_session() query = "Please summarize the Azure REST API specifications Readme" print(f"User: {query}") - result = await handle_approvals_with_thread(query, agent, thread) + result = await handle_approvals_with_session(query, agent, session) print(f"{agent.name}: {result}\n") @@ -112,7 +122,7 @@ async def main() -> None: print("=== Azure AI Agent with Hosted MCP Tools Example ===\n") await run_hosted_mcp_without_approval() - await run_hosted_mcp_with_approval_and_thread() + await run_hosted_mcp_with_approval_and_session() if __name__ == "__main__": diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_image_generation.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_image_generation.py similarity index 85% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_image_generation.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_image_generation.py index a097d3f4c2..48e54ef2e2 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_image_generation.py +++ b/python/samples/02-agents/providers/azure_ai/azure_ai_with_image_generation.py @@ -5,8 +5,7 @@ import tempfile from pathlib import Path from urllib import request as urllib_request -from agent_framework import HostedImageGenerationTool -from agent_framework.azure import AzureAIProjectAgentProvider +from agent_framework.azure import AzureAIClient, AzureAIProjectAgentProvider from azure.identity.aio import AzureCliCredential """ @@ -28,22 +27,21 @@ async def main() -> None: AzureCliCredential() as credential, AzureAIProjectAgentProvider(credential=credential) as provider, ): + # Create a client to access hosted tool factory methods + client = AzureAIClient(credential=credential) + # Create image generation tool using instance method + image_gen_tool = client.get_image_generation_tool( + model="gpt-image-1", + size="1024x1024", + output_format="png", + quality="low", + background="opaque", + ) + agent = await provider.create_agent( name="ImageGenAgent", instructions="Generate images based on user requirements.", - tools=[ - HostedImageGenerationTool( - options={ - "model_id": "gpt-image-1", - "image_size": "1024x1024", - "media_type": "png", - }, - additional_properties={ - "quality": "low", - "background": "opaque", - }, - ) - ], + tools=[image_gen_tool], ) query = "Generate an image of Microsoft logo." diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_local_mcp.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_local_mcp.py similarity index 100% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_local_mcp.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_local_mcp.py diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_memory_search.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_memory_search.py similarity index 100% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_memory_search.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_memory_search.py diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_microsoft_fabric.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_microsoft_fabric.py similarity index 100% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_microsoft_fabric.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_microsoft_fabric.py diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_openapi.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_openapi.py similarity index 95% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_openapi.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_openapi.py index 260a5a0206..73b8cf5102 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_openapi.py +++ b/python/samples/02-agents/providers/azure_ai/azure_ai_with_openapi.py @@ -20,7 +20,7 @@ Prerequisites: async def main() -> None: # Load the OpenAPI specification - resources_path = Path(__file__).parent.parent / "resources" / "countries.json" + resources_path = Path(__file__).parents[2] / "shared" / "resources" / "countries.json" with open(resources_path) as f: openapi_countries = json.load(f) diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_reasoning.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_reasoning.py similarity index 100% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_reasoning.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_reasoning.py diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_response_format.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_response_format.py similarity index 100% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_response_format.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_response_format.py diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_runtime_json_schema.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_runtime_json_schema.py similarity index 100% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_runtime_json_schema.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_runtime_json_schema.py diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_session.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_session.py new file mode 100644 index 0000000000..d5fe1ba9c3 --- /dev/null +++ b/python/samples/02-agents/providers/azure_ai/azure_ai_with_session.py @@ -0,0 +1,162 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from random import randint +from typing import Annotated + +from agent_framework import tool +from agent_framework.azure import AzureAIProjectAgentProvider +from azure.identity.aio import AzureCliCredential +from pydantic import Field + +""" +Azure AI Agent with Session Management Example + +This sample demonstrates session management with Azure AI Agent, showing +persistent conversation capabilities using service-managed sessions as well as storing messages in-memory. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production +# See: +# samples/02-agents/tools/function_tool_with_approval.py +# samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def example_with_automatic_session_creation() -> None: + """Example showing automatic session creation.""" + print("=== Automatic Session Creation Example ===") + + async with ( + AzureCliCredential() as credential, + AzureAIProjectAgentProvider(credential=credential) as provider, + ): + agent = await provider.create_agent( + name="BasicWeatherAgent", + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # First conversation - no session provided, will be created automatically + query1 = "What's the weather like in Seattle?" + print(f"User: {query1}") + result1 = await agent.run(query1) + print(f"Agent: {result1.text}") + + # Second conversation - still no session provided, will create another new session + query2 = "What was the last city I asked about?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2) + print(f"Agent: {result2.text}") + print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") + + +async def example_with_session_persistence_in_memory() -> None: + """ + Example showing session persistence across multiple conversations. + In this example, messages are stored in-memory. + """ + print("=== Session Persistence Example (In-Memory) ===") + + async with ( + AzureCliCredential() as credential, + AzureAIProjectAgentProvider(credential=credential) as provider, + ): + agent = await provider.create_agent( + name="BasicWeatherAgent", + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Create a new session that will be reused + session = agent.create_session() + + # First conversation + first_query = "What's the weather like in Tokyo?" + print(f"User: {first_query}") + first_result = await agent.run(first_query, session=session, options={"store": False}) + print(f"Agent: {first_result.text}") + + # Second conversation using the same session - maintains context + second_query = "How about London?" + print(f"\nUser: {second_query}") + second_result = await agent.run(second_query, session=session, options={"store": False}) + print(f"Agent: {second_result.text}") + + # Third conversation - agent should remember both previous cities + third_query = "Which of the cities I asked about has better weather?" + print(f"\nUser: {third_query}") + third_result = await agent.run(third_query, session=session, options={"store": False}) + print(f"Agent: {third_result.text}") + print("Note: The agent remembers context from previous messages in the same session.\n") + + +async def example_with_existing_session_id() -> None: + """ + Example showing how to work with an existing session ID from the service. + In this example, messages are stored on the server. + """ + print("=== Existing Session ID Example ===") + + # First, create a conversation and capture the session ID + existing_session_id = None + + async with ( + AzureCliCredential() as credential, + AzureAIProjectAgentProvider(credential=credential) as provider, + ): + agent = await provider.create_agent( + name="BasicWeatherAgent", + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Start a conversation and get the session ID + session = agent.create_session() + + first_query = "What's the weather in Paris?" + print(f"User: {first_query}") + first_result = await agent.run(first_query, session=session) + print(f"Agent: {first_result.text}") + + # The session ID is set after the first response + existing_session_id = session.service_session_id + print(f"Session ID: {existing_session_id}") + + if existing_session_id: + print("\n--- Continuing with the same session ID in a new agent instance ---") + + # Create a new agent instance from the same provider + second_agent = await provider.create_agent( + name="BasicWeatherAgent", + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Create a session with the existing ID + session = second_agent.create_session(service_session_id=existing_session_id) + + second_query = "What was the last city I asked about?" + print(f"User: {second_query}") + second_result = await second_agent.run(second_query, session=session) + print(f"Agent: {second_result.text}") + print("Note: The agent continues the conversation from the previous session by using session ID.\n") + + +async def main() -> None: + print("=== Azure AI Agent Session Management Examples ===\n") + + await example_with_automatic_session_creation() + await example_with_session_persistence_in_memory() + await example_with_existing_session_id() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_sharepoint.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_sharepoint.py similarity index 100% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_sharepoint.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_sharepoint.py diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_web_search.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_web_search.py similarity index 78% rename from python/samples/getting_started/agents/azure_ai/azure_ai_with_web_search.py rename to python/samples/02-agents/providers/azure_ai/azure_ai_with_web_search.py index 9ecb416f8d..39274c42d6 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_web_search.py +++ b/python/samples/02-agents/providers/azure_ai/azure_ai_with_web_search.py @@ -2,15 +2,14 @@ import asyncio -from agent_framework import HostedWebSearchTool -from agent_framework.azure import AzureAIProjectAgentProvider +from agent_framework.azure import AzureAIClient, AzureAIProjectAgentProvider from azure.identity.aio import AzureCliCredential """ Azure AI Agent With Web Search This sample demonstrates basic usage of AzureAIProjectAgentProvider to create an agent -that can perform web searches using the HostedWebSearchTool. +that can perform web searches using get_web_search_tool(). Pre-requisites: - Make sure to set up the AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME @@ -25,10 +24,15 @@ async def main() -> None: AzureCliCredential() as credential, AzureAIProjectAgentProvider(credential=credential) as provider, ): + # Create a client to access hosted tool factory methods + client = AzureAIClient(credential=credential) + # Create web search tool using instance method + web_search_tool = client.get_web_search_tool() + agent = await provider.create_agent( name="WebsearchAgent", instructions="You are a helpful assistant that can search the web", - tools=[HostedWebSearchTool()], + tools=[web_search_tool], ) query = "What's the weather today in Seattle?" diff --git a/python/samples/getting_started/agents/azure_ai_agent/README.md b/python/samples/02-agents/providers/azure_ai_agent/README.md similarity index 80% rename from python/samples/getting_started/agents/azure_ai_agent/README.md rename to python/samples/02-agents/providers/azure_ai_agent/README.md index 5440b2d3ba..3a52984006 100644 --- a/python/samples/getting_started/agents/azure_ai_agent/README.md +++ b/python/samples/02-agents/providers/azure_ai_agent/README.md @@ -8,7 +8,7 @@ All examples in this folder use the `AzureAIAgentsProvider` class which provides - **`create_agent()`** - Create a new agent on the Azure AI service - **`get_agent()`** - Retrieve an existing agent by ID or from a pre-fetched Agent object -- **`as_agent()`** - Wrap an SDK Agent object as a ChatAgent without HTTP calls +- **`as_agent()`** - Wrap an SDK Agent object as a Agent without HTTP calls ```python from agent_framework.azure import AzureAIAgentsProvider @@ -32,23 +32,23 @@ async with ( |------|-------------| | [`azure_ai_provider_methods.py`](azure_ai_provider_methods.py) | Comprehensive example demonstrating all `AzureAIAgentsProvider` methods: `create_agent()`, `get_agent()`, `as_agent()`, and managing multiple agents from a single provider. | | [`azure_ai_basic.py`](azure_ai_basic.py) | The simplest way to create an agent using `AzureAIAgentsProvider`. It automatically handles all configuration using environment variables. Shows both streaming and non-streaming responses. | -| [`azure_ai_with_bing_custom_search.py`](azure_ai_with_bing_custom_search.py) | Shows how to use Bing Custom Search with Azure AI agents to find real-time information from the web using custom search configurations. Demonstrates how to set up and use HostedWebSearchTool with custom search instances. | -| [`azure_ai_with_bing_grounding.py`](azure_ai_with_bing_grounding.py) | Shows how to use Bing Grounding search with Azure AI agents to find real-time information from the web. Demonstrates web search capabilities with proper source citations and comprehensive error handling. | +| [`azure_ai_with_bing_custom_search.py`](azure_ai_with_bing_custom_search.py) | Shows how to use Bing Custom Search with Azure AI agents to find real-time information from the web using custom search configurations. Demonstrates how to use `AzureAIAgentClient.get_web_search_tool()` with custom search instances. | +| [`azure_ai_with_bing_grounding.py`](azure_ai_with_bing_grounding.py) | Shows how to use Bing Grounding search with Azure AI agents to find real-time information from the web. Demonstrates `AzureAIAgentClient.get_web_search_tool()` with proper source citations and comprehensive error handling. | | [`azure_ai_with_bing_grounding_citations.py`](azure_ai_with_bing_grounding_citations.py) | Demonstrates how to extract and display citations from Bing Grounding search responses. Shows how to collect citation annotations (title, URL, snippet) during streaming responses, enabling users to verify sources and access referenced content. | | [`azure_ai_with_code_interpreter_file_generation.py`](azure_ai_with_code_interpreter_file_generation.py) | Shows how to retrieve file IDs from code interpreter generated files using both streaming and non-streaming approaches. | -| [`azure_ai_with_code_interpreter.py`](azure_ai_with_code_interpreter.py) | Shows how to use the HostedCodeInterpreterTool with Azure AI agents to write and execute Python code. Includes helper methods for accessing code interpreter data from response chunks. | +| [`azure_ai_with_code_interpreter.py`](azure_ai_with_code_interpreter.py) | Shows how to use `AzureAIAgentClient.get_code_interpreter_tool()` with Azure AI agents to write and execute Python code. Includes helper methods for accessing code interpreter data from response chunks. | | [`azure_ai_with_existing_agent.py`](azure_ai_with_existing_agent.py) | Shows how to work with an existing SDK Agent object using `provider.as_agent()`. This wraps the agent without making HTTP calls. | -| [`azure_ai_with_existing_thread.py`](azure_ai_with_existing_thread.py) | Shows how to work with a pre-existing thread by providing the thread ID. Demonstrates proper cleanup of manually created threads. | +| [`azure_ai_with_existing_session.py`](azure_ai_with_existing_session.py) | Shows how to work with a pre-existing session by providing the session ID. Demonstrates proper cleanup of manually created sessions. | | [`azure_ai_with_explicit_settings.py`](azure_ai_with_explicit_settings.py) | Shows how to create an agent with explicitly configured provider settings, including project endpoint and model deployment name. | | [`azure_ai_with_azure_ai_search.py`](azure_ai_with_azure_ai_search.py) | Demonstrates how to use Azure AI Search with Azure AI agents. Shows how to create an agent with search tools using the SDK directly and wrap it with `provider.get_agent()`. | -| [`azure_ai_with_file_search.py`](azure_ai_with_file_search.py) | Demonstrates how to use the HostedFileSearchTool with Azure AI agents to search through uploaded documents. Shows file upload, vector store creation, and querying document content. | +| [`azure_ai_with_file_search.py`](azure_ai_with_file_search.py) | Demonstrates how to use `AzureAIAgentClient.get_file_search_tool()` with Azure AI agents to search through uploaded documents. Shows file upload, vector store creation, and querying document content. | | [`azure_ai_with_function_tools.py`](azure_ai_with_function_tools.py) | Demonstrates how to use function tools with agents. Shows both agent-level tools (defined when creating the agent) and query-level tools (provided with specific queries). | -| [`azure_ai_with_hosted_mcp.py`](azure_ai_with_hosted_mcp.py) | Shows how to integrate Azure AI agents with hosted Model Context Protocol (MCP) servers for enhanced functionality and tool integration. Demonstrates remote MCP server connections and tool discovery. | +| [`azure_ai_with_hosted_mcp.py`](azure_ai_with_hosted_mcp.py) | Shows how to use `AzureAIAgentClient.get_mcp_tool()` with hosted Model Context Protocol (MCP) servers for enhanced functionality and tool integration. Demonstrates remote MCP server connections and tool discovery. | | [`azure_ai_with_local_mcp.py`](azure_ai_with_local_mcp.py) | Shows how to integrate Azure AI agents with local Model Context Protocol (MCP) servers for enhanced functionality and tool integration. Demonstrates both agent-level and run-level tool configuration. | -| [`azure_ai_with_multiple_tools.py`](azure_ai_with_multiple_tools.py) | Demonstrates how to use multiple tools together with Azure AI agents, including web search, MCP servers, and function tools. Shows coordinated multi-tool interactions and approval workflows. | -| [`azure_ai_with_openapi_tools.py`](azure_ai_with_openapi_tools.py) | Demonstrates how to use OpenAPI tools with Azure AI agents to integrate external REST APIs. Shows OpenAPI specification loading, anonymous authentication, thread context management, and coordinated multi-API conversations. | +| [`azure_ai_with_multiple_tools.py`](azure_ai_with_multiple_tools.py) | Demonstrates how to use multiple tools together with Azure AI agents, including web search, MCP servers, and function tools using client static methods. Shows coordinated multi-tool interactions and approval workflows. | +| [`azure_ai_with_openapi_tools.py`](azure_ai_with_openapi_tools.py) | Demonstrates how to use OpenAPI tools with Azure AI agents to integrate external REST APIs. Shows OpenAPI specification loading, anonymous authentication, session context management, and coordinated multi-API conversations. | | [`azure_ai_with_response_format.py`](azure_ai_with_response_format.py) | Demonstrates how to use structured outputs with Azure AI agents using Pydantic models. | -| [`azure_ai_with_thread.py`](azure_ai_with_thread.py) | Demonstrates thread management with Azure AI agents, including automatic thread creation for stateless conversations and explicit thread management for maintaining conversation context across multiple interactions. | +| [`azure_ai_with_session.py`](azure_ai_with_session.py) | Demonstrates session management with Azure AI agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | ## Environment Variables diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_basic.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_basic.py similarity index 94% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_basic.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_basic.py index 34bd782a9b..0d10337e86 100644 --- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_basic.py +++ b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_basic.py @@ -17,7 +17,7 @@ lifecycle management. Shows both streaming and non-streaming responses with func """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_provider_methods.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_provider_methods.py similarity index 96% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_provider_methods.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_provider_methods.py index 5dd06f16f0..e8e19b068b 100644 --- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_provider_methods.py +++ b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_provider_methods.py @@ -21,7 +21,7 @@ This sample demonstrates the methods available on the AzureAIAgentsProvider clas """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_azure_ai_search.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_azure_ai_search.py similarity index 100% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_azure_ai_search.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_azure_ai_search.py diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_bing_custom_search.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_custom_search.py similarity index 77% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_bing_custom_search.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_custom_search.py index ef41cf7c35..d4d718a868 100644 --- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_bing_custom_search.py +++ b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_custom_search.py @@ -2,8 +2,7 @@ import asyncio -from agent_framework import HostedWebSearchTool -from agent_framework.azure import AzureAIAgentsProvider +from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider from azure.identity.aio import AzureCliCredential """ @@ -30,25 +29,25 @@ To set up Bing Custom Search: async def main() -> None: """Main function demonstrating Azure AI agent with Bing Custom Search.""" - # 1. Create Bing Custom Search tool using HostedWebSearchTool - # The connection ID and instance name will be automatically picked up from environment variables - bing_search_tool = HostedWebSearchTool( - name="Bing Custom Search", - description="Search the web for current information using Bing Custom Search", - ) - - # 2. Use AzureAIAgentsProvider for agent creation and management + # Use AzureAIAgentsProvider for agent creation and management async with ( AzureCliCredential() as credential, AzureAIAgentsProvider(credential=credential) as provider, ): + # Create a client to access hosted tool factory methods + client = AzureAIAgentClient(credential=credential) + # Create Bing Custom Search tool using instance method + # The connection ID and instance name will be automatically picked up from environment variables + # (BING_CUSTOM_CONNECTION_ID and BING_CUSTOM_INSTANCE_NAME) + bing_search_tool = client.get_web_search_tool() + agent = await provider.create_agent( name="BingSearchAgent", instructions=( "You are a helpful agent that can use Bing Custom Search tools to assist users. " "Use the available Bing Custom Search tools to answer questions and perform tasks." ), - tools=bing_search_tool, + tools=[bing_search_tool], ) # 3. Demonstrate agent capabilities with bing custom search diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_bing_grounding.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_grounding.py similarity index 77% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_bing_grounding.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_grounding.py index 016c6ddeb8..9724f91591 100644 --- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_bing_grounding.py +++ b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_grounding.py @@ -2,8 +2,7 @@ import asyncio -from agent_framework import HostedWebSearchTool -from agent_framework.azure import AzureAIAgentsProvider +from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider from azure.identity.aio import AzureCliCredential """ @@ -25,18 +24,17 @@ To set up Bing Grounding: async def main() -> None: """Main function demonstrating Azure AI agent with Bing Grounding search.""" - # 1. Create Bing Grounding search tool using HostedWebSearchTool - # The connection ID will be automatically picked up from environment variable - bing_search_tool = HostedWebSearchTool( - name="Bing Grounding Search", - description="Search the web for current information using Bing", - ) - - # 2. Use AzureAIAgentsProvider for agent creation and management + # Use AzureAIAgentsProvider for agent creation and management async with ( AzureCliCredential() as credential, AzureAIAgentsProvider(credential=credential) as provider, ): + # Create a client to access hosted tool factory methods + client = AzureAIAgentClient(credential=credential) + # Create Bing Grounding search tool using instance method + # The connection ID will be automatically picked up from environment variable + bing_search_tool = client.get_web_search_tool() + agent = await provider.create_agent( name="BingSearchAgent", instructions=( @@ -44,7 +42,7 @@ async def main() -> None: "Use the Bing search tool to find up-to-date information and provide accurate, " "well-sourced answers. Always cite your sources when possible." ), - tools=bing_search_tool, + tools=[bing_search_tool], ) # 3. Demonstrate agent capabilities with web search diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_bing_grounding_citations.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_grounding_citations.py similarity index 83% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_bing_grounding_citations.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_grounding_citations.py index fd1f321741..10d594514c 100644 --- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_bing_grounding_citations.py +++ b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_grounding_citations.py @@ -2,8 +2,8 @@ import asyncio -from agent_framework import Annotation, HostedWebSearchTool -from agent_framework.azure import AzureAIAgentsProvider +from agent_framework import Annotation +from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider from azure.identity.aio import AzureCliCredential """ @@ -27,18 +27,17 @@ To set up Bing Grounding: async def main() -> None: """Main function demonstrating Azure AI agent with Bing Grounding search.""" - # 1. Create Bing Grounding search tool using HostedWebSearchTool - # The connection ID will be automatically picked up from environment variable - bing_search_tool = HostedWebSearchTool( - name="Bing Grounding Search", - description="Search the web for current information using Bing", - ) - - # 2. Use AzureAIAgentsProvider for agent creation and management + # Use AzureAIAgentsProvider for agent creation and management async with ( AzureCliCredential() as credential, AzureAIAgentsProvider(credential=credential) as provider, ): + # Create a client to access hosted tool factory methods + client = AzureAIAgentClient(credential=credential) + # Create Bing Grounding search tool using instance method + # The connection ID will be automatically picked up from environment variable + bing_search_tool = client.get_web_search_tool() + agent = await provider.create_agent( name="BingSearchAgent", instructions=( @@ -46,7 +45,7 @@ async def main() -> None: "Use the Bing search tool to find up-to-date information and provide accurate, " "well-sourced answers. Always cite your sources when possible." ), - tools=bing_search_tool, + tools=[bing_search_tool], ) # 3. Demonstrate agent capabilities with web search diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_code_interpreter.py similarity index 77% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_code_interpreter.py index a40ee17258..16da21bbe0 100644 --- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter.py +++ b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_code_interpreter.py @@ -2,8 +2,8 @@ import asyncio -from agent_framework import AgentResponse, ChatResponseUpdate, HostedCodeInterpreterTool -from agent_framework.azure import AzureAIAgentsProvider +from agent_framework import AgentResponse, ChatResponseUpdate +from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider from azure.ai.agents.models import ( RunStepDeltaCodeInterpreterDetailItemObject, ) @@ -12,7 +12,7 @@ from azure.identity.aio import AzureCliCredential """ Azure AI Agent with Code Interpreter Example -This sample demonstrates using HostedCodeInterpreterTool with Azure AI Agents +This sample demonstrates using get_code_interpreter_tool() with Azure AI Agents for Python code execution and mathematical problem solving. """ @@ -32,7 +32,7 @@ def print_code_interpreter_inputs(response: AgentResponse) -> None: async def main() -> None: - """Example showing how to use the HostedCodeInterpreterTool with Azure AI.""" + """Example showing how to use the code interpreter tool with Azure AI.""" print("=== Azure AI Agent with Code Interpreter Example ===") # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred @@ -41,10 +41,14 @@ async def main() -> None: AzureCliCredential() as credential, AzureAIAgentsProvider(credential=credential) as provider, ): + # Create a client to access hosted tool factory methods + client = AzureAIAgentClient(credential=credential) + code_interpreter_tool = client.get_code_interpreter_tool() + agent = await provider.create_agent( name="CodingAgent", instructions=("You are a helpful assistant that can write and execute Python code to solve problems."), - tools=HostedCodeInterpreterTool(), + tools=[code_interpreter_tool], ) query = "Generate the factorial of 100 using python code, show the code and execute it." print(f"User: {query}") diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter_file_generation.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_code_interpreter_file_generation.py similarity index 90% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter_file_generation.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_code_interpreter_file_generation.py index ac8d64f3cb..3cbf9c5855 100644 --- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter_file_generation.py +++ b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_code_interpreter_file_generation.py @@ -3,17 +3,14 @@ import asyncio import os -from agent_framework import ( - HostedCodeInterpreterTool, -) -from agent_framework.azure import AzureAIAgentsProvider +from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider from azure.ai.agents.aio import AgentsClient from azure.identity.aio import AzureCliCredential """ Azure AI Agent Code Interpreter File Generation Example -This sample demonstrates using HostedCodeInterpreterTool with AzureAIAgentsProvider +This sample demonstrates using get_code_interpreter_tool() with AzureAIAgentsProvider to generate a text file and then retrieve it. The test flow: @@ -32,6 +29,10 @@ async def main() -> None: AgentsClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as agents_client, AzureAIAgentsProvider(agents_client=agents_client) as provider, ): + # Create a client to access hosted tool factory methods + client = AzureAIAgentClient(credential=credential) + code_interpreter_tool = client.get_code_interpreter_tool() + agent = await provider.create_agent( name="CodeInterpreterAgent", instructions=( @@ -39,7 +40,7 @@ async def main() -> None: "ALWAYS use the code interpreter tool to execute Python code when asked to create files. " "Write actual Python code to create files, do not just describe what you would do." ), - tools=[HostedCodeInterpreterTool()], + tools=[code_interpreter_tool], ) # Be very explicit about wanting code execution and a download link diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_existing_agent.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_existing_agent.py similarity index 100% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_existing_agent.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_existing_agent.py diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_existing_thread.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_existing_session.py similarity index 75% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_existing_thread.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_existing_session.py index 0f17d35183..66451483ac 100644 --- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_existing_thread.py +++ b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_existing_session.py @@ -12,14 +12,16 @@ from azure.identity.aio import AzureCliCredential from pydantic import Field """ -Azure AI Agent with Existing Thread Example +Azure AI Agent with Existing Session Example -This sample demonstrates working with pre-existing conversation threads -by providing thread IDs for thread reuse patterns. +This sample demonstrates working with pre-existing conversation sessions +by providing session IDs for session reuse patterns. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -30,7 +32,7 @@ def get_weather( async def main() -> None: - print("=== Azure AI Agent with Existing Thread ===") + print("=== Azure AI Agent with Existing Session ===") # Create the client and provider async with ( @@ -38,7 +40,7 @@ async def main() -> None: AgentsClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as agents_client, AzureAIAgentsProvider(agents_client=agents_client) as provider, ): - # Create a thread that will persist + # Create a session that will persist created_thread = await agents_client.threads.create() try: @@ -49,12 +51,11 @@ async def main() -> None: tools=get_weather, ) - thread = agent.get_new_thread(service_thread_id=created_thread.id) - assert thread.is_initialized - result = await agent.run("What's the weather like in Tokyo?", thread=thread) + session = agent.get_session(service_session_id=created_thread.id) + result = await agent.run("What's the weather like in Tokyo?", session=session) print(f"Result: {result}\n") finally: - # Clean up the thread manually + # Clean up the session manually await agents_client.threads.delete(created_thread.id) diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_explicit_settings.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_explicit_settings.py similarity index 91% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_explicit_settings.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_explicit_settings.py index 05c8c60a36..ea088106d0 100644 --- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_explicit_settings.py +++ b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_explicit_settings.py @@ -18,7 +18,9 @@ settings rather than relying on environment variable defaults. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_file_search.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_file_search.py similarity index 88% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_file_search.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_file_search.py index 353b4aacd2..51613d394f 100644 --- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_file_search.py +++ b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_file_search.py @@ -4,8 +4,7 @@ import asyncio import os from pathlib import Path -from agent_framework import Content, HostedFileSearchTool -from agent_framework.azure import AzureAIAgentsProvider +from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider from azure.ai.agents.aio import AgentsClient from azure.ai.agents.models import FileInfo, VectorStore from azure.identity.aio import AzureCliCredential @@ -45,8 +44,9 @@ async def main() -> None: vector_store = await agents_client.vector_stores.create_and_poll(file_ids=[file.id], name="my_vectorstore") print(f"Created vector store, vector store ID: {vector_store.id}") - # 2. Create file search tool with uploaded resources - file_search_tool = HostedFileSearchTool(inputs=[Content.from_hosted_vector_store(vector_store_id=vector_store.id)]) + # 2. Create a client to access hosted tool factory methods + client = AzureAIAgentClient(credential=credential) + file_search_tool = client.get_file_search_tool(vector_store_ids=[vector_store.id]) # 3. Create an agent with file search capabilities agent = await provider.create_agent( @@ -55,7 +55,7 @@ async def main() -> None: "You are a helpful assistant that can search through uploaded employee files " "to answer questions about employees." ), - tools=file_search_tool, + tools=[file_search_tool], ) # 4. Simulate conversation with the agent diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_function_tools.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_function_tools.py similarity index 96% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_function_tools.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_function_tools.py index 97cd59ca19..2b252af9c5 100644 --- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_function_tools.py +++ b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_function_tools.py @@ -18,7 +18,9 @@ showing both agent-level and query-level tool configuration patterns. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_hosted_mcp.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_hosted_mcp.py similarity index 62% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_hosted_mcp.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_hosted_mcp.py index 19de064106..9a64bae9a1 100644 --- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_hosted_mcp.py +++ b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_hosted_mcp.py @@ -3,8 +3,8 @@ import asyncio from typing import Any -from agent_framework import AgentResponse, AgentThread, HostedMCPTool, SupportsAgentRun -from agent_framework.azure import AzureAIAgentsProvider +from agent_framework import AgentResponse, AgentSession, SupportsAgentRun +from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider from azure.identity.aio import AzureCliCredential """ @@ -15,11 +15,11 @@ servers, including user approval workflows for function call security. """ -async def handle_approvals_with_thread(query: str, agent: "SupportsAgentRun", thread: "AgentThread") -> AgentResponse: - """Here we let the thread deal with the previous responses, and we just rerun with the approval.""" - from agent_framework import ChatMessage +async def handle_approvals_with_session(query: str, agent: "SupportsAgentRun", session: "AgentSession") -> AgentResponse: + """Here we let the session deal with the previous responses, and we just rerun with the approval.""" + from agent_framework import Message - result = await agent.run(query, thread=thread, store=True) + result = await agent.run(query, session=session, store=True) while len(result.user_input_requests) > 0: new_input: list[Any] = [] for user_input_needed in result.user_input_requests: @@ -29,40 +29,46 @@ async def handle_approvals_with_thread(query: str, agent: "SupportsAgentRun", th ) user_approval = input("Approve function call? (y/n): ") new_input.append( - ChatMessage( + Message( role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")], ) ) - result = await agent.run(new_input, thread=thread, store=True) + result = await agent.run(new_input, session=session, store=True) return result async def main() -> None: """Example showing Hosted MCP tools for a Azure AI Agent.""" + async with ( AzureCliCredential() as credential, AzureAIAgentsProvider(credential=credential) as provider, ): + # Create a client to access hosted tool factory methods + client = AzureAIAgentClient(credential=credential) + # Create MCP tool using instance method + mcp_tool = client.get_mcp_tool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", + ) + agent = await provider.create_agent( name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=HostedMCPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - ), + tools=[mcp_tool], ) - thread = agent.get_new_thread() + session = agent.create_session() # First query query1 = "How to create an Azure storage account using az cli?" print(f"User: {query1}") - result1 = await handle_approvals_with_thread(query1, agent, thread) + result1 = await handle_approvals_with_session(query1, agent, session) print(f"{agent.name}: {result1}\n") print("\n=======================================\n") # Second query query2 = "What is Microsoft Agent Framework?" print(f"User: {query2}") - result2 = await handle_approvals_with_thread(query2, agent, thread) + result2 = await handle_approvals_with_session(query2, agent, session) print(f"{agent.name}: {result2}\n") diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_local_mcp.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_local_mcp.py similarity index 97% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_local_mcp.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_local_mcp.py index 0586ffb78e..8e26edfccc 100644 --- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_local_mcp.py +++ b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_local_mcp.py @@ -51,7 +51,7 @@ async def mcp_tools_on_agent_level() -> None: print("=== Tools Defined on Agent Level ===") # Tools are provided when creating the agent - # The ChatAgent will connect to the MCP server through its context manager + # The Agent will connect to the MCP server through its context manager # and discover tools at runtime async with ( AzureCliCredential() as credential, diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_multiple_tools.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_multiple_tools.py similarity index 69% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_multiple_tools.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_multiple_tools.py index b7700dd6c2..4e2112f0a5 100644 --- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_multiple_tools.py +++ b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_multiple_tools.py @@ -5,13 +5,11 @@ from datetime import datetime, timezone from typing import Any from agent_framework import ( - AgentThread, - HostedMCPTool, - HostedWebSearchTool, + AgentSession, SupportsAgentRun, tool, ) -from agent_framework.azure import AzureAIAgentsProvider +from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider from azure.identity.aio import AzureCliCredential """ @@ -35,7 +33,9 @@ To set up Bing Grounding: """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_time() -> str: """Get the current UTC time.""" @@ -43,11 +43,11 @@ def get_time() -> str: return f"The current UTC time is {current_time.strftime('%Y-%m-%d %H:%M:%S')}." -async def handle_approvals_with_thread(query: str, agent: "SupportsAgentRun", thread: "AgentThread"): - """Here we let the thread deal with the previous responses, and we just rerun with the approval.""" - from agent_framework import ChatMessage +async def handle_approvals_with_session(query: str, agent: "SupportsAgentRun", session: "AgentSession"): + """Here we let the session deal with the previous responses, and we just rerun with the approval.""" + from agent_framework import Message - result = await agent.run(query, thread=thread, store=True) + result = await agent.run(query, session=session, store=True) while len(result.user_input_requests) > 0: new_input: list[Any] = [] for user_input_needed in result.user_input_requests: @@ -57,44 +57,51 @@ async def handle_approvals_with_thread(query: str, agent: "SupportsAgentRun", th ) user_approval = input("Approve function call? (y/n): ") new_input.append( - ChatMessage( + Message( role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")], ) ) - result = await agent.run(new_input, thread=thread, store=True) + result = await agent.run(new_input, session=session, store=True) return result async def main() -> None: - """Example showing Hosted MCP tools for a Azure AI Agent.""" + """Example showing multiple tools for an Azure AI Agent.""" + async with ( AzureCliCredential() as credential, AzureAIAgentsProvider(credential=credential) as provider, ): + # Create a client to access hosted tool factory methods + client = AzureAIAgentClient(credential=credential) + # Create tools using instance methods + mcp_tool = client.get_mcp_tool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", + ) + web_search_tool = client.get_web_search_tool() + agent = await provider.create_agent( name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", tools=[ - HostedMCPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - ), - HostedWebSearchTool(count=5), + mcp_tool, + web_search_tool, get_time, ], ) - thread = agent.get_new_thread() + session = agent.create_session() # First query query1 = "How to create an Azure storage account using az cli and what time is it?" print(f"User: {query1}") - result1 = await handle_approvals_with_thread(query1, agent, thread) + result1 = await handle_approvals_with_session(query1, agent, session) print(f"{agent.name}: {result1}\n") print("\n=======================================\n") # Second query query2 = "What is Microsoft Agent Framework and use a web search to see what is Reddit saying about it?" print(f"User: {query2}") - result2 = await handle_approvals_with_thread(query2, agent, thread) + result2 = await handle_approvals_with_session(query2, agent, session) print(f"{agent.name}: {result2}\n") diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_openapi_tools.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_openapi_tools.py similarity index 88% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_openapi_tools.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_openapi_tools.py index 24fd8eba9a..ff5ad8c8dc 100644 --- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_openapi_tools.py +++ b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_openapi_tools.py @@ -23,7 +23,7 @@ USER_INPUTS = [ def load_openapi_specs() -> tuple[dict[str, Any], dict[str, Any]]: """Load OpenAPI specification files.""" - resources_path = Path(__file__).parent.parent / "resources" + resources_path = Path(__file__).parents[2] / "shared" / "resources" with open(resources_path / "weather.json") as weather_file: weather_spec = json.load(weather_file) @@ -76,16 +76,16 @@ async def main() -> None: tools=[*openapi_countries.definitions, *openapi_weather.definitions], ) - # 5. Simulate conversation with the agent maintaining thread context + # 5. Simulate conversation with the agent maintaining session context print("=== Azure AI Agent with OpenAPI Tools ===\n") - # Create a thread to maintain conversation context across multiple runs - thread = agent.get_new_thread() + # Create a session to maintain conversation context across multiple runs + session = agent.create_session() for user_input in USER_INPUTS: print(f"User: {user_input}") - # Pass the thread to maintain context across multiple agent.run() calls - response = await agent.run(user_input, thread=thread) + # Pass the session to maintain context across multiple agent.run() calls + response = await agent.run(user_input, session=session) print(f"Agent: {response.text}\n") diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_response_format.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_response_format.py similarity index 100% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_response_format.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_response_format.py diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_thread.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_session.py similarity index 56% rename from python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_thread.py rename to python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_session.py index a48851d67c..3025aff851 100644 --- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_thread.py +++ b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_session.py @@ -4,20 +4,22 @@ import asyncio from random import randint from typing import Annotated -from agent_framework import AgentThread, tool +from agent_framework import AgentSession, tool from agent_framework.azure import AzureAIAgentsProvider from azure.identity.aio import AzureCliCredential from pydantic import Field """ -Azure AI Agent with Thread Management Example +Azure AI Agent with Session Management Example -This sample demonstrates thread management with Azure AI Agents, comparing -automatic thread creation with explicit thread management for persistent context. +This sample demonstrates session management with Azure AI Agents, comparing +automatic session creation with explicit session management for persistent context. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -27,9 +29,9 @@ def get_weather( return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." -async def example_with_automatic_thread_creation() -> None: - """Example showing automatic thread creation (service-managed thread).""" - print("=== Automatic Thread Creation Example ===") +async def example_with_automatic_session_creation() -> None: + """Example showing automatic session creation (service-managed session).""" + print("=== Automatic Session Creation Example ===") # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. @@ -43,24 +45,24 @@ async def example_with_automatic_thread_creation() -> None: tools=get_weather, ) - # First conversation - no thread provided, will be created automatically + # First conversation - no session provided, will be created automatically first_query = "What's the weather like in Seattle?" print(f"User: {first_query}") first_result = await agent.run(first_query) print(f"Agent: {first_result.text}") - # Second conversation - still no thread provided, will create another new thread + # Second conversation - still no session provided, will create another new session second_query = "What was the last city I asked about?" print(f"\nUser: {second_query}") second_result = await agent.run(second_query) print(f"Agent: {second_result.text}") - print("Note: Each call creates a separate thread, so the agent doesn't remember previous context.\n") + print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") -async def example_with_thread_persistence() -> None: - """Example showing thread persistence across multiple conversations.""" - print("=== Thread Persistence Example ===") - print("Using the same thread across multiple conversations to maintain context.\n") +async def example_with_session_persistence() -> None: + """Example showing session persistence across multiple conversations.""" + print("=== Session Persistence Example ===") + print("Using the same session across multiple conversations to maintain context.\n") # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. @@ -74,36 +76,36 @@ async def example_with_thread_persistence() -> None: tools=get_weather, ) - # Create a new thread that will be reused - thread = agent.get_new_thread() + # Create a new session that will be reused + session = agent.create_session() # First conversation first_query = "What's the weather like in Tokyo?" print(f"User: {first_query}") - first_result = await agent.run(first_query, thread=thread) + first_result = await agent.run(first_query, session=session) print(f"Agent: {first_result.text}") - # Second conversation using the same thread - maintains context + # Second conversation using the same session - maintains context second_query = "How about London?" print(f"\nUser: {second_query}") - second_result = await agent.run(second_query, thread=thread) + second_result = await agent.run(second_query, session=session) print(f"Agent: {second_result.text}") # Third conversation - agent should remember both previous cities third_query = "Which of the cities I asked about has better weather?" print(f"\nUser: {third_query}") - third_result = await agent.run(third_query, thread=thread) + third_result = await agent.run(third_query, session=session) print(f"Agent: {third_result.text}") - print("Note: The agent remembers context from previous messages in the same thread.\n") + print("Note: The agent remembers context from previous messages in the same session.\n") -async def example_with_existing_thread_id() -> None: - """Example showing how to work with an existing thread ID from the service.""" - print("=== Existing Thread ID Example ===") - print("Using a specific thread ID to continue an existing conversation.\n") +async def example_with_existing_session_id() -> None: + """Example showing how to work with an existing session ID from the service.""" + print("=== Existing Session ID Example ===") + print("Using a specific session ID to continue an existing conversation.\n") - # First, create a conversation and capture the thread ID - existing_thread_id = None + # First, create a conversation and capture the session ID + existing_session_id = None # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. @@ -117,21 +119,21 @@ async def example_with_existing_thread_id() -> None: tools=get_weather, ) - # Start a conversation and get the thread ID - thread = agent.get_new_thread() + # Start a conversation and get the session ID + session = agent.create_session() first_query = "What's the weather in Paris?" print(f"User: {first_query}") - first_result = await agent.run(first_query, thread=thread) + first_result = await agent.run(first_query, session=session) print(f"Agent: {first_result.text}") - # The thread ID is set after the first response - existing_thread_id = thread.service_thread_id - print(f"Thread ID: {existing_thread_id}") + # The session ID is set after the first response + existing_session_id = session.service_session_id + print(f"Session ID: {existing_session_id}") - if existing_thread_id: - print("\n--- Continuing with the same thread ID in a new agent instance ---") + if existing_session_id: + print("\n--- Continuing with the same session ID in a new agent instance ---") - # Create a new provider and agent but use the existing thread ID + # Create a new provider and agent but use the existing session ID async with ( AzureCliCredential() as credential, AzureAIAgentsProvider(credential=credential) as provider, @@ -142,22 +144,22 @@ async def example_with_existing_thread_id() -> None: tools=get_weather, ) - # Create a thread with the existing ID - thread = AgentThread(service_thread_id=existing_thread_id) + # Create a session with the existing ID + session = AgentSession(service_session_id=existing_session_id) second_query = "What was the last city I asked about?" print(f"User: {second_query}") - second_result = await agent.run(second_query, thread=thread) + second_result = await agent.run(second_query, session=session) print(f"Agent: {second_result.text}") - print("Note: The agent continues the conversation from the previous thread.\n") + print("Note: The agent continues the conversation from the previous session.\n") async def main() -> None: - print("=== Azure AI Chat Client Agent Thread Management Examples ===\n") + print("=== Azure AI Chat Client Agent Session Management Examples ===\n") - await example_with_automatic_thread_creation() - await example_with_thread_persistence() - await example_with_existing_thread_id() + await example_with_automatic_session_creation() + await example_with_session_persistence() + await example_with_existing_session_id() if __name__ == "__main__": diff --git a/python/samples/getting_started/agents/azure_openai/README.md b/python/samples/02-agents/providers/azure_openai/README.md similarity index 60% rename from python/samples/getting_started/agents/azure_openai/README.md rename to python/samples/02-agents/providers/azure_openai/README.md index 466860de3e..6971183ccf 100644 --- a/python/samples/getting_started/agents/azure_openai/README.md +++ b/python/samples/02-agents/providers/azure_openai/README.md @@ -6,25 +6,27 @@ This folder contains examples demonstrating different ways to create and use age | File | Description | |------|-------------| -| [`azure_assistants_basic.py`](azure_assistants_basic.py) | The simplest way to create an agent using `ChatAgent` with `AzureOpenAIAssistantsClient`. Shows both streaming and non-streaming responses with automatic assistant creation and cleanup. | -| [`azure_assistants_with_code_interpreter.py`](azure_assistants_with_code_interpreter.py) | Shows how to use the HostedCodeInterpreterTool with Azure agents to write and execute Python code. Includes helper methods for accessing code interpreter data from response chunks. | +| [`azure_assistants_basic.py`](azure_assistants_basic.py) | The simplest way to create an agent using `Agent` with `AzureOpenAIAssistantsClient`. Shows both streaming and non-streaming responses with automatic assistant creation and cleanup. | +| [`azure_assistants_with_code_interpreter.py`](azure_assistants_with_code_interpreter.py) | Shows how to use `AzureOpenAIAssistantsClient.get_code_interpreter_tool()` with Azure agents to write and execute Python code. Includes helper methods for accessing code interpreter data from response chunks. | | [`azure_assistants_with_existing_assistant.py`](azure_assistants_with_existing_assistant.py) | Shows how to work with a pre-existing assistant by providing the assistant ID to the Azure Assistants client. Demonstrates proper cleanup of manually created assistants. | | [`azure_assistants_with_explicit_settings.py`](azure_assistants_with_explicit_settings.py) | Shows how to initialize an agent with a specific assistants client, configuring settings explicitly including endpoint and deployment name. | | [`azure_assistants_with_function_tools.py`](azure_assistants_with_function_tools.py) | Demonstrates how to use function tools with agents. Shows both agent-level tools (defined when creating the agent) and query-level tools (provided with specific queries). | -| [`azure_assistants_with_thread.py`](azure_assistants_with_thread.py) | Demonstrates thread management with Azure agents, including automatic thread creation for stateless conversations and explicit thread management for maintaining conversation context across multiple interactions. | -| [`azure_chat_client_basic.py`](azure_chat_client_basic.py) | The simplest way to create an agent using `ChatAgent` with `AzureOpenAIChatClient`. Shows both streaming and non-streaming responses for chat-based interactions with Azure OpenAI models. | +| [`azure_assistants_with_session.py`](azure_assistants_with_session.py) | Demonstrates session management with Azure agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | +| [`azure_chat_client_basic.py`](azure_chat_client_basic.py) | The simplest way to create an agent using `Agent` with `AzureOpenAIChatClient`. Shows both streaming and non-streaming responses for chat-based interactions with Azure OpenAI models. | | [`azure_chat_client_with_explicit_settings.py`](azure_chat_client_with_explicit_settings.py) | Shows how to initialize an agent with a specific chat client, configuring settings explicitly including endpoint and deployment name. | | [`azure_chat_client_with_function_tools.py`](azure_chat_client_with_function_tools.py) | Demonstrates how to use function tools with agents. Shows both agent-level tools (defined when creating the agent) and query-level tools (provided with specific queries). | -| [`azure_chat_client_with_thread.py`](azure_chat_client_with_thread.py) | Demonstrates thread management with Azure agents, including automatic thread creation for stateless conversations and explicit thread management for maintaining conversation context across multiple interactions. | -| [`azure_responses_client_basic.py`](azure_responses_client_basic.py) | The simplest way to create an agent using `ChatAgent` with `AzureOpenAIResponsesClient`. Shows both streaming and non-streaming responses for structured response generation with Azure OpenAI models. | -| [`azure_responses_client_code_interpreter_files.py`](azure_responses_client_code_interpreter_files.py) | Demonstrates using HostedCodeInterpreterTool with file uploads for data analysis. Shows how to create, upload, and analyze CSV files using Python code execution with Azure OpenAI Responses. | +| [`azure_chat_client_with_session.py`](azure_chat_client_with_session.py) | Demonstrates session management with Azure agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | +| [`azure_responses_client_basic.py`](azure_responses_client_basic.py) | The simplest way to create an agent using `Agent` with `AzureOpenAIResponsesClient`. Shows both streaming and non-streaming responses for structured response generation with Azure OpenAI models. | +| [`azure_responses_client_code_interpreter_files.py`](azure_responses_client_code_interpreter_files.py) | Demonstrates using `AzureOpenAIResponsesClient.get_code_interpreter_tool()` with file uploads for data analysis. Shows how to create, upload, and analyze CSV files using Python code execution with Azure OpenAI Responses. | | [`azure_responses_client_image_analysis.py`](azure_responses_client_image_analysis.py) | Shows how to use Azure OpenAI Responses for image analysis and vision tasks. Demonstrates multi-modal messages combining text and image content using remote URLs. | -| [`azure_responses_client_with_code_interpreter.py`](azure_responses_client_with_code_interpreter.py) | Shows how to use the HostedCodeInterpreterTool with Azure agents to write and execute Python code. Includes helper methods for accessing code interpreter data from response chunks. | +| [`azure_responses_client_with_code_interpreter.py`](azure_responses_client_with_code_interpreter.py) | Shows how to use `AzureOpenAIResponsesClient.get_code_interpreter_tool()` with Azure agents to write and execute Python code. Includes helper methods for accessing code interpreter data from response chunks. | | [`azure_responses_client_with_explicit_settings.py`](azure_responses_client_with_explicit_settings.py) | Shows how to initialize an agent with a specific responses client, configuring settings explicitly including endpoint and deployment name. | -| [`azure_responses_client_with_file_search.py`](azure_responses_client_with_file_search.py) | Demonstrates using HostedFileSearchTool with Azure OpenAI Responses Client for direct document-based question answering and information retrieval from vector stores. | +| [`azure_responses_client_with_file_search.py`](azure_responses_client_with_file_search.py) | Demonstrates using `AzureOpenAIResponsesClient.get_file_search_tool()` with Azure OpenAI Responses Client for direct document-based question answering and information retrieval from vector stores. | +| [`azure_responses_client_with_foundry.py`](azure_responses_client_with_foundry.py) | Shows how to create an agent using an Azure AI Foundry project endpoint instead of a direct Azure OpenAI endpoint. Requires the `azure-ai-projects` package. | | [`azure_responses_client_with_function_tools.py`](azure_responses_client_with_function_tools.py) | Demonstrates how to use function tools with agents. Shows both agent-level tools (defined when creating the agent) and query-level tools (provided with specific queries). | +| [`azure_responses_client_with_hosted_mcp.py`](azure_responses_client_with_hosted_mcp.py) | Shows how to integrate Azure OpenAI Responses Client with hosted Model Context Protocol (MCP) servers using `AzureOpenAIResponsesClient.get_mcp_tool()` for extended functionality. | | [`azure_responses_client_with_local_mcp.py`](azure_responses_client_with_local_mcp.py) | Shows how to integrate Azure OpenAI Responses Client with local Model Context Protocol (MCP) servers using MCPStreamableHTTPTool for extended functionality. | -| [`azure_responses_client_with_thread.py`](azure_responses_client_with_thread.py) | Demonstrates thread management with Azure agents, including automatic thread creation for stateless conversations and explicit thread management for maintaining conversation context across multiple interactions. | +| [`azure_responses_client_with_session.py`](azure_responses_client_with_session.py) | Demonstrates session management with Azure agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | ## Environment Variables @@ -34,6 +36,9 @@ Make sure to set the following environment variables before running the examples - `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`: The name of your Azure OpenAI chat model deployment - `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your Azure OpenAI Responses deployment +For the Foundry project sample (`azure_responses_client_with_foundry.py`), also set: +- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint + Optionally, you can set: - `AZURE_OPENAI_API_VERSION`: The API version to use (default is `2024-02-15-preview`) - `AZURE_OPENAI_API_KEY`: Your Azure OpenAI API key (if not using `AzureCliCredential`) diff --git a/python/samples/getting_started/agents/azure_openai/azure_assistants_basic.py b/python/samples/02-agents/providers/azure_openai/azure_assistants_basic.py similarity index 93% rename from python/samples/getting_started/agents/azure_openai/azure_assistants_basic.py rename to python/samples/02-agents/providers/azure_openai/azure_assistants_basic.py index 2bc74ef83c..71fbdcbe9d 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_assistants_basic.py +++ b/python/samples/02-agents/providers/azure_openai/azure_assistants_basic.py @@ -17,7 +17,7 @@ assistant lifecycle management, showing both streaming and non-streaming respons """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/azure_openai/azure_assistants_with_code_interpreter.py b/python/samples/02-agents/providers/azure_openai/azure_assistants_with_code_interpreter.py similarity index 82% rename from python/samples/getting_started/agents/azure_openai/azure_assistants_with_code_interpreter.py rename to python/samples/02-agents/providers/azure_openai/azure_assistants_with_code_interpreter.py index 3445bbcbc0..7a0eb2645d 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_assistants_with_code_interpreter.py +++ b/python/samples/02-agents/providers/azure_openai/azure_assistants_with_code_interpreter.py @@ -2,9 +2,8 @@ import asyncio -from agent_framework import AgentResponseUpdate, ChatAgent, ChatResponseUpdate, HostedCodeInterpreterTool +from agent_framework import Agent, AgentResponseUpdate, ChatResponseUpdate from agent_framework.azure import AzureOpenAIAssistantsClient -from azure.identity import AzureCliCredential from openai.types.beta.threads.runs import ( CodeInterpreterToolCallDelta, RunStepDelta, @@ -16,7 +15,7 @@ from openai.types.beta.threads.runs.code_interpreter_tool_call_delta import Code """ Azure OpenAI Assistants with Code Interpreter Example -This sample demonstrates using HostedCodeInterpreterTool with Azure OpenAI Assistants +This sample demonstrates using get_code_interpreter_tool() with Azure OpenAI Assistants for Python code execution and mathematical problem solving. """ @@ -41,15 +40,19 @@ def get_code_interpreter_chunk(chunk: AgentResponseUpdate) -> str | None: async def main() -> None: - """Example showing how to use the HostedCodeInterpreterTool with Azure OpenAI Assistants.""" + """Example showing how to use the code interpreter tool with Azure OpenAI Assistants.""" print("=== Azure OpenAI Assistants Agent with Code Interpreter Example ===") + # Create code interpreter tool using static method + client = AzureOpenAIAssistantsClient() + code_interpreter_tool = client.get_code_interpreter_tool() + # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - async with ChatAgent( - chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), + async with Agent( + client=client, instructions="You are a helpful assistant that can write and execute Python code to solve problems.", - tools=HostedCodeInterpreterTool(), + tools=[code_interpreter_tool], ) as agent: query = "What is current datetime?" print(f"User: {query}") diff --git a/python/samples/getting_started/agents/azure_openai/azure_assistants_with_existing_assistant.py b/python/samples/02-agents/providers/azure_openai/azure_assistants_with_existing_assistant.py similarity index 84% rename from python/samples/getting_started/agents/azure_openai/azure_assistants_with_existing_assistant.py rename to python/samples/02-agents/providers/azure_openai/azure_assistants_with_existing_assistant.py index 7e373d4fad..195e9fc26e 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_assistants_with_existing_assistant.py +++ b/python/samples/02-agents/providers/azure_openai/azure_assistants_with_existing_assistant.py @@ -5,7 +5,7 @@ import os from random import randint from typing import Annotated -from agent_framework import ChatAgent, tool +from agent_framework import Agent, tool from agent_framework.azure import AzureOpenAIAssistantsClient from azure.identity import AzureCliCredential, get_bearer_token_provider from openai import AsyncAzureOpenAI @@ -19,7 +19,9 @@ using existing assistant IDs rather than creating new ones. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -46,8 +48,8 @@ async def main() -> None: ) try: - async with ChatAgent( - chat_client=AzureOpenAIAssistantsClient(async_client=client, assistant_id=created_assistant.id), + async with Agent( + client=AzureOpenAIAssistantsClient(async_client=client, assistant_id=created_assistant.id), instructions="You are a helpful weather agent.", tools=get_weather, ) as agent: diff --git a/python/samples/getting_started/agents/azure_openai/azure_assistants_with_explicit_settings.py b/python/samples/02-agents/providers/azure_openai/azure_assistants_with_explicit_settings.py similarity index 90% rename from python/samples/getting_started/agents/azure_openai/azure_assistants_with_explicit_settings.py rename to python/samples/02-agents/providers/azure_openai/azure_assistants_with_explicit_settings.py index 65b0214ab8..c9c4cee118 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_assistants_with_explicit_settings.py +++ b/python/samples/02-agents/providers/azure_openai/azure_assistants_with_explicit_settings.py @@ -18,7 +18,9 @@ settings rather than relying on environment variable defaults. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/azure_openai/azure_assistants_with_function_tools.py b/python/samples/02-agents/providers/azure_openai/azure_assistants_with_function_tools.py similarity index 90% rename from python/samples/getting_started/agents/azure_openai/azure_assistants_with_function_tools.py rename to python/samples/02-agents/providers/azure_openai/azure_assistants_with_function_tools.py index 8333e7fdc8..913332e953 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_assistants_with_function_tools.py +++ b/python/samples/02-agents/providers/azure_openai/azure_assistants_with_function_tools.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from random import randint from typing import Annotated -from agent_framework import ChatAgent, tool +from agent_framework import Agent, tool from agent_framework.azure import AzureOpenAIAssistantsClient from azure.identity import AzureCliCredential from pydantic import Field @@ -18,7 +18,9 @@ showing both agent-level and query-level tool configuration patterns. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -43,8 +45,8 @@ async def tools_on_agent_level() -> None: # The agent can use these tools for any query during its lifetime # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - async with ChatAgent( - chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), + async with Agent( + client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), instructions="You are a helpful assistant that can provide weather and time information.", tools=[get_weather, get_time], # Tools defined at agent creation ) as agent: @@ -74,8 +76,8 @@ async def tools_on_run_level() -> None: # Agent created without tools # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - async with ChatAgent( - chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), + async with Agent( + client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), instructions="You are a helpful assistant.", # No tools defined here ) as agent: @@ -105,8 +107,8 @@ async def mixed_tools_example() -> None: # Agent created with some base tools # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - async with ChatAgent( - chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), + async with Agent( + client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), instructions="You are a comprehensive assistant that can help with various information requests.", tools=[get_weather], # Base tool available for all queries ) as agent: diff --git a/python/samples/02-agents/providers/azure_openai/azure_assistants_with_session.py b/python/samples/02-agents/providers/azure_openai/azure_assistants_with_session.py new file mode 100644 index 0000000000..9c4bd6e235 --- /dev/null +++ b/python/samples/02-agents/providers/azure_openai/azure_assistants_with_session.py @@ -0,0 +1,146 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from random import randint +from typing import Annotated + +from agent_framework import Agent, AgentSession, tool +from agent_framework.azure import AzureOpenAIAssistantsClient +from azure.identity import AzureCliCredential +from pydantic import Field + +""" +Azure OpenAI Assistants with Session Management Example + +This sample demonstrates session management with Azure OpenAI Assistants, comparing +automatic session creation with explicit session management for persistent context. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def example_with_automatic_session_creation() -> None: + """Example showing automatic session creation (service-managed session).""" + print("=== Automatic Session Creation Example ===") + + # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred + # authentication option. + async with Agent( + client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) as agent: + # First conversation - no session provided, will be created automatically + query1 = "What's the weather like in Seattle?" + print(f"User: {query1}") + result1 = await agent.run(query1) + print(f"Agent: {result1.text}") + + # Second conversation - still no session provided, will create another new session + query2 = "What was the last city I asked about?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2) + print(f"Agent: {result2.text}") + print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") + + +async def example_with_session_persistence() -> None: + """Example showing session persistence across multiple conversations.""" + print("=== Session Persistence Example ===") + print("Using the same session across multiple conversations to maintain context.\n") + + # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred + # authentication option. + async with Agent( + client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) as agent: + # Create a new session that will be reused + session = agent.create_session() + + # First conversation + query1 = "What's the weather like in Tokyo?" + print(f"User: {query1}") + result1 = await agent.run(query1, session=session) + print(f"Agent: {result1.text}") + + # Second conversation using the same session - maintains context + query2 = "How about London?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2, session=session) + print(f"Agent: {result2.text}") + + # Third conversation - agent should remember both previous cities + query3 = "Which of the cities I asked about has better weather?" + print(f"\nUser: {query3}") + result3 = await agent.run(query3, session=session) + print(f"Agent: {result3.text}") + print("Note: The agent remembers context from previous messages in the same session.\n") + + +async def example_with_existing_session_id() -> None: + """Example showing how to work with an existing session ID from the service.""" + print("=== Existing Session ID Example ===") + print("Using a specific session ID to continue an existing conversation.\n") + + # First, create a conversation and capture the session ID + existing_session_id = None + + # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred + # authentication option. + async with Agent( + client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) as agent: + # Start a conversation and get the session ID + session = agent.create_session() + query1 = "What's the weather in Paris?" + print(f"User: {query1}") + result1 = await agent.run(query1, session=session) + print(f"Agent: {result1.text}") + + # The session ID is set after the first response + existing_session_id = session.service_session_id + print(f"Session ID: {existing_session_id}") + + if existing_session_id: + print("\n--- Continuing with the same session ID in a new agent instance ---") + + # Create a new agent instance but use the existing session ID + async with Agent( + client=AzureOpenAIAssistantsClient(thread_id=existing_session_id, credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) as agent: + # Create a session with the existing ID + session = AgentSession(service_session_id=existing_session_id) + + query2 = "What was the last city I asked about?" + print(f"User: {query2}") + result2 = await agent.run(query2, session=session) + print(f"Agent: {result2.text}") + print("Note: The agent continues the conversation from the previous session.\n") + + +async def main() -> None: + print("=== Azure OpenAI Assistants Chat Client Agent Session Management Examples ===\n") + + await example_with_automatic_session_creation() + await example_with_session_persistence() + await example_with_existing_session_id() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/agents/azure_openai/azure_chat_client_basic.py b/python/samples/02-agents/providers/azure_openai/azure_chat_client_basic.py similarity index 93% rename from python/samples/getting_started/agents/azure_openai/azure_chat_client_basic.py rename to python/samples/02-agents/providers/azure_openai/azure_chat_client_basic.py index e1e9fab2f5..c55e8682aa 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_chat_client_basic.py +++ b/python/samples/02-agents/providers/azure_openai/azure_chat_client_basic.py @@ -17,7 +17,9 @@ interactions, showing both streaming and non-streaming responses. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/azure_openai/azure_chat_client_with_explicit_settings.py b/python/samples/02-agents/providers/azure_openai/azure_chat_client_with_explicit_settings.py similarity index 90% rename from python/samples/getting_started/agents/azure_openai/azure_chat_client_with_explicit_settings.py rename to python/samples/02-agents/providers/azure_openai/azure_chat_client_with_explicit_settings.py index 5f7bc794e5..ac0a1af782 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_chat_client_with_explicit_settings.py +++ b/python/samples/02-agents/providers/azure_openai/azure_chat_client_with_explicit_settings.py @@ -18,7 +18,9 @@ settings rather than relying on environment variable defaults. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/azure_openai/azure_chat_client_with_function_tools.py b/python/samples/02-agents/providers/azure_openai/azure_chat_client_with_function_tools.py similarity index 90% rename from python/samples/getting_started/agents/azure_openai/azure_chat_client_with_function_tools.py rename to python/samples/02-agents/providers/azure_openai/azure_chat_client_with_function_tools.py index 777bcc51b1..4b42ebecef 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_chat_client_with_function_tools.py +++ b/python/samples/02-agents/providers/azure_openai/azure_chat_client_with_function_tools.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from random import randint from typing import Annotated -from agent_framework import ChatAgent, tool +from agent_framework import Agent, tool from agent_framework.azure import AzureOpenAIChatClient from azure.identity import AzureCliCredential from pydantic import Field @@ -18,7 +18,9 @@ showing both agent-level and query-level tool configuration patterns. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -43,8 +45,8 @@ async def tools_on_agent_level() -> None: # The agent can use these tools for any query during its lifetime # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - agent = ChatAgent( - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), + agent = Agent( + client=AzureOpenAIChatClient(credential=AzureCliCredential()), instructions="You are a helpful assistant that can provide weather and time information.", tools=[get_weather, get_time], # Tools defined at agent creation ) @@ -75,8 +77,8 @@ async def tools_on_run_level() -> None: # Agent created without tools # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - agent = ChatAgent( - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), + agent = Agent( + client=AzureOpenAIChatClient(credential=AzureCliCredential()), instructions="You are a helpful assistant.", # No tools defined here ) @@ -107,8 +109,8 @@ async def mixed_tools_example() -> None: # Agent created with some base tools # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - agent = ChatAgent( - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), + agent = Agent( + client=AzureOpenAIChatClient(credential=AzureCliCredential()), instructions="You are a comprehensive assistant that can help with various information requests.", tools=[get_weather], # Base tool available for all queries ) diff --git a/python/samples/02-agents/providers/azure_openai/azure_chat_client_with_session.py b/python/samples/02-agents/providers/azure_openai/azure_chat_client_with_session.py new file mode 100644 index 0000000000..c5993431fb --- /dev/null +++ b/python/samples/02-agents/providers/azure_openai/azure_chat_client_with_session.py @@ -0,0 +1,157 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from random import randint +from typing import Annotated + +from agent_framework import Agent, AgentSession, tool +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential +from pydantic import Field + +""" +Azure OpenAI Chat Client with Session Management Example + +This sample demonstrates session management with Azure OpenAI Chat Client, comparing +automatic session creation with explicit session management for persistent context. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def example_with_automatic_session_creation() -> None: + """Example showing automatic session creation (service-managed session).""" + print("=== Automatic Session Creation Example ===") + + # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred + # authentication option. + agent = Agent( + client=AzureOpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # First conversation - no session provided, will be created automatically + query1 = "What's the weather like in Seattle?" + print(f"User: {query1}") + result1 = await agent.run(query1) + print(f"Agent: {result1.text}") + + # Second conversation - still no session provided, will create another new session + query2 = "What was the last city I asked about?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2) + print(f"Agent: {result2.text}") + print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") + + +async def example_with_session_persistence() -> None: + """Example showing session persistence across multiple conversations.""" + print("=== Session Persistence Example ===") + print("Using the same session across multiple conversations to maintain context.\n") + + # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred + # authentication option. + agent = Agent( + client=AzureOpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Create a new session that will be reused + session = agent.create_session() + + # First conversation + query1 = "What's the weather like in Tokyo?" + print(f"User: {query1}") + result1 = await agent.run(query1, session=session) + print(f"Agent: {result1.text}") + + # Second conversation using the same session - maintains context + query2 = "How about London?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2, session=session) + print(f"Agent: {result2.text}") + + # Third conversation - agent should remember both previous cities + query3 = "Which of the cities I asked about has better weather?" + print(f"\nUser: {query3}") + result3 = await agent.run(query3, session=session) + print(f"Agent: {result3.text}") + print("Note: The agent remembers context from previous messages in the same session.\n") + + +async def example_with_existing_session_messages() -> None: + """Example showing how to work with existing session messages for Azure.""" + print("=== Existing Session Messages Example ===") + + # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred + # authentication option. + agent = Agent( + client=AzureOpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Start a conversation and build up message history + session = agent.create_session() + + query1 = "What's the weather in Paris?" + print(f"User: {query1}") + result1 = await agent.run(query1, session=session) + print(f"Agent: {result1.text}") + + # The session now contains the conversation history in state + memory_state = session.state.get("memory", {}) + messages = memory_state.get("messages", []) + if messages: + print(f"Session contains {len(messages)} messages") + + print("\n--- Continuing with the same session in a new agent instance ---") + + # Create a new agent instance but use the existing session with its message history + new_agent = Agent( + client=AzureOpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Use the same session object which contains the conversation history + query2 = "What was the last city I asked about?" + print(f"User: {query2}") + result2 = await new_agent.run(query2, session=session) + print(f"Agent: {result2.text}") + print("Note: The agent continues the conversation using the local message history.\n") + + print("\n--- Alternative: Creating a new session from existing messages ---") + + # You can also create a new session from existing messages + new_session = AgentSession() + + query3 = "How does the Paris weather compare to London?" + print(f"User: {query3}") + result3 = await new_agent.run(query3, session=new_session) + print(f"Agent: {result3.text}") + print("Note: This creates a new session with the same conversation history.\n") + + +async def main() -> None: + print("=== Azure Chat Client Agent Session Management Examples ===\n") + + await example_with_automatic_session_creation() + await example_with_session_persistence() + await example_with_existing_session_messages() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/agents/azure_openai/azure_responses_client_basic.py b/python/samples/02-agents/providers/azure_openai/azure_responses_client_basic.py similarity index 93% rename from python/samples/getting_started/agents/azure_openai/azure_responses_client_basic.py rename to python/samples/02-agents/providers/azure_openai/azure_responses_client_basic.py index de20e03c4a..c638426e4d 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_responses_client_basic.py +++ b/python/samples/02-agents/providers/azure_openai/azure_responses_client_basic.py @@ -17,7 +17,9 @@ response generation, showing both streaming and non-streaming responses. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/azure_openai/azure_responses_client_code_interpreter_files.py b/python/samples/02-agents/providers/azure_openai/azure_responses_client_code_interpreter_files.py similarity index 87% rename from python/samples/getting_started/agents/azure_openai/azure_responses_client_code_interpreter_files.py rename to python/samples/02-agents/providers/azure_openai/azure_responses_client_code_interpreter_files.py index 187e354264..33154a7c47 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_responses_client_code_interpreter_files.py +++ b/python/samples/02-agents/providers/azure_openai/azure_responses_client_code_interpreter_files.py @@ -4,7 +4,7 @@ import asyncio import os import tempfile -from agent_framework import ChatAgent, HostedCodeInterpreterTool +from agent_framework import Agent from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential from openai import AsyncAzureOpenAI @@ -12,7 +12,7 @@ from openai import AsyncAzureOpenAI """ Azure OpenAI Responses Client with Code Interpreter and Files Example -This sample demonstrates using HostedCodeInterpreterTool with Azure OpenAI Responses +This sample demonstrates using get_code_interpreter_tool() with Azure OpenAI Responses for Python code execution and data analysis with uploaded files. """ @@ -76,10 +76,15 @@ async def main() -> None: temp_file_path, file_id = await create_sample_file_and_upload(openai_client) # Create agent using Azure OpenAI Responses client - agent = ChatAgent( - chat_client=AzureOpenAIResponsesClient(credential=credential), + client = AzureOpenAIResponsesClient(credential=credential) + + # Create code interpreter tool with file access + code_interpreter_tool = client.get_code_interpreter_tool(file_ids=[file_id]) + + agent = Agent( + client=client, instructions="You are a helpful assistant that can analyze data files using Python code.", - tools=HostedCodeInterpreterTool(inputs=[{"file_id": file_id}]), + tools=[code_interpreter_tool], ) # Test the code interpreter with the uploaded file diff --git a/python/samples/getting_started/agents/azure_openai/azure_responses_client_image_analysis.py b/python/samples/02-agents/providers/azure_openai/azure_responses_client_image_analysis.py similarity index 78% rename from python/samples/getting_started/agents/azure_openai/azure_responses_client_image_analysis.py rename to python/samples/02-agents/providers/azure_openai/azure_responses_client_image_analysis.py index 9bf05e32e0..e9bedfd474 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_responses_client_image_analysis.py +++ b/python/samples/02-agents/providers/azure_openai/azure_responses_client_image_analysis.py @@ -2,7 +2,7 @@ import asyncio -from agent_framework import ChatMessage, Content +from agent_framework import Content, Message from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential @@ -24,12 +24,12 @@ async def main(): ) # 2. Create a simple message with both text and image content - user_message = ChatMessage( + user_message = Message( role="user", contents=[ - Content.from_text(text="What do you see in this image?"), + Content.from_text("What do you see in this image?"), Content.from_uri( - uri="https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", + uri="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800", media_type="image/jpeg", ), ], diff --git a/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_code_interpreter.py b/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_code_interpreter.py similarity index 75% rename from python/samples/getting_started/agents/azure_openai/azure_responses_client_with_code_interpreter.py rename to python/samples/02-agents/providers/azure_openai/azure_responses_client_with_code_interpreter.py index 70c8fb832f..544e4c49e6 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_code_interpreter.py +++ b/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_code_interpreter.py @@ -2,7 +2,7 @@ import asyncio -from agent_framework import ChatAgent, ChatResponse, HostedCodeInterpreterTool +from agent_framework import Agent, ChatResponse from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential from openai.types.responses.response import Response as OpenAIResponse @@ -11,21 +11,26 @@ from openai.types.responses.response_code_interpreter_tool_call import ResponseC """ Azure OpenAI Responses Client with Code Interpreter Example -This sample demonstrates using HostedCodeInterpreterTool with Azure OpenAI Responses +This sample demonstrates using get_code_interpreter_tool() with Azure OpenAI Responses for Python code execution and mathematical problem solving. """ async def main() -> None: - """Example showing how to use the HostedCodeInterpreterTool with Azure OpenAI Responses.""" + """Example showing how to use the code interpreter tool with Azure OpenAI Responses.""" print("=== Azure OpenAI Responses Agent with Code Interpreter Example ===") # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - agent = ChatAgent( - chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) + + # Create code interpreter tool using instance method + code_interpreter_tool = client.get_code_interpreter_tool() + + agent = Agent( + client=client, instructions="You are a helpful assistant that can write and execute Python code to solve problems.", - tools=HostedCodeInterpreterTool(), + tools=[code_interpreter_tool], ) query = "Use code to calculate the factorial of 100?" diff --git a/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_explicit_settings.py b/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_explicit_settings.py similarity index 90% rename from python/samples/getting_started/agents/azure_openai/azure_responses_client_with_explicit_settings.py rename to python/samples/02-agents/providers/azure_openai/azure_responses_client_with_explicit_settings.py index c21462b11f..57498b9e1f 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_explicit_settings.py +++ b/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_explicit_settings.py @@ -18,7 +18,9 @@ settings rather than relying on environment variable defaults. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_file_search.py b/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_file_search.py similarity index 82% rename from python/samples/getting_started/agents/azure_openai/azure_responses_client_with_file_search.py rename to python/samples/02-agents/providers/azure_openai/azure_responses_client_with_file_search.py index 08f35eb659..432cede701 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_file_search.py +++ b/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_file_search.py @@ -2,14 +2,14 @@ import asyncio -from agent_framework import ChatAgent, Content, HostedFileSearchTool +from agent_framework import Agent, Content from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential """ Azure OpenAI Responses Client with File Search Example -This sample demonstrates using HostedFileSearchTool with Azure OpenAI Responses Client +This sample demonstrates using get_file_search_tool() with Azure OpenAI Responses Client for direct document-based question answering and information retrieval. Prerequisites: @@ -51,12 +51,15 @@ async def main() -> None: # Make sure you're logged in via 'az login' before running this sample client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - file_id, vector_store = await create_vector_store(client) + file_id, vector_store_id = await create_vector_store(client) - agent = ChatAgent( - chat_client=client, + # Create file search tool using instance method + file_search_tool = client.get_file_search_tool(vector_store_ids=[vector_store_id]) + + agent = Agent( + client=client, instructions="You are a helpful assistant that can search through files to find information.", - tools=[HostedFileSearchTool(inputs=vector_store)], + tools=[file_search_tool], ) query = "What is the weather today? Do a file search to find the answer." @@ -64,7 +67,7 @@ async def main() -> None: result = await agent.run(query) print(f"Agent: {result}\n") - await delete_vector_store(client, file_id, vector_store.vector_store_id) + await delete_vector_store(client, file_id, vector_store_id) if __name__ == "__main__": diff --git a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_foundry.py b/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_foundry.py new file mode 100644 index 0000000000..36b6572427 --- /dev/null +++ b/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_foundry.py @@ -0,0 +1,113 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from random import randint +from typing import Annotated + +from agent_framework import tool +from agent_framework.azure import AzureOpenAIResponsesClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field + +""" +Azure OpenAI Responses Client with Foundry Project Example + +This sample demonstrates how to create an AzureOpenAIResponsesClient using an +Azure AI Foundry project endpoint. Instead of providing an Azure OpenAI endpoint +directly, you provide a Foundry project endpoint and the client is created via +the Azure AI Foundry project SDK. + +This requires: +- The `azure-ai-projects` package to be installed. +- The `AZURE_AI_PROJECT_ENDPOINT` environment variable set to your Foundry project endpoint. +- The `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` environment variable set to the model deployment name. +""" + +load_dotenv() # Load environment variables from .env file if present + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def non_streaming_example() -> None: + """Example of non-streaming response (get the complete result at once).""" + print("=== Non-streaming Response Example ===") + + # 1. Create the AzureOpenAIResponsesClient using a Foundry project endpoint. + # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred + # authentication option. + credential = AzureCliCredential() + agent = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + credential=credential, + ).as_agent( + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # 2. Run a query and print the result. + query = "What's the weather like in Seattle?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Result: {result}\n") + + +async def streaming_example() -> None: + """Example of streaming response (get results as they are generated).""" + print("=== Streaming Response Example ===") + + # 1. Create the AzureOpenAIResponsesClient using a Foundry project endpoint. + # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred + # authentication option. + credential = AzureCliCredential() + agent = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + credential=credential, + ).as_agent( + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # 2. Stream the response and print each chunk as it arrives. + query = "What's the weather like in Portland?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +async def main() -> None: + print("=== Azure OpenAI Responses Client with Foundry Project Example ===") + + await non_streaming_example() + await streaming_example() + + +if __name__ == "__main__": + asyncio.run(main()) + + +""" +Sample output: +=== Azure OpenAI Responses Client with Foundry Project Example === +=== Non-streaming Response Example === +User: What's the weather like in Seattle? +Result: The weather in Seattle is cloudy with a high of 18°C. + +=== Streaming Response Example === +User: What's the weather like in Portland? +Agent: The weather in Portland is sunny with a high of 25°C. +""" diff --git a/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_function_tools.py b/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_function_tools.py similarity index 89% rename from python/samples/getting_started/agents/azure_openai/azure_responses_client_with_function_tools.py rename to python/samples/02-agents/providers/azure_openai/azure_responses_client_with_function_tools.py index a5d6d85aa6..32fc56ed97 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_function_tools.py +++ b/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_function_tools.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from random import randint from typing import Annotated -from agent_framework import ChatAgent, tool +from agent_framework import Agent, tool from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential from pydantic import Field @@ -18,7 +18,9 @@ showing both agent-level and query-level tool configuration patterns. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -43,8 +45,8 @@ async def tools_on_agent_level() -> None: # The agent can use these tools for any query during its lifetime # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - agent = ChatAgent( - chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + agent = Agent( + client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), instructions="You are a helpful assistant that can provide weather and time information.", tools=[get_weather, get_time], # Tools defined at agent creation ) @@ -75,8 +77,8 @@ async def tools_on_run_level() -> None: # Agent created without tools # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - agent = ChatAgent( - chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + agent = Agent( + client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), instructions="You are a helpful assistant.", # No tools defined here ) @@ -107,8 +109,8 @@ async def mixed_tools_example() -> None: # Agent created with some base tools # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - agent = ChatAgent( - chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + agent = Agent( + client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), instructions="You are a comprehensive assistant that can help with various information requests.", tools=[get_weather], # Base tool available for all queries ) diff --git a/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_hosted_mcp.py b/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_hosted_mcp.py similarity index 53% rename from python/samples/getting_started/agents/azure_openai/azure_responses_client_with_hosted_mcp.py rename to python/samples/02-agents/providers/azure_openai/azure_responses_client_with_hosted_mcp.py index eddc54d48c..9de272c62a 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_hosted_mcp.py +++ b/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_hosted_mcp.py @@ -3,7 +3,7 @@ import asyncio from typing import TYPE_CHECKING, Any -from agent_framework import ChatAgent, HostedMCPTool +from agent_framework import Agent from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential @@ -15,12 +15,12 @@ Azure OpenAI Responses Client, including user approval workflows for function ca """ if TYPE_CHECKING: - from agent_framework import AgentThread, SupportsAgentRun + from agent_framework import AgentSession, SupportsAgentRun -async def handle_approvals_without_thread(query: str, agent: "SupportsAgentRun"): - """When we don't have a thread, we need to ensure we return with the input, approval request and approval.""" - from agent_framework import ChatMessage +async def handle_approvals_without_session(query: str, agent: "SupportsAgentRun"): + """When we don't have a session, we need to ensure we return with the input, approval request and approval.""" + from agent_framework import Message result = await agent.run(query) while len(result.user_input_requests) > 0: @@ -30,21 +30,24 @@ async def handle_approvals_without_thread(query: str, agent: "SupportsAgentRun") f"User Input Request for function from {agent.name}: {user_input_needed.function_call.name}" f" with arguments: {user_input_needed.function_call.arguments}" ) - new_inputs.append(ChatMessage(role="assistant", contents=[user_input_needed])) + new_inputs.append(Message(role="assistant", contents=[user_input_needed])) user_approval = input("Approve function call? (y/n): ") new_inputs.append( - ChatMessage(role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")]) + Message( + role="user", + contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")], + ) ) result = await agent.run(new_inputs) return result -async def handle_approvals_with_thread(query: str, agent: "SupportsAgentRun", thread: "AgentThread"): - """Here we let the thread deal with the previous responses, and we just rerun with the approval.""" - from agent_framework import ChatMessage +async def handle_approvals_with_session(query: str, agent: "SupportsAgentRun", session: "AgentSession"): + """Here we let the session deal with the previous responses, and we just rerun with the approval.""" + from agent_framework import Message - result = await agent.run(query, thread=thread, store=True) + result = await agent.run(query, session=session, store=True) while len(result.user_input_requests) > 0: new_input: list[Any] = [] for user_input_needed in result.user_input_requests: @@ -54,25 +57,25 @@ async def handle_approvals_with_thread(query: str, agent: "SupportsAgentRun", th ) user_approval = input("Approve function call? (y/n): ") new_input.append( - ChatMessage( + Message( role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")], ) ) - result = await agent.run(new_input, thread=thread, store=True) + result = await agent.run(new_input, session=session, store=True) return result -async def handle_approvals_with_thread_streaming(query: str, agent: "SupportsAgentRun", thread: "AgentThread"): - """Here we let the thread deal with the previous responses, and we just rerun with the approval.""" - from agent_framework import ChatMessage +async def handle_approvals_with_session_streaming(query: str, agent: "SupportsAgentRun", session: "AgentSession"): + """Here we let the session deal with the previous responses, and we just rerun with the approval.""" + from agent_framework import Message - new_input: list[ChatMessage] = [] + new_input: list[Message] = [] new_input_added = True while new_input_added: new_input_added = False - new_input.append(ChatMessage(role="user", text=query)) - async for update in agent.run(new_input, thread=thread, options={"store": True}, stream=True): + new_input.append(Message(role="user", text=query)) + async for update in agent.run(new_input, session=session, options={"store": True}, stream=True): if update.user_input_requests: for user_input_needed in update.user_input_requests: print( @@ -81,8 +84,9 @@ async def handle_approvals_with_thread_streaming(query: str, agent: "SupportsAge ) user_approval = input("Approve function call? (y/n): ") new_input.append( - ChatMessage( - role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")] + Message( + role="user", + contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")], ) ) new_input_added = True @@ -90,36 +94,39 @@ async def handle_approvals_with_thread_streaming(query: str, agent: "SupportsAge yield update -async def run_hosted_mcp_without_thread_and_specific_approval() -> None: - """Example showing Mcp Tools with approvals without using a thread.""" - print("=== Mcp with approvals and without thread ===") +async def run_hosted_mcp_without_session_and_specific_approval() -> None: + """Example showing Mcp Tools with approvals without using a session.""" + print("=== Mcp with approvals and without session ===") credential = AzureCliCredential() + client = AzureOpenAIResponsesClient(credential=credential) + + # Create MCP tool with specific approval settings + mcp_tool = client.get_mcp_tool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", + # we don't require approval for microsoft_docs_search tool calls + # but we do for any other tool + approval_mode={"never_require_approval": ["microsoft_docs_search"]}, + ) + # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime - async with ChatAgent( - chat_client=AzureOpenAIResponsesClient( - credential=credential, - ), + async with Agent( + client=client, name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=HostedMCPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - # we don't require approval for microsoft_docs_search tool calls - # but we do for any other tool - approval_mode={"never_require_approval": ["microsoft_docs_search"]}, - ), + tools=[mcp_tool], ) as agent: # First query query1 = "How to create an Azure storage account using az cli?" print(f"User: {query1}") - result1 = await handle_approvals_without_thread(query1, agent) + result1 = await handle_approvals_without_session(query1, agent) print(f"{agent.name}: {result1}\n") print("\n=======================================\n") # Second query query2 = "What is Microsoft Agent Framework?" print(f"User: {query2}") - result2 = await handle_approvals_without_thread(query2, agent) + result2 = await handle_approvals_without_session(query2, agent) print(f"{agent.name}: {result2}\n") @@ -127,94 +134,103 @@ async def run_hosted_mcp_without_approval() -> None: """Example showing Mcp Tools without approvals.""" print("=== Mcp without approvals ===") credential = AzureCliCredential() + client = AzureOpenAIResponsesClient(credential=credential) + + # Create MCP tool without approval requirements + mcp_tool = client.get_mcp_tool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", + # we don't require approval for any function calls + # this means we will not see the approval messages, + # it is fully handled by the service and a final response is returned. + approval_mode="never_require", + ) + # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime - async with ChatAgent( - chat_client=AzureOpenAIResponsesClient( - credential=credential, - ), + async with Agent( + client=client, name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=HostedMCPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - # we don't require approval for any function calls - # this means we will not see the approval messages, - # it is fully handled by the service and a final response is returned. - approval_mode="never_require", - ), + tools=[mcp_tool], ) as agent: # First query query1 = "How to create an Azure storage account using az cli?" print(f"User: {query1}") - result1 = await handle_approvals_without_thread(query1, agent) + result1 = await handle_approvals_without_session(query1, agent) print(f"{agent.name}: {result1}\n") print("\n=======================================\n") # Second query query2 = "What is Microsoft Agent Framework?" print(f"User: {query2}") - result2 = await handle_approvals_without_thread(query2, agent) + result2 = await handle_approvals_without_session(query2, agent) print(f"{agent.name}: {result2}\n") -async def run_hosted_mcp_with_thread() -> None: - """Example showing Mcp Tools with approvals using a thread.""" - print("=== Mcp with approvals and with thread ===") +async def run_hosted_mcp_with_session() -> None: + """Example showing Mcp Tools with approvals using a session.""" + print("=== Mcp with approvals and with session ===") credential = AzureCliCredential() + client = AzureOpenAIResponsesClient(credential=credential) + + # Create MCP tool with always require approval + mcp_tool = client.get_mcp_tool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", + # we require approval for all function calls + approval_mode="always_require", + ) + # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime - async with ChatAgent( - chat_client=AzureOpenAIResponsesClient( - credential=credential, - ), + async with Agent( + client=client, name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=HostedMCPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - # we require approval for all function calls - approval_mode="always_require", - ), + tools=[mcp_tool], ) as agent: # First query - thread = agent.get_new_thread() + session = agent.create_session() query1 = "How to create an Azure storage account using az cli?" print(f"User: {query1}") - result1 = await handle_approvals_with_thread(query1, agent, thread) + result1 = await handle_approvals_with_session(query1, agent, session) print(f"{agent.name}: {result1}\n") print("\n=======================================\n") # Second query query2 = "What is Microsoft Agent Framework?" print(f"User: {query2}") - result2 = await handle_approvals_with_thread(query2, agent, thread) + result2 = await handle_approvals_with_session(query2, agent, session) print(f"{agent.name}: {result2}\n") -async def run_hosted_mcp_with_thread_streaming() -> None: - """Example showing Mcp Tools with approvals using a thread.""" - print("=== Mcp with approvals and with thread ===") +async def run_hosted_mcp_with_session_streaming() -> None: + """Example showing Mcp Tools with approvals using a session.""" + print("=== Mcp with approvals and with session ===") credential = AzureCliCredential() + client = AzureOpenAIResponsesClient(credential=credential) + + # Create MCP tool with always require approval + mcp_tool = client.get_mcp_tool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", + # we require approval for all function calls + approval_mode="always_require", + ) + # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime - async with ChatAgent( - chat_client=AzureOpenAIResponsesClient( - credential=credential, - ), + async with Agent( + client=client, name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=HostedMCPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - # we require approval for all function calls - approval_mode="always_require", - ), + tools=[mcp_tool], ) as agent: # First query - thread = agent.get_new_thread() + session = agent.create_session() query1 = "How to create an Azure storage account using az cli?" print(f"User: {query1}") print(f"{agent.name}: ", end="") - async for update in handle_approvals_with_thread_streaming(query1, agent, thread): + async for update in handle_approvals_with_session_streaming(query1, agent, session): print(update, end="") print("\n") print("\n=======================================\n") @@ -222,7 +238,7 @@ async def run_hosted_mcp_with_thread_streaming() -> None: query2 = "What is Microsoft Agent Framework?" print(f"User: {query2}") print(f"{agent.name}: ", end="") - async for update in handle_approvals_with_thread_streaming(query2, agent, thread): + async for update in handle_approvals_with_session_streaming(query2, agent, session): print(update, end="") print("\n") @@ -231,9 +247,9 @@ async def main() -> None: print("=== OpenAI Responses Client Agent with Hosted Mcp Tools Examples ===\n") await run_hosted_mcp_without_approval() - await run_hosted_mcp_without_thread_and_specific_approval() - await run_hosted_mcp_with_thread() - await run_hosted_mcp_with_thread_streaming() + await run_hosted_mcp_without_session_and_specific_approval() + await run_hosted_mcp_with_session() + await run_hosted_mcp_with_session_streaming() if __name__ == "__main__": diff --git a/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_local_mcp.py b/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_local_mcp.py similarity index 78% rename from python/samples/getting_started/agents/azure_openai/azure_responses_client_with_local_mcp.py rename to python/samples/02-agents/providers/azure_openai/azure_responses_client_with_local_mcp.py index 4958a64b44..7d8f2466b6 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_local_mcp.py +++ b/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_local_mcp.py @@ -3,7 +3,7 @@ import asyncio import os -from agent_framework import ChatAgent, MCPStreamableHTTPTool +from agent_framework import Agent, MCPStreamableHTTPTool from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential @@ -37,7 +37,7 @@ async def main(): credential=credential, ) - agent: ChatAgent = responses_client.as_agent( + agent: Agent = responses_client.as_agent( name="DocsAgent", instructions=("You are a helpful assistant that can help with Microsoft documentation questions."), ) @@ -48,14 +48,14 @@ async def main(): url=MCP_URL, ) as mcp_tool: # First query — expect the agent to use the MCP tool if it helps - q1 = "How to create an Azure storage account using az cli?" - r1 = await agent.run(q1, tools=mcp_tool) - print("\n=== Answer 1 ===\n", r1.text) + first_query = "How to create an Azure storage account using az cli?" + first_response = await agent.run(first_query, tools=mcp_tool) + print("\n=== Answer 1 ===\n", first_response.text) # Follow-up query (connection is reused) - q2 = "What is Microsoft Agent Framework?" - r2 = await agent.run(q2, tools=mcp_tool) - print("\n=== Answer 2 ===\n", r2.text) + second_query = "What is Microsoft Agent Framework?" + second_response = await agent.run(second_query, tools=mcp_tool) + print("\n=== Answer 2 ===\n", second_response.text) if __name__ == "__main__": diff --git a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_session.py b/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_session.py new file mode 100644 index 0000000000..a406b9969d --- /dev/null +++ b/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_session.py @@ -0,0 +1,155 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from random import randint +from typing import Annotated + +from agent_framework import Agent, AgentSession, tool +from agent_framework.azure import AzureOpenAIResponsesClient +from azure.identity import AzureCliCredential +from pydantic import Field + +""" +Azure OpenAI Responses Client with Session Management Example + +This sample demonstrates session management with Azure OpenAI Responses Client, comparing +automatic session creation with explicit session management for persistent context. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def example_with_automatic_session_creation() -> None: + """Example showing automatic session creation.""" + print("=== Automatic Session Creation Example ===") + + # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred + # authentication option. + agent = Agent( + client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # First conversation - no session provided, will be created automatically + query1 = "What's the weather like in Seattle?" + print(f"User: {query1}") + result1 = await agent.run(query1) + print(f"Agent: {result1.text}") + + # Second conversation - still no session provided, will create another new session + query2 = "What was the last city I asked about?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2) + print(f"Agent: {result2.text}") + print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") + + +async def example_with_session_persistence_in_memory() -> None: + """ + Example showing session persistence across multiple conversations. + In this example, messages are stored in-memory. + """ + print("=== Session Persistence Example (In-Memory) ===") + + # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred + # authentication option. + agent = Agent( + client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Create a new session that will be reused + session = agent.create_session() + + # First conversation + query1 = "What's the weather like in Tokyo?" + print(f"User: {query1}") + result1 = await agent.run(query1, session=session) + print(f"Agent: {result1.text}") + + # Second conversation using the same session - maintains context + query2 = "How about London?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2, session=session) + print(f"Agent: {result2.text}") + + # Third conversation - agent should remember both previous cities + query3 = "Which of the cities I asked about has better weather?" + print(f"\nUser: {query3}") + result3 = await agent.run(query3, session=session) + print(f"Agent: {result3.text}") + print("Note: The agent remembers context from previous messages in the same session.\n") + + +async def example_with_existing_session_id() -> None: + """ + Example showing how to work with an existing session ID from the service. + In this example, messages are stored on the server using Azure OpenAI conversation state. + """ + print("=== Existing Session ID Example ===") + + # First, create a conversation and capture the session ID + existing_session_id = None + + # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred + # authentication option. + agent = Agent( + client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Start a conversation and get the session ID + session = agent.create_session() + + query1 = "What's the weather in Paris?" + print(f"User: {query1}") + # Enable Azure OpenAI conversation state by setting `store` parameter to True + result1 = await agent.run(query1, session=session, store=True) + print(f"Agent: {result1.text}") + + # The session ID is set after the first response + existing_session_id = session.service_session_id + print(f"Session ID: {existing_session_id}") + + if existing_session_id: + print("\n--- Continuing with the same session ID in a new agent instance ---") + + agent = Agent( + client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Create a session with the existing ID + session = AgentSession(service_session_id=existing_session_id) + + query2 = "What was the last city I asked about?" + print(f"User: {query2}") + result2 = await agent.run(query2, session=session, store=True) + print(f"Agent: {result2.text}") + print("Note: The agent continues the conversation from the previous session by using session ID.\n") + + +async def main() -> None: + print("=== Azure OpenAI Response Client Agent Session Management Examples ===\n") + + await example_with_automatic_session_creation() + await example_with_session_persistence_in_memory() + await example_with_existing_session_id() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/agents/copilotstudio/README.md b/python/samples/02-agents/providers/copilotstudio/README.md similarity index 100% rename from python/samples/getting_started/agents/copilotstudio/README.md rename to python/samples/02-agents/providers/copilotstudio/README.md diff --git a/python/samples/getting_started/agents/copilotstudio/copilotstudio_basic.py b/python/samples/02-agents/providers/copilotstudio/copilotstudio_basic.py similarity index 100% rename from python/samples/getting_started/agents/copilotstudio/copilotstudio_basic.py rename to python/samples/02-agents/providers/copilotstudio/copilotstudio_basic.py diff --git a/python/samples/getting_started/agents/copilotstudio/copilotstudio_with_explicit_settings.py b/python/samples/02-agents/providers/copilotstudio/copilotstudio_with_explicit_settings.py similarity index 97% rename from python/samples/getting_started/agents/copilotstudio/copilotstudio_with_explicit_settings.py rename to python/samples/02-agents/providers/copilotstudio/copilotstudio_with_explicit_settings.py index 7f26019550..80a260681f 100644 --- a/python/samples/getting_started/agents/copilotstudio/copilotstudio_with_explicit_settings.py +++ b/python/samples/02-agents/providers/copilotstudio/copilotstudio_with_explicit_settings.py @@ -5,7 +5,7 @@ # ] # /// # Run with any PEP 723 compatible runner, e.g.: -# uv run samples/getting_started/agents/copilotstudio/copilotstudio_with_explicit_settings.py +# uv run samples/02-agents/providers/copilotstudio/copilotstudio_with_explicit_settings.py # Copyright (c) Microsoft. All rights reserved. diff --git a/python/samples/getting_started/agents/custom/README.md b/python/samples/02-agents/providers/custom/README.md similarity index 90% rename from python/samples/getting_started/agents/custom/README.md rename to python/samples/02-agents/providers/custom/README.md index eba87c4350..f2d67e0315 100644 --- a/python/samples/getting_started/agents/custom/README.md +++ b/python/samples/02-agents/providers/custom/README.md @@ -6,8 +6,8 @@ This folder contains examples demonstrating how to implement custom agents and c | File | Description | |------|-------------| -| [`custom_agent.py`](custom_agent.py) | Shows how to create custom agents by extending the `BaseAgent` class. Demonstrates the `EchoAgent` implementation with both streaming and non-streaming responses, proper thread management, and message history handling. | -| [`custom_chat_client.py`](../../chat_client/custom_chat_client.py) | Demonstrates how to create custom chat clients by extending the `BaseChatClient` class. Shows a `EchoingChatClient` implementation and how to integrate it with `ChatAgent` using the `as_agent()` method. | +| [`custom_agent.py`](custom_agent.py) | Shows how to create custom agents by extending the `BaseAgent` class. Demonstrates the `EchoAgent` implementation with both streaming and non-streaming responses, proper session management, and message history handling. | +| [`custom_chat_client.py`](../../chat_client/custom_chat_client.py) | Demonstrates how to create custom chat clients by extending the `BaseChatClient` class. Shows a `EchoingChatClient` implementation and how to integrate it with `Agent` using the `as_agent()` method. | ## Key Takeaways @@ -15,12 +15,12 @@ This folder contains examples demonstrating how to implement custom agents and c - Custom agents give you complete control over the agent's behavior - You must implement both `run()` for both the `stream=True` and `stream=False` cases - Use `self._normalize_messages()` to handle different input message formats -- Use `self._notify_thread_of_new_messages()` to properly manage conversation history +- Store messages in `session.state` to properly manage conversation history ### Custom Chat Clients - Custom chat clients allow you to integrate any backend service or create new LLM providers - You must implement `_inner_get_response()` with a stream parameter to handle both streaming and non-streaming responses -- Custom chat clients can be used with `ChatAgent` to leverage all agent framework features +- Custom chat clients can be used with `Agent` to leverage all agent framework features - Use the `as_agent()` method to easily create agents from your custom chat clients Both approaches allow you to extend the framework for your specific use cases while maintaining compatibility with the broader Agent Framework ecosystem. diff --git a/python/samples/getting_started/agents/custom/custom_agent.py b/python/samples/02-agents/providers/custom/custom_agent.py similarity index 72% rename from python/samples/getting_started/agents/custom/custom_agent.py rename to python/samples/02-agents/providers/custom/custom_agent.py index 7df37ba781..1d3e5577d4 100644 --- a/python/samples/getting_started/agents/custom/custom_agent.py +++ b/python/samples/02-agents/providers/custom/custom_agent.py @@ -7,10 +7,10 @@ from typing import Any from agent_framework import ( AgentResponse, AgentResponseUpdate, - AgentThread, + AgentSession, BaseAgent, - ChatMessage, Content, + Message, Role, normalize_messages, ) @@ -57,10 +57,10 @@ class EchoAgent(BaseAgent): def run( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, stream: bool = False, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> "AsyncIterable[AgentResponseUpdate] | asyncio.Future[AgentResponse]": """Execute the agent and return a response. @@ -68,7 +68,7 @@ class EchoAgent(BaseAgent): Args: messages: The message(s) to process. stream: If True, return an async iterable of updates. If False, return an awaitable response. - thread: The conversation thread (optional). + session: The conversation session (optional). **kwargs: Additional keyword arguments. Returns: @@ -76,14 +76,14 @@ class EchoAgent(BaseAgent): When stream=True: An async iterable of AgentResponseUpdate objects. """ if stream: - return self._run_stream(messages=messages, thread=thread, **kwargs) - return self._run(messages=messages, thread=thread, **kwargs) + return self._run_stream(messages=messages, session=session, **kwargs) + return self._run(messages=messages, session=session, **kwargs) async def _run( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> AgentResponse: """Non-streaming implementation.""" @@ -91,11 +91,9 @@ class EchoAgent(BaseAgent): normalized_messages = normalize_messages(messages) if not normalized_messages: - response_message = ChatMessage( + response_message = Message( role=Role.ASSISTANT, - contents=[ - Content.from_text(text="Hello! I'm a custom echo agent. Send me a message and I'll echo it back.") - ], + contents=[Content.from_text(text="Hello! I'm a custom echo agent. Send me a message and I'll echo it back.")], ) else: # For simplicity, echo the last user message @@ -105,19 +103,21 @@ class EchoAgent(BaseAgent): else: echo_text = f"{self.echo_prefix}[Non-text message received]" - response_message = ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text(text=echo_text)]) + response_message = Message(role=Role.ASSISTANT, contents=[Content.from_text(text=echo_text)]) - # Notify the thread of new messages if provided - if thread is not None: - await self._notify_thread_of_new_messages(thread, normalized_messages, response_message) + # Store messages in session state if provided + if session is not None: + stored = session.state.setdefault("memory", {}).setdefault("messages", []) + stored.extend(normalized_messages) + stored.append(response_message) return AgentResponse(messages=[response_message]) async def _run_stream( self, - messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + messages: str | Message | list[str] | list[Message] | None = None, *, - thread: AgentThread | None = None, + session: AgentSession | None = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseUpdate]: """Streaming implementation.""" @@ -148,10 +148,12 @@ class EchoAgent(BaseAgent): # Small delay to simulate streaming await asyncio.sleep(0.1) - # Notify the thread of the complete response if provided - if thread is not None: - complete_response = ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text(text=response_text)]) - await self._notify_thread_of_new_messages(thread, normalized_messages, complete_response) + # Store messages in session state if provided + if session is not None: + complete_response = Message(role=Role.ASSISTANT, contents=[Content.from_text(text=response_text)]) + stored = session.state.setdefault("memory", {}).setdefault("messages", []) + stored.extend(normalized_messages) + stored.append(complete_response) async def main() -> None: @@ -182,26 +184,27 @@ async def main() -> None: print(chunk.text, end="", flush=True) print() - # Example with threads - print("\n--- Using Custom Agent with Thread ---") - thread = echo_agent.get_new_thread() + # Example with sessions + print("\n--- Using Custom Agent with Session ---") + session = echo_agent.create_session() # First message - result1 = await echo_agent.run("First message", thread=thread) + result1 = await echo_agent.run("First message", session=session) print("User: First message") print(f"Agent: {result1.messages[0].text}") # Second message in same thread - result2 = await echo_agent.run("Second message", thread=thread) + result2 = await echo_agent.run("Second message", session=session) print("User: Second message") print(f"Agent: {result2.messages[0].text}") # Check conversation history - if thread.message_store: - messages = await thread.message_store.list_messages() - print(f"\nThread contains {len(messages)} messages in history") + memory_state = session.state.get("memory", {}) + messages = memory_state.get("messages", []) + if messages: + print(f"\nSession contains {len(messages)} messages in history") else: - print("\nThread has no message store configured") + print("\nSession has no messages stored") if __name__ == "__main__": diff --git a/python/samples/getting_started/agents/github_copilot/README.md b/python/samples/02-agents/providers/github_copilot/README.md similarity index 97% rename from python/samples/getting_started/agents/github_copilot/README.md rename to python/samples/02-agents/providers/github_copilot/README.md index c69ffe37eb..572ec9c444 100644 --- a/python/samples/getting_started/agents/github_copilot/README.md +++ b/python/samples/02-agents/providers/github_copilot/README.md @@ -29,7 +29,7 @@ The following environment variables can be configured: | File | Description | |------|-------------| | [`github_copilot_basic.py`](github_copilot_basic.py) | The simplest way to create an agent using `GitHubCopilotAgent`. Demonstrates both streaming and non-streaming responses with function tools. | -| [`github_copilot_with_session.py`](github_copilot_with_session.py) | Shows session management with automatic creation, persistence via thread objects, and resuming sessions by ID. | +| [`github_copilot_with_session.py`](github_copilot_with_session.py) | Shows session management with automatic creation, persistence via session objects, and resuming sessions by ID. | | [`github_copilot_with_shell.py`](github_copilot_with_shell.py) | Shows how to enable shell command execution permissions. Demonstrates running system commands like listing files and getting system information. | | [`github_copilot_with_file_operations.py`](github_copilot_with_file_operations.py) | Shows how to enable file read and write permissions. Demonstrates reading file contents and creating new files. | | [`github_copilot_with_url.py`](github_copilot_with_url.py) | Shows how to enable URL fetching permissions. Demonstrates fetching and processing web content. | diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_basic.py b/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py similarity index 95% rename from python/samples/getting_started/agents/github_copilot/github_copilot_basic.py rename to python/samples/02-agents/providers/github_copilot/github_copilot_basic.py index 0e2fa722b6..6faacea67c 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_basic.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py @@ -22,7 +22,7 @@ from agent_framework.github import GitHubCopilotAgent from pydantic import Field -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py similarity index 100% rename from python/samples/getting_started/agents/github_copilot/github_copilot_with_file_operations.py rename to python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_mcp.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py similarity index 100% rename from python/samples/getting_started/agents/github_copilot/github_copilot_with_mcp.py rename to python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py similarity index 100% rename from python/samples/getting_started/agents/github_copilot/github_copilot_with_multiple_permissions.py rename to python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_session.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py similarity index 82% rename from python/samples/getting_started/agents/github_copilot/github_copilot_with_session.py rename to python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py index fa1c2e4640..4d386bfa19 100644 --- a/python/samples/getting_started/agents/github_copilot/github_copilot_with_session.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py @@ -17,7 +17,7 @@ from agent_framework.github import GitHubCopilotAgent from pydantic import Field -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -61,31 +61,31 @@ async def example_with_session_persistence() -> None: ) async with agent: - # Create a thread to maintain conversation context - thread = agent.get_new_thread() + # Create a session to maintain conversation context + session = agent.create_session() # First query query1 = "What's the weather like in Tokyo?" print(f"User: {query1}") - result1 = await agent.run(query1, thread=thread) + result1 = await agent.run(query1, session=session) print(f"Agent: {result1}") # Second query - using same thread maintains context query2 = "How about London?" print(f"\nUser: {query2}") - result2 = await agent.run(query2, thread=thread) + result2 = await agent.run(query2, session=session) print(f"Agent: {result2}") # Third query - agent should remember both previous cities query3 = "Which of the cities I asked about has better weather?" print(f"\nUser: {query3}") - result3 = await agent.run(query3, thread=thread) + result3 = await agent.run(query3, session=session) print(f"Agent: {result3}") print("Note: The agent remembers context from previous messages in the same session.\n") async def example_with_existing_session_id() -> None: - """Resume session in new agent instance using service_thread_id.""" + """Resume session in new agent instance using service_session_id.""" print("=== Existing Session ID Example ===") existing_session_id = None @@ -97,15 +97,15 @@ async def example_with_existing_session_id() -> None: ) async with agent1: - thread = agent1.get_new_thread() + session = agent1.create_session() query1 = "What's the weather in Paris?" print(f"User: {query1}") - result1 = await agent1.run(query1, thread=thread) + result1 = await agent1.run(query1, session=session) print(f"Agent: {result1}") # Capture the session ID for later use - existing_session_id = thread.service_thread_id + existing_session_id = session.service_session_id print(f"Session ID: {existing_session_id}") if existing_session_id: @@ -118,12 +118,12 @@ async def example_with_existing_session_id() -> None: ) async with agent2: - # Create thread with existing session ID - thread = agent2.get_new_thread(service_thread_id=existing_session_id) + # Create session with existing session ID + session = agent2.create_session(service_session_id=existing_session_id) query2 = "What was the last city I asked about?" print(f"User: {query2}") - result2 = await agent2.run(query2, thread=thread) + result2 = await agent2.run(query2, session=session) print(f"Agent: {result2}") print("Note: The agent continues the conversation using the session ID.\n") diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py similarity index 100% rename from python/samples/getting_started/agents/github_copilot/github_copilot_with_shell.py rename to python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_with_url.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py similarity index 100% rename from python/samples/getting_started/agents/github_copilot/github_copilot_with_url.py rename to python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py diff --git a/python/samples/getting_started/agents/ollama/README.md b/python/samples/02-agents/providers/ollama/README.md similarity index 100% rename from python/samples/getting_started/agents/ollama/README.md rename to python/samples/02-agents/providers/ollama/README.md diff --git a/python/samples/getting_started/agents/ollama/ollama_agent_basic.py b/python/samples/02-agents/providers/ollama/ollama_agent_basic.py similarity index 92% rename from python/samples/getting_started/agents/ollama/ollama_agent_basic.py rename to python/samples/02-agents/providers/ollama/ollama_agent_basic.py index 6477e620f0..92b7f47156 100644 --- a/python/samples/getting_started/agents/ollama/ollama_agent_basic.py +++ b/python/samples/02-agents/providers/ollama/ollama_agent_basic.py @@ -19,7 +19,7 @@ https://ollama.com/ """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_time(location: str) -> str: """Get the current time.""" diff --git a/python/samples/getting_started/agents/ollama/ollama_agent_reasoning.py b/python/samples/02-agents/providers/ollama/ollama_agent_reasoning.py similarity index 100% rename from python/samples/getting_started/agents/ollama/ollama_agent_reasoning.py rename to python/samples/02-agents/providers/ollama/ollama_agent_reasoning.py diff --git a/python/samples/getting_started/agents/ollama/ollama_chat_client.py b/python/samples/02-agents/providers/ollama/ollama_chat_client.py similarity index 88% rename from python/samples/getting_started/agents/ollama/ollama_chat_client.py rename to python/samples/02-agents/providers/ollama/ollama_chat_client.py index 07dd5cc368..636b7e4aa2 100644 --- a/python/samples/getting_started/agents/ollama/ollama_chat_client.py +++ b/python/samples/02-agents/providers/ollama/ollama_chat_client.py @@ -19,7 +19,7 @@ https://ollama.com/ """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_time(): """Get the current time.""" diff --git a/python/samples/getting_started/agents/ollama/ollama_chat_multimodal.py b/python/samples/02-agents/providers/ollama/ollama_chat_multimodal.py similarity index 94% rename from python/samples/getting_started/agents/ollama/ollama_chat_multimodal.py rename to python/samples/02-agents/providers/ollama/ollama_chat_multimodal.py index 3deb6f6e92..68c1246ad2 100644 --- a/python/samples/getting_started/agents/ollama/ollama_chat_multimodal.py +++ b/python/samples/02-agents/providers/ollama/ollama_chat_multimodal.py @@ -2,7 +2,7 @@ import asyncio -from agent_framework import ChatMessage, Content +from agent_framework import Content, Message from agent_framework.ollama import OllamaChatClient """ @@ -32,7 +32,7 @@ async def test_image() -> None: image_uri = create_sample_image() - message = ChatMessage( + message = Message( role="user", contents=[ Content.from_text(text="What's in this image?"), diff --git a/python/samples/getting_started/agents/ollama/ollama_with_openai_chat_client.py b/python/samples/02-agents/providers/ollama/ollama_with_openai_chat_client.py similarity index 93% rename from python/samples/getting_started/agents/ollama/ollama_with_openai_chat_client.py rename to python/samples/02-agents/providers/ollama/ollama_with_openai_chat_client.py index da2468cb22..140c338192 100644 --- a/python/samples/getting_started/agents/ollama/ollama_with_openai_chat_client.py +++ b/python/samples/02-agents/providers/ollama/ollama_with_openai_chat_client.py @@ -21,7 +21,7 @@ Environment Variables: """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, "The location to get the weather for."], diff --git a/python/samples/getting_started/agents/openai/README.md b/python/samples/02-agents/providers/openai/README.md similarity index 69% rename from python/samples/getting_started/agents/openai/README.md rename to python/samples/02-agents/providers/openai/README.md index 4feff05d22..20e757d421 100644 --- a/python/samples/getting_started/agents/openai/README.md +++ b/python/samples/02-agents/providers/openai/README.md @@ -1,6 +1,6 @@ # OpenAI Agent Framework Examples -This folder contains examples demonstrating different ways to create and use agents with the OpenAI Assistants client from the `agent_framework.openai` package. +This folder contains examples demonstrating different ways to create and use agents with the OpenAI clients from the `agent_framework.openai` package. ## Examples @@ -8,36 +8,37 @@ This folder contains examples demonstrating different ways to create and use age |------|-------------| | [`openai_assistants_basic.py`](openai_assistants_basic.py) | Basic usage of `OpenAIAssistantProvider` with streaming and non-streaming responses. | | [`openai_assistants_provider_methods.py`](openai_assistants_provider_methods.py) | Demonstrates all `OpenAIAssistantProvider` methods: `create_agent()`, `get_agent()`, and `as_agent()`. | -| [`openai_assistants_with_code_interpreter.py`](openai_assistants_with_code_interpreter.py) | Using `HostedCodeInterpreterTool` with `OpenAIAssistantProvider` to execute Python code. | +| [`openai_assistants_with_code_interpreter.py`](openai_assistants_with_code_interpreter.py) | Using `OpenAIAssistantsClient.get_code_interpreter_tool()` with `OpenAIAssistantProvider` to execute Python code. | | [`openai_assistants_with_existing_assistant.py`](openai_assistants_with_existing_assistant.py) | Working with pre-existing assistants using `get_agent()` and `as_agent()` methods. | | [`openai_assistants_with_explicit_settings.py`](openai_assistants_with_explicit_settings.py) | Configuring `OpenAIAssistantProvider` with explicit settings including API key and model ID. | -| [`openai_assistants_with_file_search.py`](openai_assistants_with_file_search.py) | Using `HostedFileSearchTool` with `OpenAIAssistantProvider` for file search capabilities. | +| [`openai_assistants_with_file_search.py`](openai_assistants_with_file_search.py) | Using `OpenAIAssistantsClient.get_file_search_tool()` with `OpenAIAssistantProvider` for file search capabilities. | | [`openai_assistants_with_function_tools.py`](openai_assistants_with_function_tools.py) | Function tools with `OpenAIAssistantProvider` at both agent-level and query-level. | | [`openai_assistants_with_response_format.py`](openai_assistants_with_response_format.py) | Structured outputs with `OpenAIAssistantProvider` using Pydantic models. | -| [`openai_assistants_with_thread.py`](openai_assistants_with_thread.py) | Thread management with `OpenAIAssistantProvider` for conversation context persistence. | -| [`openai_chat_client_basic.py`](openai_chat_client_basic.py) | The simplest way to create an agent using `ChatAgent` with `OpenAIChatClient`. Shows both streaming and non-streaming responses for chat-based interactions with OpenAI models. | +| [`openai_assistants_with_session.py`](openai_assistants_with_session.py) | Session management with `OpenAIAssistantProvider` for conversation context persistence. | +| [`openai_chat_client_basic.py`](openai_chat_client_basic.py) | The simplest way to create an agent using `Agent` with `OpenAIChatClient`. Shows both streaming and non-streaming responses for chat-based interactions with OpenAI models. | | [`openai_chat_client_with_explicit_settings.py`](openai_chat_client_with_explicit_settings.py) | Shows how to initialize an agent with a specific chat client, configuring settings explicitly including API key and model ID. | | [`openai_chat_client_with_function_tools.py`](openai_chat_client_with_function_tools.py) | Demonstrates how to use function tools with agents. Shows both agent-level tools (defined when creating the agent) and query-level tools (provided with specific queries). | | [`openai_chat_client_with_local_mcp.py`](openai_chat_client_with_local_mcp.py) | Shows how to integrate OpenAI agents with local Model Context Protocol (MCP) servers for enhanced functionality and tool integration. | -| [`openai_chat_client_with_thread.py`](openai_chat_client_with_thread.py) | Demonstrates thread management with OpenAI agents, including automatic thread creation for stateless conversations and explicit thread management for maintaining conversation context across multiple interactions. | -| [`openai_chat_client_with_web_search.py`](openai_chat_client_with_web_search.py) | Shows how to use web search capabilities with OpenAI agents to retrieve and use information from the internet in responses. | +| [`openai_chat_client_with_session.py`](openai_chat_client_with_session.py) | Demonstrates session management with OpenAI agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | +| [`openai_chat_client_with_web_search.py`](openai_chat_client_with_web_search.py) | Shows how to use `OpenAIChatClient.get_web_search_tool()` for web search capabilities with OpenAI agents. | | [`openai_chat_client_with_runtime_json_schema.py`](openai_chat_client_with_runtime_json_schema.py) | Shows how to supply a runtime JSON Schema via `additional_chat_options` for structured output without defining a Pydantic model. | -| [`openai_responses_client_basic.py`](openai_responses_client_basic.py) | The simplest way to create an agent using `ChatAgent` with `OpenAIResponsesClient`. Shows both streaming and non-streaming responses for structured response generation with OpenAI models. | +| [`openai_responses_client_basic.py`](openai_responses_client_basic.py) | The simplest way to create an agent using `Agent` with `OpenAIResponsesClient`. Shows both streaming and non-streaming responses for structured response generation with OpenAI models. | | [`openai_responses_client_image_analysis.py`](openai_responses_client_image_analysis.py) | Demonstrates how to use vision capabilities with agents to analyze images. | -| [`openai_responses_client_image_generation.py`](openai_responses_client_image_generation.py) | Demonstrates how to use image generation capabilities with OpenAI agents to create images based on text descriptions. Requires PIL (Pillow) for image display. | +| [`openai_responses_client_image_generation.py`](openai_responses_client_image_generation.py) | Demonstrates how to use `OpenAIResponsesClient.get_image_generation_tool()` to create images based on text descriptions. | | [`openai_responses_client_reasoning.py`](openai_responses_client_reasoning.py) | Demonstrates how to use reasoning capabilities with OpenAI agents, showing how the agent can provide detailed reasoning for its responses. | | [`openai_responses_client_streaming_image_generation.py`](openai_responses_client_streaming_image_generation.py) | Demonstrates streaming image generation with partial images for real-time image creation feedback and improved user experience. | | [`openai_responses_client_with_agent_as_tool.py`](openai_responses_client_with_agent_as_tool.py) | Shows how to use the agent-as-tool pattern with OpenAI Responses Client, where one agent delegates work to specialized sub-agents wrapped as tools using `as_tool()`. Demonstrates hierarchical agent architectures. | -| [`openai_responses_client_with_code_interpreter.py`](openai_responses_client_with_code_interpreter.py) | Shows how to use the HostedCodeInterpreterTool with OpenAI agents to write and execute Python code. Includes helper methods for accessing code interpreter data from response chunks. | +| [`openai_responses_client_with_code_interpreter.py`](openai_responses_client_with_code_interpreter.py) | Shows how to use `OpenAIResponsesClient.get_code_interpreter_tool()` to write and execute Python code. | +| [`openai_responses_client_with_code_interpreter_files.py`](openai_responses_client_with_code_interpreter_files.py) | Shows how to use code interpreter with uploaded files for data analysis. | | [`openai_responses_client_with_explicit_settings.py`](openai_responses_client_with_explicit_settings.py) | Shows how to initialize an agent with a specific responses client, configuring settings explicitly including API key and model ID. | -| [`openai_responses_client_with_file_search.py`](openai_responses_client_with_file_search.py) | Demonstrates how to use file search capabilities with OpenAI agents, allowing the agent to search through uploaded files to answer questions. | +| [`openai_responses_client_with_file_search.py`](openai_responses_client_with_file_search.py) | Demonstrates how to use `OpenAIResponsesClient.get_file_search_tool()` for searching through uploaded files. | | [`openai_responses_client_with_function_tools.py`](openai_responses_client_with_function_tools.py) | Demonstrates how to use function tools with agents. Shows both agent-level tools (defined when creating the agent) and run-level tools (provided with specific queries). | -| [`openai_responses_client_with_hosted_mcp.py`](openai_responses_client_with_hosted_mcp.py) | Shows how to integrate OpenAI agents with hosted Model Context Protocol (MCP) servers, including approval workflows and tool management for remote MCP services. | +| [`openai_responses_client_with_hosted_mcp.py`](openai_responses_client_with_hosted_mcp.py) | Shows how to use `OpenAIResponsesClient.get_mcp_tool()` for hosted MCP servers, including approval workflows. | | [`openai_responses_client_with_local_mcp.py`](openai_responses_client_with_local_mcp.py) | Shows how to integrate OpenAI agents with local Model Context Protocol (MCP) servers for enhanced functionality and tool integration. | | [`openai_responses_client_with_runtime_json_schema.py`](openai_responses_client_with_runtime_json_schema.py) | Shows how to supply a runtime JSON Schema via `additional_chat_options` for structured output without defining a Pydantic model. | | [`openai_responses_client_with_structured_output.py`](openai_responses_client_with_structured_output.py) | Demonstrates how to use structured outputs with OpenAI agents to get structured data responses in predefined formats. | -| [`openai_responses_client_with_thread.py`](openai_responses_client_with_thread.py) | Demonstrates thread management with OpenAI agents, including automatic thread creation for stateless conversations and explicit thread management for maintaining conversation context across multiple interactions. | -| [`openai_responses_client_with_web_search.py`](openai_responses_client_with_web_search.py) | Shows how to use web search capabilities with OpenAI agents to retrieve and use information from the internet in responses. | +| [`openai_responses_client_with_session.py`](openai_responses_client_with_session.py) | Demonstrates session management with OpenAI agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | +| [`openai_responses_client_with_web_search.py`](openai_responses_client_with_web_search.py) | Shows how to use `OpenAIResponsesClient.get_web_search_tool()` for web search capabilities. | ## Environment Variables diff --git a/python/samples/getting_started/agents/openai/openai_assistants_basic.py b/python/samples/02-agents/providers/openai/openai_assistants_basic.py similarity index 94% rename from python/samples/getting_started/agents/openai/openai_assistants_basic.py rename to python/samples/02-agents/providers/openai/openai_assistants_basic.py index 2fa4f79094..573b3c260c 100644 --- a/python/samples/getting_started/agents/openai/openai_assistants_basic.py +++ b/python/samples/02-agents/providers/openai/openai_assistants_basic.py @@ -18,7 +18,9 @@ assistant lifecycle management, showing both streaming and non-streaming respons """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/openai/openai_assistants_provider_methods.py b/python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py similarity index 96% rename from python/samples/getting_started/agents/openai/openai_assistants_provider_methods.py rename to python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py index 1c3ed11642..f74e064002 100644 --- a/python/samples/getting_started/agents/openai/openai_assistants_provider_methods.py +++ b/python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py @@ -20,7 +20,9 @@ This sample demonstrates the methods available on the OpenAIAssistantProvider cl """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/openai/openai_assistants_with_code_interpreter.py b/python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py similarity index 87% rename from python/samples/getting_started/agents/openai/openai_assistants_with_code_interpreter.py rename to python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py index 0599e796ea..f05264423e 100644 --- a/python/samples/getting_started/agents/openai/openai_assistants_with_code_interpreter.py +++ b/python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py @@ -3,8 +3,8 @@ import asyncio import os -from agent_framework import AgentResponseUpdate, ChatResponseUpdate, HostedCodeInterpreterTool -from agent_framework.openai import OpenAIAssistantProvider +from agent_framework import AgentResponseUpdate, ChatResponseUpdate +from agent_framework.openai import OpenAIAssistantProvider, OpenAIAssistantsClient from openai import AsyncOpenAI from openai.types.beta.threads.runs import ( CodeInterpreterToolCallDelta, @@ -17,7 +17,7 @@ from openai.types.beta.threads.runs.code_interpreter_tool_call_delta import Code """ OpenAI Assistants with Code Interpreter Example -This sample demonstrates using HostedCodeInterpreterTool with OpenAI Assistants +This sample demonstrates using get_code_interpreter_tool() with OpenAI Assistants for Python code execution and mathematical problem solving. """ @@ -42,17 +42,18 @@ def get_code_interpreter_chunk(chunk: AgentResponseUpdate) -> str | None: async def main() -> None: - """Example showing how to use the HostedCodeInterpreterTool with OpenAI Assistants.""" + """Example showing how to use the code interpreter tool with OpenAI Assistants.""" print("=== OpenAI Assistants Provider with Code Interpreter Example ===") client = AsyncOpenAI() provider = OpenAIAssistantProvider(client) + chat_client = OpenAIAssistantsClient(client=client) agent = await provider.create_agent( name="CodeHelper", model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), instructions="You are a helpful assistant that can write and execute Python code to solve problems.", - tools=[HostedCodeInterpreterTool()], + tools=[chat_client.get_code_interpreter_tool()], ) try: diff --git a/python/samples/getting_started/agents/openai/openai_assistants_with_existing_assistant.py b/python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py similarity index 95% rename from python/samples/getting_started/agents/openai/openai_assistants_with_existing_assistant.py rename to python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py index b004253796..4e432f88b2 100644 --- a/python/samples/getting_started/agents/openai/openai_assistants_with_existing_assistant.py +++ b/python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py @@ -18,7 +18,7 @@ using the provider's get_agent() and as_agent() methods. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/openai/openai_assistants_with_explicit_settings.py b/python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py similarity index 83% rename from python/samples/getting_started/agents/openai/openai_assistants_with_explicit_settings.py rename to python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py index 70622f714b..aa7b5db6f6 100644 --- a/python/samples/getting_started/agents/openai/openai_assistants_with_explicit_settings.py +++ b/python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py @@ -18,7 +18,9 @@ settings rather than relying on environment variable defaults. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -43,7 +45,9 @@ async def main() -> None: ) try: - result = await agent.run("What's the weather like in New York?") + query = "What's the weather like in New York?" + print(f"Query: {query}") + result = await agent.run(query) print(f"Result: {result}\n") finally: await client.beta.assistants.delete(agent.id) diff --git a/python/samples/getting_started/agents/openai/openai_assistants_with_file_search.py b/python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py similarity index 80% rename from python/samples/getting_started/agents/openai/openai_assistants_with_file_search.py rename to python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py index 0046be1206..505a3a3957 100644 --- a/python/samples/getting_started/agents/openai/openai_assistants_with_file_search.py +++ b/python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py @@ -3,14 +3,14 @@ import asyncio import os -from agent_framework import Content, HostedFileSearchTool -from agent_framework.openai import OpenAIAssistantProvider +from agent_framework import Content +from agent_framework.openai import OpenAIAssistantProvider, OpenAIAssistantsClient from openai import AsyncOpenAI """ OpenAI Assistants with File Search Example -This sample demonstrates using HostedFileSearchTool with OpenAI Assistants +This sample demonstrates using get_file_search_tool() with OpenAI Assistants for document-based question answering and information retrieval. """ @@ -42,29 +42,30 @@ async def main() -> None: client = AsyncOpenAI() provider = OpenAIAssistantProvider(client) + chat_client = OpenAIAssistantsClient(client=client) agent = await provider.create_agent( name="SearchAssistant", model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), instructions="You are a helpful assistant that searches files in a knowledge base.", - tools=[HostedFileSearchTool()], + tools=[chat_client.get_file_search_tool()], ) try: query = "What is the weather today? Do a file search to find the answer." - file_id, vector_store = await create_vector_store(client) + file_id, vector_store_content = await create_vector_store(client) print(f"User: {query}") print("Agent: ", end="", flush=True) async for chunk in agent.run( query, stream=True, - options={"tool_resources": {"file_search": {"vector_store_ids": [vector_store.vector_store_id]}}}, + options={"tool_resources": {"file_search": {"vector_store_ids": [vector_store_content.vector_store_id]}}}, ): if chunk.text: print(chunk.text, end="", flush=True) - await delete_vector_store(client, file_id, vector_store.vector_store_id) + await delete_vector_store(client, file_id, vector_store_content.vector_store_id) finally: await client.beta.assistants.delete(agent.id) diff --git a/python/samples/getting_started/agents/openai/openai_assistants_with_function_tools.py b/python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py similarity index 96% rename from python/samples/getting_started/agents/openai/openai_assistants_with_function_tools.py rename to python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py index fe4b3d3b4e..2b406170a6 100644 --- a/python/samples/getting_started/agents/openai/openai_assistants_with_function_tools.py +++ b/python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py @@ -19,7 +19,7 @@ showing both agent-level and query-level tool configuration patterns. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/openai/openai_assistants_with_response_format.py b/python/samples/02-agents/providers/openai/openai_assistants_with_response_format.py similarity index 100% rename from python/samples/getting_started/agents/openai/openai_assistants_with_response_format.py rename to python/samples/02-agents/providers/openai/openai_assistants_with_response_format.py diff --git a/python/samples/getting_started/agents/openai/openai_assistants_with_thread.py b/python/samples/02-agents/providers/openai/openai_assistants_with_session.py similarity index 55% rename from python/samples/getting_started/agents/openai/openai_assistants_with_thread.py rename to python/samples/02-agents/providers/openai/openai_assistants_with_session.py index 02b8086199..39e13745a2 100644 --- a/python/samples/getting_started/agents/openai/openai_assistants_with_thread.py +++ b/python/samples/02-agents/providers/openai/openai_assistants_with_session.py @@ -5,20 +5,22 @@ import os from random import randint from typing import Annotated -from agent_framework import AgentThread, tool +from agent_framework import AgentSession, tool from agent_framework.openai import OpenAIAssistantProvider from openai import AsyncOpenAI from pydantic import Field """ -OpenAI Assistants with Thread Management Example +OpenAI Assistants with Session Management Example -This sample demonstrates thread management with OpenAI Assistants, showing -persistent conversation threads and context preservation across interactions. +This sample demonstrates session management with OpenAI Assistants, showing +persistent conversation sessions and context preservation across interactions. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -28,9 +30,9 @@ def get_weather( return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." -async def example_with_automatic_thread_creation() -> None: - """Example showing automatic thread creation (service-managed thread).""" - print("=== Automatic Thread Creation Example ===") +async def example_with_automatic_session_creation() -> None: + """Example showing automatic session creation (service-managed session).""" + print("=== Automatic Session Creation Example ===") client = AsyncOpenAI() provider = OpenAIAssistantProvider(client) @@ -43,26 +45,26 @@ async def example_with_automatic_thread_creation() -> None: ) try: - # First conversation - no thread provided, will be created automatically + # First conversation - no session provided, will be created automatically query1 = "What's the weather like in Seattle?" print(f"User: {query1}") result1 = await agent.run(query1) print(f"Agent: {result1.text}") - # Second conversation - still no thread provided, will create another new thread + # Second conversation - still no session provided, will create another new session query2 = "What was the last city I asked about?" print(f"\nUser: {query2}") result2 = await agent.run(query2) print(f"Agent: {result2.text}") - print("Note: Each call creates a separate thread, so the agent doesn't remember previous context.\n") + print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") finally: await client.beta.assistants.delete(agent.id) -async def example_with_thread_persistence() -> None: - """Example showing thread persistence across multiple conversations.""" - print("=== Thread Persistence Example ===") - print("Using the same thread across multiple conversations to maintain context.\n") +async def example_with_session_persistence() -> None: + """Example showing session persistence across multiple conversations.""" + print("=== Session Persistence Example ===") + print("Using the same session across multiple conversations to maintain context.\n") client = AsyncOpenAI() provider = OpenAIAssistantProvider(client) @@ -75,41 +77,41 @@ async def example_with_thread_persistence() -> None: ) try: - # Create a new thread that will be reused - thread = agent.get_new_thread() + # Create a new session that will be reused + session = agent.create_session() # First conversation query1 = "What's the weather like in Tokyo?" print(f"User: {query1}") - result1 = await agent.run(query1, thread=thread) + result1 = await agent.run(query1, session=session) print(f"Agent: {result1.text}") - # Second conversation using the same thread - maintains context + # Second conversation using the same session - maintains context query2 = "How about London?" print(f"\nUser: {query2}") - result2 = await agent.run(query2, thread=thread) + result2 = await agent.run(query2, session=session) print(f"Agent: {result2.text}") # Third conversation - agent should remember both previous cities query3 = "Which of the cities I asked about has better weather?" print(f"\nUser: {query3}") - result3 = await agent.run(query3, thread=thread) + result3 = await agent.run(query3, session=session) print(f"Agent: {result3.text}") - print("Note: The agent remembers context from previous messages in the same thread.\n") + print("Note: The agent remembers context from previous messages in the same session.\n") finally: await client.beta.assistants.delete(agent.id) -async def example_with_existing_thread_id() -> None: - """Example showing how to work with an existing thread ID from the service.""" - print("=== Existing Thread ID Example ===") - print("Using a specific thread ID to continue an existing conversation.\n") +async def example_with_existing_session_id() -> None: + """Example showing how to work with an existing session ID from the service.""" + print("=== Existing Session ID Example ===") + print("Using a specific session ID to continue an existing conversation.\n") client = AsyncOpenAI() provider = OpenAIAssistantProvider(client) - # First, create a conversation and capture the thread ID - existing_thread_id = None + # First, create a conversation and capture the session ID + existing_session_id = None assistant_id = None agent = await provider.create_agent( @@ -121,19 +123,19 @@ async def example_with_existing_thread_id() -> None: assistant_id = agent.id try: - # Start a conversation and get the thread ID - thread = agent.get_new_thread() + # Start a conversation and get the session ID + session = agent.create_session() query1 = "What's the weather in Paris?" print(f"User: {query1}") - result1 = await agent.run(query1, thread=thread) + result1 = await agent.run(query1, session=session) print(f"Agent: {result1.text}") - # The thread ID is set after the first response - existing_thread_id = thread.service_thread_id - print(f"Thread ID: {existing_thread_id}") + # The session ID is set after the first response + existing_session_id = session.service_session_id + print(f"Session ID: {existing_session_id}") - if existing_thread_id: - print("\n--- Continuing with the same thread ID using get_agent ---") + if existing_session_id: + print("\n--- Continuing with the same session ID using get_agent ---") # Get the existing assistant by ID agent2 = await provider.get_agent( @@ -141,25 +143,25 @@ async def example_with_existing_thread_id() -> None: tools=[get_weather], # Must provide function implementations ) - # Create a thread with the existing ID - thread = AgentThread(service_thread_id=existing_thread_id) + # Create a session with the existing ID + session = AgentSession(service_session_id=existing_session_id) query2 = "What was the last city I asked about?" print(f"User: {query2}") - result2 = await agent2.run(query2, thread=thread) + result2 = await agent2.run(query2, session=session) print(f"Agent: {result2.text}") - print("Note: The agent continues the conversation from the previous thread.\n") + print("Note: The agent continues the conversation from the previous session.\n") finally: if assistant_id: await client.beta.assistants.delete(assistant_id) async def main() -> None: - print("=== OpenAI Assistants Provider Thread Management Examples ===\n") + print("=== OpenAI Assistants Provider Session Management Examples ===\n") - await example_with_automatic_thread_creation() - await example_with_thread_persistence() - await example_with_existing_thread_id() + await example_with_automatic_session_creation() + await example_with_session_persistence() + await example_with_existing_session_id() if __name__ == "__main__": diff --git a/python/samples/getting_started/agents/openai/openai_chat_client_basic.py b/python/samples/02-agents/providers/openai/openai_chat_client_basic.py similarity index 92% rename from python/samples/getting_started/agents/openai/openai_chat_client_basic.py rename to python/samples/02-agents/providers/openai/openai_chat_client_basic.py index b7137b2d43..fb7bd42613 100644 --- a/python/samples/getting_started/agents/openai/openai_chat_client_basic.py +++ b/python/samples/02-agents/providers/openai/openai_chat_client_basic.py @@ -15,7 +15,9 @@ interactions, showing both streaming and non-streaming responses. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, "The location to get the weather for."], diff --git a/python/samples/getting_started/agents/openai/openai_chat_client_with_explicit_settings.py b/python/samples/02-agents/providers/openai/openai_chat_client_with_explicit_settings.py similarity index 88% rename from python/samples/getting_started/agents/openai/openai_chat_client_with_explicit_settings.py rename to python/samples/02-agents/providers/openai/openai_chat_client_with_explicit_settings.py index 0bac0b863c..e5b85c31fa 100644 --- a/python/samples/getting_started/agents/openai/openai_chat_client_with_explicit_settings.py +++ b/python/samples/02-agents/providers/openai/openai_chat_client_with_explicit_settings.py @@ -17,7 +17,9 @@ settings rather than relying on environment variable defaults. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/openai/openai_chat_client_with_function_tools.py b/python/samples/02-agents/providers/openai/openai_chat_client_with_function_tools.py similarity index 91% rename from python/samples/getting_started/agents/openai/openai_chat_client_with_function_tools.py rename to python/samples/02-agents/providers/openai/openai_chat_client_with_function_tools.py index 057989d228..d66a5cf778 100644 --- a/python/samples/getting_started/agents/openai/openai_chat_client_with_function_tools.py +++ b/python/samples/02-agents/providers/openai/openai_chat_client_with_function_tools.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from random import randint from typing import Annotated -from agent_framework import ChatAgent, tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIChatClient from pydantic import Field @@ -17,7 +17,9 @@ showing both agent-level and query-level tool configuration patterns. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -40,8 +42,8 @@ async def tools_on_agent_level() -> None: # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime - agent = ChatAgent( - chat_client=OpenAIChatClient(), + agent = Agent( + client=OpenAIChatClient(), instructions="You are a helpful assistant that can provide weather and time information.", tools=[get_weather, get_time], # Tools defined at agent creation ) @@ -70,8 +72,8 @@ async def tools_on_run_level() -> None: print("=== Tools Passed to Run Method ===") # Agent created without tools - agent = ChatAgent( - chat_client=OpenAIChatClient(), + agent = Agent( + client=OpenAIChatClient(), instructions="You are a helpful assistant.", # No tools defined here ) @@ -100,8 +102,8 @@ async def mixed_tools_example() -> None: print("=== Mixed Tools Example (Agent + Run Method) ===") # Agent created with some base tools - agent = ChatAgent( - chat_client=OpenAIChatClient(), + agent = Agent( + client=OpenAIChatClient(), instructions="You are a comprehensive assistant that can help with various information requests.", tools=[get_weather], # Base tool available for all queries ) diff --git a/python/samples/getting_started/agents/openai/openai_chat_client_with_local_mcp.py b/python/samples/02-agents/providers/openai/openai_chat_client_with_local_mcp.py similarity index 96% rename from python/samples/getting_started/agents/openai/openai_chat_client_with_local_mcp.py rename to python/samples/02-agents/providers/openai/openai_chat_client_with_local_mcp.py index e49304adcc..d741a1f6b8 100644 --- a/python/samples/getting_started/agents/openai/openai_chat_client_with_local_mcp.py +++ b/python/samples/02-agents/providers/openai/openai_chat_client_with_local_mcp.py @@ -2,7 +2,7 @@ import asyncio -from agent_framework import ChatAgent, MCPStreamableHTTPTool +from agent_framework import Agent, MCPStreamableHTTPTool from agent_framework.openai import OpenAIChatClient """ @@ -29,8 +29,8 @@ async def mcp_tools_on_run_level() -> None: name="Microsoft Learn MCP", url="https://learn.microsoft.com/api/mcp", ) as mcp_server, - ChatAgent( - chat_client=OpenAIChatClient(), + Agent( + client=OpenAIChatClient(), name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", ) as agent, diff --git a/python/samples/getting_started/agents/openai/openai_chat_client_with_runtime_json_schema.py b/python/samples/02-agents/providers/openai/openai_chat_client_with_runtime_json_schema.py similarity index 100% rename from python/samples/getting_started/agents/openai/openai_chat_client_with_runtime_json_schema.py rename to python/samples/02-agents/providers/openai/openai_chat_client_with_runtime_json_schema.py diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_session.py b/python/samples/02-agents/providers/openai/openai_chat_client_with_session.py new file mode 100644 index 0000000000..a5bbf20b63 --- /dev/null +++ b/python/samples/02-agents/providers/openai/openai_chat_client_with_session.py @@ -0,0 +1,149 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from random import randint +from typing import Annotated + +from agent_framework import Agent, AgentSession, tool +from agent_framework.openai import OpenAIChatClient +from pydantic import Field + +""" +OpenAI Chat Client with Session Management Example + +This sample demonstrates session management with OpenAI Chat Client, showing +conversation sessions and message history preservation across interactions. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def example_with_automatic_session_creation() -> None: + """Example showing automatic session creation (service-managed session).""" + print("=== Automatic Session Creation Example ===") + + agent = Agent( + client=OpenAIChatClient(), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # First conversation - no session provided, will be created automatically + query1 = "What's the weather like in Seattle?" + print(f"User: {query1}") + result1 = await agent.run(query1) + print(f"Agent: {result1.text}") + + # Second conversation - still no session provided, will create another new session + query2 = "What was the last city I asked about?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2) + print(f"Agent: {result2.text}") + print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") + + +async def example_with_session_persistence() -> None: + """Example showing session persistence across multiple conversations.""" + print("=== Session Persistence Example ===") + print("Using the same session across multiple conversations to maintain context.\n") + + agent = Agent( + client=OpenAIChatClient(), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Create a new session that will be reused + session = agent.create_session() + + # First conversation + query1 = "What's the weather like in Tokyo?" + print(f"User: {query1}") + result1 = await agent.run(query1, session=session) + print(f"Agent: {result1.text}") + + # Second conversation using the same session - maintains context + query2 = "How about London?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2, session=session) + print(f"Agent: {result2.text}") + + # Third conversation - agent should remember both previous cities + query3 = "Which of the cities I asked about has better weather?" + print(f"\nUser: {query3}") + result3 = await agent.run(query3, session=session) + print(f"Agent: {result3.text}") + print("Note: The agent remembers context from previous messages in the same session.\n") + + +async def example_with_existing_session_messages() -> None: + """Example showing how to work with existing session messages for OpenAI.""" + print("=== Existing Session Messages Example ===") + + agent = Agent( + client=OpenAIChatClient(), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Start a conversation and build up message history + session = agent.create_session() + + query1 = "What's the weather in Paris?" + print(f"User: {query1}") + result1 = await agent.run(query1, session=session) + print(f"Agent: {result1.text}") + + # The session now contains the conversation history in state + memory_state = session.state.get("memory", {}) + messages = memory_state.get("messages", []) + if messages: + print(f"Session contains {len(messages)} messages") + + print("\n--- Continuing with the same session in a new agent instance ---") + + # Create a new agent instance but use the existing session with its message history + new_agent = Agent( + client=OpenAIChatClient(), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Use the same session object which contains the conversation history + query2 = "What was the last city I asked about?" + print(f"User: {query2}") + result2 = await new_agent.run(query2, session=session) + print(f"Agent: {result2.text}") + print("Note: The agent continues the conversation using the local message history.\n") + + print("\n--- Alternative: Creating a new session from existing messages ---") + + new_session = AgentSession() + + query3 = "How does the Paris weather compare to London?" + print(f"User: {query3}") + result3 = await new_agent.run(query3, session=new_session) + print(f"Agent: {result3.text}") + print("Note: This creates a new session with the same conversation history.\n") + + +async def main() -> None: + print("=== OpenAI Chat Client Agent Session Management Examples ===\n") + + await example_with_automatic_session_creation() + await example_with_session_persistence() + await example_with_existing_session_messages() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/agents/openai/openai_chat_client_with_web_search.py b/python/samples/02-agents/providers/openai/openai_chat_client_with_web_search.py similarity index 62% rename from python/samples/getting_started/agents/openai/openai_chat_client_with_web_search.py rename to python/samples/02-agents/providers/openai/openai_chat_client_with_web_search.py index eb1072f945..7370d4fee9 100644 --- a/python/samples/getting_started/agents/openai/openai_chat_client_with_web_search.py +++ b/python/samples/02-agents/providers/openai/openai_chat_client_with_web_search.py @@ -2,30 +2,29 @@ import asyncio -from agent_framework import ChatAgent, HostedWebSearchTool +from agent_framework import Agent from agent_framework.openai import OpenAIChatClient """ OpenAI Chat Client with Web Search Example -This sample demonstrates using HostedWebSearchTool with OpenAI Chat Client +This sample demonstrates using get_web_search_tool() with OpenAI Chat Client for real-time information retrieval and current data access. """ async def main() -> None: - # Test that the agent will use the web search tool with location - additional_properties = { - "user_location": { - "country": "US", - "city": "Seattle", - } - } + client = OpenAIChatClient(model_id="gpt-4o-search-preview") - agent = ChatAgent( - chat_client=OpenAIChatClient(model_id="gpt-4o-search-preview"), + # Create web search tool with location context + web_search_tool = client.get_web_search_tool( + user_location={"city": "Seattle", "country": "US"}, + ) + + agent = Agent( + client=client, instructions="You are a helpful assistant that can search the web for current information.", - tools=[HostedWebSearchTool(additional_properties=additional_properties)], + tools=[web_search_tool], ) message = "What is the current weather? Do not ask for my current location." diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_basic.py b/python/samples/02-agents/providers/openai/openai_responses_client_basic.py similarity index 84% rename from python/samples/getting_started/agents/openai/openai_responses_client_basic.py rename to python/samples/02-agents/providers/openai/openai_responses_client_basic.py index b564f07d51..fa4766e575 100644 --- a/python/samples/getting_started/agents/openai/openai_responses_client_basic.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_basic.py @@ -6,11 +6,12 @@ from random import randint from typing import Annotated from agent_framework import ( - ChatAgent, + Agent, ChatContext, - ChatMessage, ChatResponse, + Message, MiddlewareTermination, + Role, chat_middleware, tool, ) @@ -28,7 +29,7 @@ response generation, showing both streaming and non-streaming responses. @chat_middleware async def security_and_override_middleware( context: ChatContext, - call_next: Callable[[ChatContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """Function-based middleware that implements security filtering and response override.""" print("[SecurityMiddleware] Processing input...") @@ -46,8 +47,8 @@ async def security_and_override_middleware( # Override the response instead of calling AI context.result = ChatResponse( messages=[ - ChatMessage( - role="assistant", + Message( + role=Role.ASSISTANT, text="I cannot process requests containing sensitive information. " "Please rephrase your question without including passwords, secrets, or other " "sensitive data.", @@ -55,17 +56,19 @@ async def security_and_override_middleware( ] ) - # Set terminate flag to stop execution - raise MiddlewareTermination + # Terminate middleware execution with the blocked response + raise MiddlewareTermination(result=context.result) # Continue to next middleware or AI execution - await call_next(context) + await call_next() print("[SecurityMiddleware] Response generated.") print(type(context.result)) -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -79,8 +82,8 @@ async def non_streaming_example() -> None: """Example of non-streaming response (get the complete result at once).""" print("=== Non-streaming Response Example ===") - agent = ChatAgent( - chat_client=OpenAIResponsesClient(), + agent = Agent( + client=OpenAIResponsesClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -95,12 +98,12 @@ async def streaming_example() -> None: """Example of streaming response (get results as they are generated).""" print("=== Streaming Response Example ===") - agent = ChatAgent( - chat_client=OpenAIResponsesClient( + agent = Agent( + client=OpenAIResponsesClient( middleware=[security_and_override_middleware], ), instructions="You are a helpful weather agent.", - # tools=get_weather, + tools=get_weather, ) query = "What's the weather like in Portland?" diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_image_analysis.py b/python/samples/02-agents/providers/openai/openai_responses_client_image_analysis.py similarity index 81% rename from python/samples/getting_started/agents/openai/openai_responses_client_image_analysis.py rename to python/samples/02-agents/providers/openai/openai_responses_client_image_analysis.py index c9c56d5e48..93c517b97b 100644 --- a/python/samples/getting_started/agents/openai/openai_responses_client_image_analysis.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_image_analysis.py @@ -2,7 +2,7 @@ import asyncio -from agent_framework import ChatMessage, Content +from agent_framework import Content, Message from agent_framework.openai import OpenAIResponsesClient """ @@ -23,12 +23,12 @@ async def main(): ) # 2. Create a simple message with both text and image content - user_message = ChatMessage( + user_message = Message( role="user", contents=[ Content.from_text(text="What do you see in this image?"), Content.from_uri( - uri="https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", + uri="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800", media_type="image/jpeg", ), ], diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_image_generation.py b/python/samples/02-agents/providers/openai/openai_responses_client_image_generation.py new file mode 100644 index 0000000000..1e015b3762 --- /dev/null +++ b/python/samples/02-agents/providers/openai/openai_responses_client_image_generation.py @@ -0,0 +1,100 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import base64 +import tempfile +import urllib.request as urllib_request +from pathlib import Path + +import aiofiles # pyright: ignore[reportMissingModuleSource] +from agent_framework import Content +from agent_framework.openai import OpenAIResponsesClient + +""" +OpenAI Responses Client Image Generation Example + +This sample demonstrates how to generate images using OpenAI's DALL-E models +through the Responses Client. Image generation capabilities enable AI to create visual content from text, +making it ideal for creative applications, content creation, design prototyping, +and automated visual asset generation. +""" + + +async def save_image(output: Content) -> None: + """Save the generated image to a temporary directory.""" + filename = "generated_image.webp" + file_path = Path(tempfile.gettempdir()) / filename + + data_bytes: bytes | None = None + uri = getattr(output, "uri", None) + + if isinstance(uri, str): + if ";base64," in uri: + try: + b64 = uri.split(";base64,", 1)[1] + data_bytes = base64.b64decode(b64) + except Exception: + data_bytes = None + else: + try: + data_bytes = await asyncio.to_thread(lambda: urllib_request.urlopen(uri).read()) + except Exception: + data_bytes = None + + if data_bytes is None: + raise RuntimeError("Image output present but could not retrieve bytes.") + + async with aiofiles.open(file_path, "wb") as f: + await f.write(data_bytes) + + print(f"Image downloaded and saved to: {file_path}") + + +async def main() -> None: + print("=== OpenAI Responses Image Generation Agent Example ===") + + # Create an agent with customized image generation options + client = OpenAIResponsesClient() + agent = client.as_agent( + instructions="You are a helpful AI that can generate images.", + tools=[ + client.get_image_generation_tool( + size="1024x1024", + output_format="webp", + ) + ], + ) + + query = "Generate a black furry cat." + print(f"User: {query}") + print("Generating image with parameters: 1024x1024 size, WebP format...") + + result = await agent.run(query) + print(f"Agent: {result.text}") + + # Find and save the generated image + image_saved = False + for message in result.messages: + for content in message.contents: + if content.type == "image_generation_tool_result_tool_result" and content.outputs: + output = content.outputs + if isinstance(output, Content) and output.uri: + await save_image(output) + image_saved = True + elif isinstance(output, list): + for out in output: + if isinstance(out, Content) and out.uri: + await save_image(out) + image_saved = True + break + if image_saved: + break + if image_saved: + break + + if not image_saved: + print("No image data found in the agent response.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_reasoning.py b/python/samples/02-agents/providers/openai/openai_responses_client_reasoning.py similarity index 100% rename from python/samples/getting_started/agents/openai/openai_responses_client_reasoning.py rename to python/samples/02-agents/providers/openai/openai_responses_client_reasoning.py diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_streaming_image_generation.py b/python/samples/02-agents/providers/openai/openai_responses_client_streaming_image_generation.py similarity index 82% rename from python/samples/getting_started/agents/openai/openai_responses_client_streaming_image_generation.py rename to python/samples/02-agents/providers/openai/openai_responses_client_streaming_image_generation.py index 4fbf2b0da5..5921a9b07b 100644 --- a/python/samples/getting_started/agents/openai/openai_responses_client_streaming_image_generation.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_streaming_image_generation.py @@ -2,9 +2,10 @@ import asyncio import base64 +import tempfile +from pathlib import Path import anyio -from agent_framework import HostedImageGenerationTool from agent_framework.openai import OpenAIResponsesClient """OpenAI Responses Client Streaming Image Generation Example @@ -42,15 +43,14 @@ async def main(): print("=== OpenAI Streaming Image Generation Example ===\n") # Create agent with streaming image generation enabled - agent = OpenAIResponsesClient().as_agent( + client = OpenAIResponsesClient() + agent = client.as_agent( instructions="You are a helpful agent that can generate images.", tools=[ - HostedImageGenerationTool( - options={ - "size": "1024x1024", - "quality": "high", - "partial_images": 3, - } + client.get_image_generation_tool( + size="1024x1024", + quality="high", + partial_images=3, ) ], ) @@ -62,9 +62,9 @@ async def main(): # Track partial images image_count = 0 - # Create output directory - output_dir = anyio.Path("generated_images") - await output_dir.mkdir(exist_ok=True) + # Use temp directory for output + output_dir = Path(tempfile.gettempdir()) / "generated_images" + output_dir.mkdir(exist_ok=True) print(" Streaming response:") async for update in agent.run(query, stream=True): @@ -72,7 +72,11 @@ async def main(): # Handle partial images # The final partial image IS the complete, full-quality image. Each partial # represents a progressive refinement, with the last one being the finished result. - if content.type == "data" and content.additional_properties.get("is_partial_image"): + if ( + content.type == "uri" + and content.additional_properties + and content.additional_properties.get("is_partial_image") + ): print(f" Image {image_count} received") # Extract file extension from media_type (e.g., "image/png" -> "png") @@ -89,7 +93,7 @@ async def main(): # Summary print("\n Summary:") print(f" Images received: {image_count}") - print(" Output directory: generated_images") + print(f" Output directory: {output_dir}") print("\n Streaming image generation completed!") diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_agent_as_tool.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_agent_as_tool.py similarity index 95% rename from python/samples/getting_started/agents/openai/openai_responses_client_with_agent_as_tool.py rename to python/samples/02-agents/providers/openai/openai_responses_client_with_agent_as_tool.py index d37d5a9b4a..774231d0d6 100644 --- a/python/samples/getting_started/agents/openai/openai_responses_client_with_agent_as_tool.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_agent_as_tool.py @@ -19,13 +19,13 @@ multiple specialized agents, each focusing on specific tasks. async def logging_middleware( context: FunctionInvocationContext, - call_next: Callable[[FunctionInvocationContext], Awaitable[None]], + call_next: Callable[[], Awaitable[None]], ) -> None: """MiddlewareTypes that logs tool invocations to show the delegation flow.""" print(f"[Calling tool: {context.function.name}]") print(f"[Request: {context.arguments}]") - await call_next(context) + await call_next() print(f"[Response: {context.result}]") diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_code_interpreter.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter.py similarity index 72% rename from python/samples/getting_started/agents/openai/openai_responses_client_with_code_interpreter.py rename to python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter.py index 71d81d9ba8..915915bc90 100644 --- a/python/samples/getting_started/agents/openai/openai_responses_client_with_code_interpreter.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter.py @@ -3,27 +3,28 @@ import asyncio from agent_framework import ( - ChatAgent, - HostedCodeInterpreterTool, + Agent, + Content, ) from agent_framework.openai import OpenAIResponsesClient """ OpenAI Responses Client with Code Interpreter Example -This sample demonstrates using HostedCodeInterpreterTool with OpenAI Responses Client +This sample demonstrates using get_code_interpreter_tool() with OpenAI Responses Client for Python code execution and mathematical problem solving. """ async def main() -> None: - """Example showing how to use the HostedCodeInterpreterTool with OpenAI Responses.""" + """Example showing how to use the code interpreter tool with OpenAI Responses.""" print("=== OpenAI Responses Agent with Code Interpreter Example ===") - agent = ChatAgent( - chat_client=OpenAIResponsesClient(), + client = OpenAIResponsesClient() + agent = Agent( + client=client, instructions="You are a helpful assistant that can write and execute Python code to solve problems.", - tools=HostedCodeInterpreterTool(), + tools=client.get_code_interpreter_tool(), ) query = "Use code to get the factorial of 100?" @@ -34,16 +35,17 @@ async def main() -> None: for message in result.messages: code_blocks = [c for c in message.contents if c.type == "code_interpreter_tool_call"] outputs = [c for c in message.contents if c.type == "code_interpreter_tool_result"] + if code_blocks: code_inputs = code_blocks[0].inputs or [] for content in code_inputs: - if content.type == "text": + if isinstance(content, Content) and content.type == "text": print(f"Generated code:\n{content.text}") break if outputs: print("Execution outputs:") for out in outputs[0].outputs or []: - if out.type == "text": + if isinstance(out, Content) and out.type == "text": print(out.text) diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_code_interpreter_files.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter_files.py similarity index 89% rename from python/samples/getting_started/agents/openai/openai_responses_client_with_code_interpreter_files.py rename to python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter_files.py index f3d311e307..195c162c5c 100644 --- a/python/samples/getting_started/agents/openai/openai_responses_client_with_code_interpreter_files.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter_files.py @@ -4,14 +4,14 @@ import asyncio import os import tempfile -from agent_framework import ChatAgent, HostedCodeInterpreterTool +from agent_framework import Agent from agent_framework.openai import OpenAIResponsesClient from openai import AsyncOpenAI """ OpenAI Responses Client with Code Interpreter and Files Example -This sample demonstrates using HostedCodeInterpreterTool with OpenAI Responses Client +This sample demonstrates using get_code_interpreter_tool() with OpenAI Responses Client for Python code execution and data analysis with uploaded files. """ @@ -66,10 +66,11 @@ async def main() -> None: temp_file_path, file_id = await create_sample_file_and_upload(openai_client) # Create agent using OpenAI Responses client - agent = ChatAgent( - chat_client=OpenAIResponsesClient(), + client = OpenAIResponsesClient() + agent = Agent( + client=client, instructions="You are a helpful assistant that can analyze data files using Python code.", - tools=HostedCodeInterpreterTool(inputs=[{"file_id": file_id}]), + tools=client.get_code_interpreter_tool(file_ids=[file_id]), ) # Test the code interpreter with the uploaded file diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_explicit_settings.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_explicit_settings.py similarity index 88% rename from python/samples/getting_started/agents/openai/openai_responses_client_with_explicit_settings.py rename to python/samples/02-agents/providers/openai/openai_responses_client_with_explicit_settings.py index 826fd880bf..9aeba0f009 100644 --- a/python/samples/getting_started/agents/openai/openai_responses_client_with_explicit_settings.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_explicit_settings.py @@ -17,7 +17,9 @@ settings rather than relying on environment variable defaults. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_file_search.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_file_search.py similarity index 83% rename from python/samples/getting_started/agents/openai/openai_responses_client_with_file_search.py rename to python/samples/02-agents/providers/openai/openai_responses_client_with_file_search.py index 3784c5a715..daa0d24e38 100644 --- a/python/samples/getting_started/agents/openai/openai_responses_client_with_file_search.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_file_search.py @@ -2,13 +2,13 @@ import asyncio -from agent_framework import ChatAgent, Content, HostedFileSearchTool +from agent_framework import Agent, Content from agent_framework.openai import OpenAIResponsesClient """ OpenAI Responses Client with File Search Example -This sample demonstrates using HostedFileSearchTool with OpenAI Responses Client +This sample demonstrates using get_file_search_tool() with OpenAI Responses Client for direct document-based question answering and information retrieval. """ @@ -33,7 +33,6 @@ async def create_vector_store(client: OpenAIResponsesClient) -> tuple[str, Conte async def delete_vector_store(client: OpenAIResponsesClient, file_id: str, vector_store_id: str) -> None: """Delete the vector store after using it.""" - await client.client.vector_stores.delete(vector_store_id=vector_store_id) await client.client.files.delete(file_id=file_id) @@ -45,12 +44,12 @@ async def main() -> None: stream = False print(f"User: {message}") - file_id, vector_store = await create_vector_store(client) + file_id, vector_store_id = await create_vector_store(client) - agent = ChatAgent( - chat_client=client, + agent = Agent( + client=client, instructions="You are a helpful assistant that can search through files to find information.", - tools=[HostedFileSearchTool(inputs=vector_store)], + tools=[client.get_file_search_tool(vector_store_ids=[vector_store_id])], ) if stream: @@ -62,7 +61,7 @@ async def main() -> None: else: response = await agent.run(message) print(f"Assistant: {response}") - await delete_vector_store(client, file_id, vector_store.vector_store_id) + await delete_vector_store(client, file_id, vector_store_id) if __name__ == "__main__": diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_function_tools.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_function_tools.py similarity index 90% rename from python/samples/getting_started/agents/openai/openai_responses_client_with_function_tools.py rename to python/samples/02-agents/providers/openai/openai_responses_client_with_function_tools.py index 032a8b20d8..5884467650 100644 --- a/python/samples/getting_started/agents/openai/openai_responses_client_with_function_tools.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_function_tools.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from random import randint from typing import Annotated -from agent_framework import ChatAgent, tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIResponsesClient from pydantic import Field @@ -17,7 +17,9 @@ showing both agent-level and query-level tool configuration patterns. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -40,8 +42,8 @@ async def tools_on_agent_level() -> None: # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime - agent = ChatAgent( - chat_client=OpenAIResponsesClient(), + agent = Agent( + client=OpenAIResponsesClient(), instructions="You are a helpful assistant that can provide weather and time information.", tools=[get_weather, get_time], # Tools defined at agent creation ) @@ -70,8 +72,8 @@ async def tools_on_run_level() -> None: print("=== Tools Passed to Run Method ===") # Agent created without tools - agent = ChatAgent( - chat_client=OpenAIResponsesClient(), + agent = Agent( + client=OpenAIResponsesClient(), instructions="You are a helpful assistant.", # No tools defined here ) @@ -100,8 +102,8 @@ async def mixed_tools_example() -> None: print("=== Mixed Tools Example (Agent + Run Method) ===") # Agent created with some base tools - agent = ChatAgent( - chat_client=OpenAIResponsesClient(), + agent = Agent( + client=OpenAIResponsesClient(), instructions="You are a comprehensive assistant that can help with various information requests.", tools=[get_weather], # Base tool available for all queries ) diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_hosted_mcp.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_hosted_mcp.py similarity index 50% rename from python/samples/getting_started/agents/openai/openai_responses_client_with_hosted_mcp.py rename to python/samples/02-agents/providers/openai/openai_responses_client_with_hosted_mcp.py index 526503f813..6c27ea36a9 100644 --- a/python/samples/getting_started/agents/openai/openai_responses_client_with_hosted_mcp.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_hosted_mcp.py @@ -3,7 +3,7 @@ import asyncio from typing import TYPE_CHECKING, Any -from agent_framework import ChatAgent, HostedMCPTool +from agent_framework import Agent from agent_framework.openai import OpenAIResponsesClient """ @@ -14,12 +14,12 @@ OpenAI Responses Client, including user approval workflows for function call sec """ if TYPE_CHECKING: - from agent_framework import AgentThread, SupportsAgentRun + from agent_framework import AgentSession, SupportsAgentRun -async def handle_approvals_without_thread(query: str, agent: "SupportsAgentRun"): - """When we don't have a thread, we need to ensure we return with the input, approval request and approval.""" - from agent_framework import ChatMessage +async def handle_approvals_without_session(query: str, agent: "SupportsAgentRun"): + """When we don't have a session, we need to ensure we return with the input, approval request and approval.""" + from agent_framework import Message result = await agent.run(query) while len(result.user_input_requests) > 0: @@ -29,21 +29,24 @@ async def handle_approvals_without_thread(query: str, agent: "SupportsAgentRun") f"User Input Request for function from {agent.name}: {user_input_needed.function_call.name}" f" with arguments: {user_input_needed.function_call.arguments}" ) - new_inputs.append(ChatMessage(role="assistant", contents=[user_input_needed])) + new_inputs.append(Message(role="assistant", contents=[user_input_needed])) user_approval = input("Approve function call? (y/n): ") new_inputs.append( - ChatMessage(role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")]) + Message( + role="user", + contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")], + ) ) result = await agent.run(new_inputs) return result -async def handle_approvals_with_thread(query: str, agent: "SupportsAgentRun", thread: "AgentThread"): - """Here we let the thread deal with the previous responses, and we just rerun with the approval.""" - from agent_framework import ChatMessage +async def handle_approvals_with_session(query: str, agent: "SupportsAgentRun", session: "AgentSession"): + """Here we let the session deal with the previous responses, and we just rerun with the approval.""" + from agent_framework import Message - result = await agent.run(query, thread=thread, store=True) + result = await agent.run(query, session=session, store=True) while len(result.user_input_requests) > 0: new_input: list[Any] = [] for user_input_needed in result.user_input_requests: @@ -53,25 +56,25 @@ async def handle_approvals_with_thread(query: str, agent: "SupportsAgentRun", th ) user_approval = input("Approve function call? (y/n): ") new_input.append( - ChatMessage( + Message( role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")], ) ) - result = await agent.run(new_input, thread=thread, store=True) + result = await agent.run(new_input, session=session, store=True) return result -async def handle_approvals_with_thread_streaming(query: str, agent: "SupportsAgentRun", thread: "AgentThread"): - """Here we let the thread deal with the previous responses, and we just rerun with the approval.""" - from agent_framework import ChatMessage +async def handle_approvals_with_session_streaming(query: str, agent: "SupportsAgentRun", session: "AgentSession"): + """Here we let the session deal with the previous responses, and we just rerun with the approval.""" + from agent_framework import Message - new_input: list[ChatMessage] = [] + new_input: list[Message] = [] new_input_added = True while new_input_added: new_input_added = False - new_input.append(ChatMessage(role="user", text=query)) - async for update in agent.run(new_input, thread=thread, stream=True, options={"store": True}): + new_input.append(Message(role="user", text=query)) + async for update in agent.run(new_input, session=session, stream=True, options={"store": True}): if update.user_input_requests: for user_input_needed in update.user_input_requests: print( @@ -80,8 +83,9 @@ async def handle_approvals_with_thread_streaming(query: str, agent: "SupportsAge ) user_approval = input("Approve function call? (y/n): ") new_input.append( - ChatMessage( - role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")] + Message( + role="user", + contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")], ) ) new_input_added = True @@ -89,34 +93,36 @@ async def handle_approvals_with_thread_streaming(query: str, agent: "SupportsAge yield update -async def run_hosted_mcp_without_thread_and_specific_approval() -> None: - """Example showing Mcp Tools with approvals without using a thread.""" - print("=== Mcp with approvals and without thread ===") +async def run_hosted_mcp_without_session_and_specific_approval() -> None: + """Example showing Mcp Tools with approvals without using a session.""" + print("=== Mcp with approvals and without session ===") - # Tools are provided when creating the agent - # The agent can use these tools for any query during its lifetime - async with ChatAgent( - chat_client=OpenAIResponsesClient(), + client = OpenAIResponsesClient() + # Create MCP tool with specific approval mode + mcp_tool = client.get_mcp_tool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", + # we don't require approval for microsoft_docs_search tool calls + # but we do for any other tool + approval_mode={"never_require_approval": ["microsoft_docs_search"]}, + ) + + async with Agent( + client=client, name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=HostedMCPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - # we don't require approval for microsoft_docs_search tool calls - # but we do for any other tool - approval_mode={"never_require_approval": ["microsoft_docs_search"]}, - ), + tools=mcp_tool, ) as agent: # First query query1 = "How to create an Azure storage account using az cli?" print(f"User: {query1}") - result1 = await handle_approvals_without_thread(query1, agent) + result1 = await handle_approvals_without_session(query1, agent) print(f"{agent.name}: {result1}\n") print("\n=======================================\n") # Second query query2 = "What is Microsoft Agent Framework?" print(f"User: {query2}") - result2 = await handle_approvals_without_thread(query2, agent) + result2 = await handle_approvals_without_session(query2, agent) print(f"{agent.name}: {result2}\n") @@ -124,88 +130,92 @@ async def run_hosted_mcp_without_approval() -> None: """Example showing Mcp Tools without approvals.""" print("=== Mcp without approvals ===") - # Tools are provided when creating the agent - # The agent can use these tools for any query during its lifetime - async with ChatAgent( - chat_client=OpenAIResponsesClient(), + client = OpenAIResponsesClient() + # Create MCP tool that never requires approval + mcp_tool = client.get_mcp_tool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", + # we don't require approval for any function calls + approval_mode="never_require", + ) + + async with Agent( + client=client, name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=HostedMCPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - # we don't require approval for any function calls - # this means we will not see the approval messages, - # it is fully handled by the service and a final response is returned. - approval_mode="never_require", - ), + tools=mcp_tool, ) as agent: # First query query1 = "How to create an Azure storage account using az cli?" print(f"User: {query1}") - result1 = await handle_approvals_without_thread(query1, agent) + result1 = await handle_approvals_without_session(query1, agent) print(f"{agent.name}: {result1}\n") print("\n=======================================\n") # Second query query2 = "What is Microsoft Agent Framework?" print(f"User: {query2}") - result2 = await handle_approvals_without_thread(query2, agent) + result2 = await handle_approvals_without_session(query2, agent) print(f"{agent.name}: {result2}\n") -async def run_hosted_mcp_with_thread() -> None: - """Example showing Mcp Tools with approvals using a thread.""" - print("=== Mcp with approvals and with thread ===") +async def run_hosted_mcp_with_session() -> None: + """Example showing Mcp Tools with approvals using a session.""" + print("=== Mcp with approvals and with session ===") - # Tools are provided when creating the agent - # The agent can use these tools for any query during its lifetime - async with ChatAgent( - chat_client=OpenAIResponsesClient(), + client = OpenAIResponsesClient() + # Create MCP tool that always requires approval + mcp_tool = client.get_mcp_tool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", + # we require approval for all function calls + approval_mode="always_require", + ) + + async with Agent( + client=client, name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=HostedMCPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - # we require approval for all function calls - approval_mode="always_require", - ), + tools=mcp_tool, ) as agent: # First query - thread = agent.get_new_thread() + session = agent.create_session() query1 = "How to create an Azure storage account using az cli?" print(f"User: {query1}") - result1 = await handle_approvals_with_thread(query1, agent, thread) + result1 = await handle_approvals_with_session(query1, agent, session) print(f"{agent.name}: {result1}\n") print("\n=======================================\n") # Second query query2 = "What is Microsoft Agent Framework?" print(f"User: {query2}") - result2 = await handle_approvals_with_thread(query2, agent, thread) + result2 = await handle_approvals_with_session(query2, agent, session) print(f"{agent.name}: {result2}\n") -async def run_hosted_mcp_with_thread_streaming() -> None: - """Example showing Mcp Tools with approvals using a thread.""" - print("=== Mcp with approvals and with thread ===") +async def run_hosted_mcp_with_session_streaming() -> None: + """Example showing Mcp Tools with approvals using a session.""" + print("=== Mcp with approvals and with session ===") - # Tools are provided when creating the agent - # The agent can use these tools for any query during its lifetime - async with ChatAgent( - chat_client=OpenAIResponsesClient(), + client = OpenAIResponsesClient() + # Create MCP tool that always requires approval + mcp_tool = client.get_mcp_tool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", + # we require approval for all function calls + approval_mode="always_require", + ) + + async with Agent( + client=client, name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=HostedMCPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - # we require approval for all function calls - approval_mode="always_require", - ), + tools=mcp_tool, ) as agent: # First query - thread = agent.get_new_thread() + session = agent.create_session() query1 = "How to create an Azure storage account using az cli?" print(f"User: {query1}") print(f"{agent.name}: ", end="") - async for update in handle_approvals_with_thread_streaming(query1, agent, thread): + async for update in handle_approvals_with_session_streaming(query1, agent, session): print(update, end="") print("\n") print("\n=======================================\n") @@ -213,7 +223,7 @@ async def run_hosted_mcp_with_thread_streaming() -> None: query2 = "What is Microsoft Agent Framework?" print(f"User: {query2}") print(f"{agent.name}: ", end="") - async for update in handle_approvals_with_thread_streaming(query2, agent, thread): + async for update in handle_approvals_with_session_streaming(query2, agent, session): print(update, end="") print("\n") @@ -222,9 +232,9 @@ async def main() -> None: print("=== OpenAI Responses Client Agent with Hosted Mcp Tools Examples ===\n") await run_hosted_mcp_without_approval() - await run_hosted_mcp_without_thread_and_specific_approval() - await run_hosted_mcp_with_thread() - await run_hosted_mcp_with_thread_streaming() + await run_hosted_mcp_without_session_and_specific_approval() + await run_hosted_mcp_with_session() + await run_hosted_mcp_with_session_streaming() if __name__ == "__main__": diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_local_mcp.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_local_mcp.py similarity index 94% rename from python/samples/getting_started/agents/openai/openai_responses_client_with_local_mcp.py rename to python/samples/02-agents/providers/openai/openai_responses_client_with_local_mcp.py index 50ebcf9ad7..1b1e55c28d 100644 --- a/python/samples/getting_started/agents/openai/openai_responses_client_with_local_mcp.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_local_mcp.py @@ -2,7 +2,7 @@ import asyncio -from agent_framework import ChatAgent, MCPStreamableHTTPTool +from agent_framework import Agent, MCPStreamableHTTPTool from agent_framework.openai import OpenAIResponsesClient """ @@ -22,8 +22,8 @@ async def streaming_with_mcp(show_raw_stream: bool = False) -> None: print("=== Tools Defined on Agent Level ===") # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime - async with ChatAgent( - chat_client=OpenAIResponsesClient(), + async with Agent( + client=OpenAIResponsesClient(), name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", tools=MCPStreamableHTTPTool( # Tools defined at agent creation @@ -60,8 +60,8 @@ async def run_with_mcp() -> None: # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime - async with ChatAgent( - chat_client=OpenAIResponsesClient(), + async with Agent( + client=OpenAIResponsesClient(), name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", tools=MCPStreamableHTTPTool( # Tools defined at agent creation diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_runtime_json_schema.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_runtime_json_schema.py similarity index 100% rename from python/samples/getting_started/agents/openai/openai_responses_client_with_runtime_json_schema.py rename to python/samples/02-agents/providers/openai/openai_responses_client_with_runtime_json_schema.py diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_session.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_session.py new file mode 100644 index 0000000000..30866cc5da --- /dev/null +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_session.py @@ -0,0 +1,147 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from random import randint +from typing import Annotated + +from agent_framework import Agent, AgentSession, tool +from agent_framework.openai import OpenAIResponsesClient +from pydantic import Field + +""" +OpenAI Responses Client with Session Management Example + +This sample demonstrates session management with OpenAI Responses Client, showing +persistent conversation context and simplified response handling. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def example_with_automatic_session_creation() -> None: + """Example showing automatic session creation.""" + print("=== Automatic Session Creation Example ===") + + agent = Agent( + client=OpenAIResponsesClient(), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # First conversation - no session provided, will be created automatically + query1 = "What's the weather like in Seattle?" + print(f"User: {query1}") + result1 = await agent.run(query1) + print(f"Agent: {result1.text}") + + # Second conversation - still no session provided, will create another new session + query2 = "What was the last city I asked about?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2) + print(f"Agent: {result2.text}") + print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") + + +async def example_with_session_persistence_in_memory() -> None: + """ + Example showing session persistence across multiple conversations. + In this example, messages are stored in-memory. + """ + print("=== Session Persistence Example (In-Memory) ===") + + agent = Agent( + client=OpenAIResponsesClient(), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Create a new session that will be reused + session = agent.create_session() + + # First conversation + query1 = "What's the weather like in Tokyo?" + print(f"User: {query1}") + result1 = await agent.run(query1, session=session, store=False) + print(f"Agent: {result1.text}") + + # Second conversation using the same session - maintains context + query2 = "How about London?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2, session=session, store=False) + print(f"Agent: {result2.text}") + + # Third conversation - agent should remember both previous cities + query3 = "Which of the cities I asked about has better weather?" + print(f"\nUser: {query3}") + result3 = await agent.run(query3, session=session, store=False) + print(f"Agent: {result3.text}") + print("Note: The agent remembers context from previous messages in the same session.\n") + + +async def example_with_existing_session_id() -> None: + """ + Example showing how to work with an existing session ID from the service. + In this example, messages are stored on the server using OpenAI conversation state. + """ + print("=== Existing Session ID Example ===") + + # First, create a conversation and capture the session ID + existing_session_id = None + + agent = Agent( + client=OpenAIResponsesClient(), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Start a conversation and get the session ID + session = agent.create_session() + + query1 = "What's the weather in Paris?" + print(f"User: {query1}") + result1 = await agent.run(query1, session=session) + print(f"Agent: {result1.text}") + + # The session ID is set after the first response + existing_session_id = session.service_session_id + print(f"Session ID: {existing_session_id}") + + if existing_session_id: + print("\n--- Continuing with the same session ID in a new agent instance ---") + + agent = Agent( + client=OpenAIResponsesClient(), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Create a session with the existing ID + session = AgentSession(service_session_id=existing_session_id) + + query2 = "What was the last city I asked about?" + print(f"User: {query2}") + result2 = await agent.run(query2, session=session) + print(f"Agent: {result2.text}") + print("Note: The agent continues the conversation from the previous session by using session ID.\n") + + +async def main() -> None: + print("=== OpenAI Response Client Agent Session Management Examples ===\n") + + await example_with_automatic_session_creation() + await example_with_session_persistence_in_memory() + await example_with_existing_session_id() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_structured_output.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_structured_output.py similarity index 100% rename from python/samples/getting_started/agents/openai/openai_responses_client_with_structured_output.py rename to python/samples/02-agents/providers/openai/openai_responses_client_with_structured_output.py diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_web_search.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_web_search.py similarity index 63% rename from python/samples/getting_started/agents/openai/openai_responses_client_with_web_search.py rename to python/samples/02-agents/providers/openai/openai_responses_client_with_web_search.py index 24e0368512..26d148901c 100644 --- a/python/samples/getting_started/agents/openai/openai_responses_client_with_web_search.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_web_search.py @@ -2,30 +2,29 @@ import asyncio -from agent_framework import ChatAgent, HostedWebSearchTool +from agent_framework import Agent from agent_framework.openai import OpenAIResponsesClient """ OpenAI Responses Client with Web Search Example -This sample demonstrates using HostedWebSearchTool with OpenAI Responses Client +This sample demonstrates using get_web_search_tool() with OpenAI Responses Client for direct real-time information retrieval and current data access. """ async def main() -> None: - # Test that the agent will use the web search tool with location - additional_properties = { - "user_location": { - "country": "US", - "city": "Seattle", - } - } + client = OpenAIResponsesClient() - agent = ChatAgent( - chat_client=OpenAIResponsesClient(), + # Create web search tool with location context + web_search_tool = client.get_web_search_tool( + user_location={"city": "Seattle", "country": "US"}, + ) + + agent = Agent( + client=client, instructions="You are a helpful assistant that can search the web for current information.", - tools=[HostedWebSearchTool(additional_properties=additional_properties)], + tools=[web_search_tool], ) message = "What is the current weather? Do not ask for my current location." diff --git a/python/samples/concepts/response_stream.py b/python/samples/02-agents/response_stream.py similarity index 97% rename from python/samples/concepts/response_stream.py rename to python/samples/02-agents/response_stream.py index 6d99058062..1b26ac5e90 100644 --- a/python/samples/concepts/response_stream.py +++ b/python/samples/02-agents/response_stream.py @@ -94,9 +94,9 @@ final = await response_stream.get_final_response() # Get the aggregated result === Chaining with .map() and .with_finalizer() === -When building a ChatAgent on top of a ChatClient, we face a challenge: +When building a Agent on top of a ChatClient, we face a challenge: - The ChatClient returns a ResponseStream[ChatResponseUpdate, ChatResponse] -- The ChatAgent needs to return a ResponseStream[AgentResponseUpdate, AgentResponse] +- The Agent needs to return a ResponseStream[AgentResponseUpdate, AgentResponse] - We can't iterate the ChatClient's stream twice! The `.map()` and `.with_finalizer()` methods solve this by creating new ResponseStreams that: @@ -123,8 +123,8 @@ provider notifications, telemetry, thread updates) are still executed even when stream is wrapped/mapped. ```python -# ChatAgent does something like this internally: -chat_stream = chat_client.get_response(messages, stream=True) +# Agent does something like this internally: +chat_stream = client.get_response(messages, stream=True) agent_stream = ( chat_stream .map(_to_agent_update, _to_agent_response) @@ -135,7 +135,7 @@ agent_stream = ( This ensures: - The underlying ChatClient stream is only consumed once - The agent can add its own transform hooks, result hooks, and cleanup logic -- Each layer (ChatClient, ChatAgent, middleware) can add independent behavior +- Each layer (ChatClient, Agent, middleware) can add independent behavior - Inner stream post-processing (like context provider notification) still runs - Types flow naturally through the chain """ @@ -281,7 +281,7 @@ async def main() -> None: # Simulate what ChatClient returns inner_stream = ResponseStream(generate_updates(), finalizer=combine_updates) - # Simulate what ChatAgent does: wrap the inner stream + # Simulate what Agent does: wrap the inner stream def to_agent_format(update: ChatResponseUpdate) -> ChatResponseUpdate: """Map ChatResponseUpdate to agent format (simulated transformation).""" # In real code, this would convert to AgentResponseUpdate diff --git a/python/samples/getting_started/tools/function_invocation_configuration.py b/python/samples/02-agents/tools/function_invocation_configuration.py similarity index 90% rename from python/samples/getting_started/tools/function_invocation_configuration.py rename to python/samples/02-agents/tools/function_invocation_configuration.py index b6cb27a7bc..81318484e6 100644 --- a/python/samples/getting_started/tools/function_invocation_configuration.py +++ b/python/samples/02-agents/tools/function_invocation_configuration.py @@ -14,7 +14,7 @@ This behavior is the same for all chat client types. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def add( x: Annotated[int, "First number"], diff --git a/python/samples/getting_started/tools/function_tool_declaration_only.py b/python/samples/02-agents/tools/function_tool_declaration_only.py similarity index 100% rename from python/samples/getting_started/tools/function_tool_declaration_only.py rename to python/samples/02-agents/tools/function_tool_declaration_only.py diff --git a/python/samples/getting_started/tools/function_tool_from_dict_with_dependency_injection.py b/python/samples/02-agents/tools/function_tool_from_dict_with_dependency_injection.py similarity index 100% rename from python/samples/getting_started/tools/function_tool_from_dict_with_dependency_injection.py rename to python/samples/02-agents/tools/function_tool_from_dict_with_dependency_injection.py diff --git a/python/samples/getting_started/tools/function_tool_recover_from_failures.py b/python/samples/02-agents/tools/function_tool_recover_from_failures.py similarity index 75% rename from python/samples/getting_started/tools/function_tool_recover_from_failures.py rename to python/samples/02-agents/tools/function_tool_recover_from_failures.py index 8c38a81e77..8f1cde5d14 100644 --- a/python/samples/getting_started/tools/function_tool_recover_from_failures.py +++ b/python/samples/02-agents/tools/function_tool_recover_from_failures.py @@ -14,7 +14,7 @@ The LLM decides whether to retry the call or to respond with something else, bas """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def greet(name: Annotated[str, "Name to greet"]) -> str: """Greet someone.""" @@ -44,29 +44,28 @@ async def main(): instructions="Use the provided tools.", tools=[greet, safe_divide], ) - thread = agent.get_new_thread() + session = agent.create_session() print("=" * 60) print("Step 1: Call divide(10, 0) - tool raises exception") - response = await agent.run("Divide 10 by 0", thread=thread) + response = await agent.run("Divide 10 by 0", session=session) print(f"Response: {response.text}") print("=" * 60) print("Step 2: Call greet('Bob') - conversation can keep going.") - response = await agent.run("Greet Bob", thread=thread) + response = await agent.run("Greet Bob", session=session) print(f"Response: {response.text}") print("=" * 60) - print("Replay the conversation:") - assert thread.message_store - assert thread.message_store.list_messages - for idx, msg in enumerate(await thread.message_store.list_messages()): - if msg.text: - print(f"{idx + 1} {msg.author_name or msg.role}: {msg.text} ") - for content in msg.contents: - if content.type == "function_call": - print( - f"{idx + 1} {msg.author_name}: calling function: {content.name} with arguments: {content.arguments}" - ) - if content.type == "function_result": - print(f"{idx + 1} {msg.role}: {content.result if content.result else content.exception}") + # TODO: Use history providers to replay the conversation + # print("Replay the conversation:") + # for idx, msg in enumerate(messages): + # if msg.text: + # print(f"{idx + 1} {msg.author_name or msg.role}: {msg.text} ") + # for content in msg.contents: + # if content.type == "function_call": + # print( + # f"{idx + 1} {msg.author_name}: calling function: {content.name} with arguments: {content.arguments}" + # ) + # if content.type == "function_result": + # print(f"{idx + 1} {msg.role}: {content.result if content.result else content.exception}") """ diff --git a/python/samples/getting_started/tools/function_tool_with_approval.py b/python/samples/02-agents/tools/function_tool_with_approval.py similarity index 89% rename from python/samples/getting_started/tools/function_tool_with_approval.py rename to python/samples/02-agents/tools/function_tool_with_approval.py index 4a76c631e6..e87b3da462 100644 --- a/python/samples/getting_started/tools/function_tool_with_approval.py +++ b/python/samples/02-agents/tools/function_tool_with_approval.py @@ -4,7 +4,7 @@ import asyncio from random import randrange from typing import TYPE_CHECKING, Annotated, Any -from agent_framework import AgentResponse, ChatAgent, ChatMessage, tool +from agent_framework import Agent, AgentResponse, Message, tool from agent_framework.openai import OpenAIResponsesClient if TYPE_CHECKING: @@ -20,7 +20,7 @@ It shows how to handle function call approvals without using threads. conditions = ["sunny", "cloudy", "raining", "snowing", "clear"] -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather(location: Annotated[str, "The city and state, e.g. San Francisco, CA"]) -> str: """Get the current weather for a given location.""" @@ -59,14 +59,14 @@ async def handle_approvals(query: str, agent: "SupportsAgentRun") -> AgentRespon ) # Add the assistant message with the approval request - new_inputs.append(ChatMessage("assistant", [user_input_needed])) + new_inputs.append(Message("assistant", [user_input_needed])) # Get user approval user_approval = await asyncio.to_thread(input, "\nApprove function call? (y/n): ") # Add the user's approval response new_inputs.append( - ChatMessage("user", [user_input_needed.to_function_approval_response(user_approval.lower() == "y")]) + Message("user", [user_input_needed.to_function_approval_response(user_approval.lower() == "y")]) ) # Run again with all the context @@ -109,14 +109,14 @@ async def handle_approvals_streaming(query: str, agent: "SupportsAgentRun") -> N ) # Add the assistant message with the approval request - new_inputs.append(ChatMessage("assistant", [user_input_needed])) + new_inputs.append(Message("assistant", [user_input_needed])) # Get user approval user_approval = await asyncio.to_thread(input, "\nApprove function call? (y/n): ") # Add the user's approval response new_inputs.append( - ChatMessage("user", [user_input_needed.to_function_approval_response(user_approval.lower() == "y")]) + Message("user", [user_input_needed.to_function_approval_response(user_approval.lower() == "y")]) ) # Update input with all the context for next iteration @@ -127,8 +127,8 @@ async def run_weather_agent_with_approval(stream: bool) -> None: """Example showing AI function with approval requirement.""" print(f"\n=== Weather Agent with Approval Required ({'Streaming' if stream else 'Non-Streaming'}) ===\n") - async with ChatAgent( - chat_client=OpenAIResponsesClient(), + async with Agent( + client=OpenAIResponsesClient(), name="WeatherAgent", instructions=("You are a helpful weather assistant. Use the get_weather tool to provide weather information."), tools=[get_weather, get_weather_detail], diff --git a/python/samples/getting_started/tools/function_tool_with_approval_and_threads.py b/python/samples/02-agents/tools/function_tool_with_approval_and_sessions.py similarity index 71% rename from python/samples/getting_started/tools/function_tool_with_approval_and_threads.py rename to python/samples/02-agents/tools/function_tool_with_approval_and_sessions.py index de1da05991..004a182876 100644 --- a/python/samples/getting_started/tools/function_tool_with_approval_and_threads.py +++ b/python/samples/02-agents/tools/function_tool_with_approval_and_sessions.py @@ -3,15 +3,15 @@ import asyncio from typing import Annotated -from agent_framework import ChatAgent, ChatMessage, tool +from agent_framework import Agent, Message, tool from agent_framework.azure import AzureOpenAIChatClient """ -Tool Approvals with Threads +Tool Approvals with Sessions -This sample demonstrates using tool approvals with threads. -With threads, you don't need to manually pass previous messages - -the thread stores and retrieves them automatically. +This sample demonstrates using tool approvals with sessions. +With sessions, you don't need to manually pass previous messages - +the session stores and retrieves them automatically. """ @@ -25,22 +25,22 @@ def add_to_calendar( async def approval_example() -> None: - """Example showing approval with threads.""" - print("=== Tool Approval with Thread ===\n") + """Example showing approval with sessions.""" + print("=== Tool Approval with Session ===\n") - agent = ChatAgent( - chat_client=AzureOpenAIChatClient(), + agent = Agent( + client=AzureOpenAIChatClient(), name="CalendarAgent", instructions="You are a helpful calendar assistant.", tools=[add_to_calendar], ) - thread = agent.get_new_thread() + session = agent.create_session() # Step 1: Agent requests to call the tool query = "Add a dentist appointment on March 15th" print(f"User: {query}") - result = await agent.run(query, thread=thread) + result = await agent.run(query, session=session) # Check for approval requests if result.user_input_requests: @@ -55,27 +55,27 @@ async def approval_example() -> None: # Step 2: Send approval response approval_response = request.to_function_approval_response(approved=approved) - result = await agent.run(ChatMessage("user", [approval_response]), thread=thread) + result = await agent.run(Message("user", [approval_response]), session=session) print(f"Agent: {result}\n") async def rejection_example() -> None: - """Example showing rejection with threads.""" - print("=== Tool Rejection with Thread ===\n") + """Example showing rejection with sessions.""" + print("=== Tool Rejection with Session ===\n") - agent = ChatAgent( - chat_client=AzureOpenAIChatClient(), + agent = Agent( + client=AzureOpenAIChatClient(), name="CalendarAgent", instructions="You are a helpful calendar assistant.", tools=[add_to_calendar], ) - thread = agent.get_new_thread() + session = agent.create_session() query = "Add a team meeting on December 20th" print(f"User: {query}") - result = await agent.run(query, thread=thread) + result = await agent.run(query, session=session) if result.user_input_requests: for request in result.user_input_requests: @@ -88,7 +88,7 @@ async def rejection_example() -> None: # Send rejection response rejection_response = request.to_function_approval_response(approved=False) - result = await agent.run(ChatMessage("user", [rejection_response]), thread=thread) + result = await agent.run(Message("user", [rejection_response]), session=session) print(f"Agent: {result}\n") diff --git a/python/samples/getting_started/tools/function_tool_with_explicit_schema.py b/python/samples/02-agents/tools/function_tool_with_explicit_schema.py similarity index 100% rename from python/samples/getting_started/tools/function_tool_with_explicit_schema.py rename to python/samples/02-agents/tools/function_tool_with_explicit_schema.py diff --git a/python/samples/getting_started/tools/function_tool_with_kwargs.py b/python/samples/02-agents/tools/function_tool_with_kwargs.py similarity index 90% rename from python/samples/getting_started/tools/function_tool_with_kwargs.py rename to python/samples/02-agents/tools/function_tool_with_kwargs.py index 59225c0832..15dd597354 100644 --- a/python/samples/getting_started/tools/function_tool_with_kwargs.py +++ b/python/samples/02-agents/tools/function_tool_with_kwargs.py @@ -20,7 +20,7 @@ or provide. # Define the function tool with **kwargs to accept injected arguments -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/getting_started/tools/function_tool_with_max_exceptions.py b/python/samples/02-agents/tools/function_tool_with_max_exceptions.py similarity index 87% rename from python/samples/getting_started/tools/function_tool_with_max_exceptions.py rename to python/samples/02-agents/tools/function_tool_with_max_exceptions.py index 7e60487704..89d883174c 100644 --- a/python/samples/getting_started/tools/function_tool_with_max_exceptions.py +++ b/python/samples/02-agents/tools/function_tool_with_max_exceptions.py @@ -36,31 +36,30 @@ async def main(): instructions="Use the provided tools.", tools=[safe_divide], ) - thread = agent.get_new_thread() + session = agent.create_session() print("=" * 60) print("Step 1: Call divide(10, 0) - tool raises exception") - response = await agent.run("Divide 10 by 0", thread=thread) + response = await agent.run("Divide 10 by 0", session=session) print(f"Response: {response.text}") print("=" * 60) print("Step 2: Call divide(100, 0) - will refuse to execute due to max_invocation_exceptions") - response = await agent.run("Divide 100 by 0", thread=thread) + response = await agent.run("Divide 100 by 0", session=session) print(f"Response: {response.text}") print("=" * 60) print(f"Number of tool calls attempted: {safe_divide.invocation_count}") print(f"Number of tool calls failed: {safe_divide.invocation_exception_count}") - print("Replay the conversation:") - assert thread.message_store - assert thread.message_store.list_messages - for idx, msg in enumerate(await thread.message_store.list_messages()): - if msg.text: - print(f"{idx + 1} {msg.author_name or msg.role}: {msg.text} ") - for content in msg.contents: - if content.type == "function_call": - print( - f"{idx + 1} {msg.author_name}: calling function: {content.name} with arguments: {content.arguments}" - ) - if content.type == "function_result": - print(f"{idx + 1} {msg.role}: {content.result if content.result else content.exception}") + # TODO: Use history providers to replay the conversation + # print("Replay the conversation:") + # for idx, msg in enumerate(messages): + # if msg.text: + # print(f"{idx + 1} {msg.author_name or msg.role}: {msg.text} ") + # for content in msg.contents: + # if content.type == "function_call": + # print( + # f"{idx + 1} {msg.author_name}: calling function: {content.name} with arguments: {content.arguments}" + # ) + # if content.type == "function_result": + # print(f"{idx + 1} {msg.role}: {content.result if content.result else content.exception}") """ diff --git a/python/samples/getting_started/tools/function_tool_with_max_invocations.py b/python/samples/02-agents/tools/function_tool_with_max_invocations.py similarity index 78% rename from python/samples/getting_started/tools/function_tool_with_max_invocations.py rename to python/samples/02-agents/tools/function_tool_with_max_invocations.py index be9d37d807..c8bdc306c3 100644 --- a/python/samples/getting_started/tools/function_tool_with_max_invocations.py +++ b/python/samples/02-agents/tools/function_tool_with_max_invocations.py @@ -25,31 +25,30 @@ async def main(): instructions="Use the provided tools.", tools=[unicorn_function], ) - thread = agent.get_new_thread() + session = agent.create_session() print("=" * 60) print("Step 1: Call unicorn_function") - response = await agent.run("Call 5 unicorns!", thread=thread) + response = await agent.run("Call 5 unicorns!", session=session) print(f"Response: {response.text}") print("=" * 60) print("Step 2: Call unicorn_function again - will refuse to execute due to max_invocations") - response = await agent.run("Call 10 unicorns and use the function to do it.", thread=thread) + response = await agent.run("Call 10 unicorns and use the function to do it.", session=session) print(f"Response: {response.text}") print("=" * 60) print(f"Number of tool calls attempted: {unicorn_function.invocation_count}") print(f"Number of tool calls failed: {unicorn_function.invocation_exception_count}") - print("Replay the conversation:") - assert thread.message_store - assert thread.message_store.list_messages - for idx, msg in enumerate(await thread.message_store.list_messages()): - if msg.text: - print(f"{idx + 1} {msg.author_name or msg.role}: {msg.text} ") - for content in msg.contents: - if content.type == "function_call": - print( - f"{idx + 1} {msg.author_name}: calling function: {content.name} with arguments: {content.arguments}" - ) - if content.type == "function_result": - print(f"{idx + 1} {msg.role}: {content.result if content.result else content.exception}") + # TODO: Use history providers to replay the conversation + # print("Replay the conversation:") + # for idx, msg in enumerate(messages): + # if msg.text: + # print(f"{idx + 1} {msg.author_name or msg.role}: {msg.text} ") + # for content in msg.contents: + # if content.type == "function_call": + # print( + # f"{idx + 1} {msg.author_name}: calling function: {content.name} with arguments: {content.arguments}" + # ) + # if content.type == "function_result": + # print(f"{idx + 1} {msg.role}: {content.result if content.result else content.exception}") """ diff --git a/python/samples/02-agents/tools/function_tool_with_session_injection.py b/python/samples/02-agents/tools/function_tool_with_session_injection.py new file mode 100644 index 0000000000..5e5a8322ac --- /dev/null +++ b/python/samples/02-agents/tools/function_tool_with_session_injection.py @@ -0,0 +1,52 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import Annotated, Any + +from agent_framework import AgentSession, tool +from agent_framework.openai import OpenAIResponsesClient +from pydantic import Field + +""" +AI Function with Session Injection Example + +This example demonstrates the behavior when passing 'session' to agent.run() +and accessing that session in AI function. +""" + + +# Define the function tool with **kwargs +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +async def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], + **kwargs: Any, +) -> str: + """Get the weather for a given location.""" + # Get session object from kwargs + session = kwargs.get("session") + if session and isinstance(session, AgentSession) and session.service_session_id: + print(f"Session ID: {session.service_session_id}.") + + return f"The weather in {location} is cloudy." + + +async def main() -> None: + agent = OpenAIResponsesClient().as_agent( + name="WeatherAgent", + instructions="You are a helpful weather assistant.", + tools=[get_weather], + options={"store": True}, + ) + + # Create a session + session = agent.create_session() + + # Run the agent with the session + print(f"Agent: {await agent.run('What is the weather in London?', session=session)}") + print(f"Agent: {await agent.run('What is the weather in Amsterdam?', session=session)}") + print(f"Agent: {await agent.run('What cities did I ask about?', session=session)}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/tools/tool_in_class.py b/python/samples/02-agents/tools/tool_in_class.py similarity index 100% rename from python/samples/getting_started/tools/tool_in_class.py rename to python/samples/02-agents/tools/tool_in_class.py diff --git a/python/samples/concepts/typed_options.py b/python/samples/02-agents/typed_options.py similarity index 89% rename from python/samples/concepts/typed_options.py rename to python/samples/02-agents/typed_options.py index 533b214ebe..e111222601 100644 --- a/python/samples/concepts/typed_options.py +++ b/python/samples/02-agents/typed_options.py @@ -3,13 +3,13 @@ import asyncio from typing import Literal -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.anthropic import AnthropicClient from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions """TypedDict-based Chat Options. -In Agent Framework, we have made ChatClient and ChatAgent generic over a ChatOptions typeddict, this means that +In Agent Framework, we have made ChatClient and Agent generic over a ChatOptions typeddict, this means that you can override which options are available for a given client or agent by providing your own TypedDict subclass. And we include the most common options for all ChatClient providers out of the box. @@ -21,7 +21,7 @@ which provides: including overriding unsupported options. The sample shows usage with both OpenAI and Anthropic clients, demonstrating -how provider-specific options work for ChatClient and ChatAgent. But the same approach works for other providers too. +how provider-specific options work for ChatClient and Agent. But the same approach works for other providers too. """ @@ -49,14 +49,14 @@ async def demo_anthropic_chat_client() -> None: async def demo_anthropic_agent() -> None: - """Demonstrate ChatAgent with Anthropic client and typed options.""" - print("\n=== ChatAgent with Anthropic and Typed Options ===\n") + """Demonstrate Agent with Anthropic client and typed options.""" + print("\n=== Agent with Anthropic and Typed Options ===\n") client = AnthropicClient(model_id="claude-sonnet-4-5-20250929") # Create a typed agent for Anthropic - IDE knows Anthropic-specific options! - agent = ChatAgent( - chat_client=client, + agent = Agent( + client=client, name="claude-assistant", instructions="You are a helpful assistant powered by Claude. Be concise.", default_options={ @@ -132,15 +132,15 @@ async def demo_openai_chat_client_reasoning_models() -> None: async def demo_openai_agent() -> None: - """Demonstrate ChatAgent with OpenAI client and typed options.""" - print("\n=== ChatAgent with OpenAI and Typed Options ===\n") + """Demonstrate Agent with OpenAI client and typed options.""" + print("\n=== Agent with OpenAI and Typed Options ===\n") # Create a typed agent - IDE will autocomplete options! # The type annotation can be done either on the agent like below, # or on the client when constructing the client instance: # client = OpenAIChatClient[OpenAIReasoningChatOptions]() - agent = ChatAgent[OpenAIReasoningChatOptions]( - chat_client=OpenAIChatClient(), + agent = Agent[OpenAIReasoningChatOptions]( + client=OpenAIChatClient(), name="weather-assistant", instructions="You are a helpful assistant. Answer concisely.", # Options can be set at construction time diff --git a/python/samples/getting_started/workflows/README.md b/python/samples/03-workflows/README.md similarity index 76% rename from python/samples/getting_started/workflows/README.md rename to python/samples/03-workflows/README.md index 7b368335a3..b72cdce54d 100644 --- a/python/samples/getting_started/workflows/README.md +++ b/python/samples/03-workflows/README.md @@ -36,16 +36,12 @@ Once comfortable with these, explore the rest of the samples below. | -------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | Azure Chat Agents (Streaming) | [agents/azure_chat_agents_streaming.py](./agents/azure_chat_agents_streaming.py) | Add Azure Chat agents as edges and handle streaming events | | Azure AI Agents (Streaming) | [agents/azure_ai_agents_streaming.py](./agents/azure_ai_agents_streaming.py) | Add Azure AI agents as edges and handle streaming events | -| Azure AI Agents (Shared Thread) | [agents/azure_ai_agents_with_shared_thread.py](./agents/azure_ai_agents_with_shared_thread.py) | Share a common message thread between multiple Azure AI agents in a workflow | +| Azure AI Agents (Shared Thread) | [agents/azure_ai_agents_with_shared_session.py](./agents/azure_ai_agents_with_shared_session.py) | Share a common message session between multiple Azure AI agents in a workflow | | Custom Agent Executors | [agents/custom_agent_executors.py](./agents/custom_agent_executors.py) | Create executors to handle agent run methods | -| Sequential Workflow as Agent | [agents/sequential_workflow_as_agent.py](./agents/sequential_workflow_as_agent.py) | Build a sequential workflow orchestrating agents, then expose it as a reusable agent | -| Concurrent Workflow as Agent | [agents/concurrent_workflow_as_agent.py](./agents/concurrent_workflow_as_agent.py) | Build a concurrent fan-out/fan-in workflow, then expose it as a reusable agent | -| Magentic Workflow as Agent | [agents/magentic_workflow_as_agent.py](./agents/magentic_workflow_as_agent.py) | Configure Magentic orchestration with callbacks, then expose the workflow as an agent | | Workflow as Agent (Reflection Pattern) | [agents/workflow_as_agent_reflection_pattern.py](./agents/workflow_as_agent_reflection_pattern.py) | Wrap a workflow so it can behave like an agent (reflection pattern) | | Workflow as Agent + HITL | [agents/workflow_as_agent_human_in_the_loop.py](./agents/workflow_as_agent_human_in_the_loop.py) | Extend workflow-as-agent with human-in-the-loop capability | -| Workflow as Agent with Thread | [agents/workflow_as_agent_with_thread.py](./agents/workflow_as_agent_with_thread.py) | Use AgentThread to maintain conversation history across workflow-as-agent invocations | +| Workflow as Agent with Session | [agents/workflow_as_agent_with_session.py](./agents/workflow_as_agent_with_session.py) | Use AgentSession to maintain conversation history across workflow-as-agent invocations | | Workflow as Agent kwargs | [agents/workflow_as_agent_kwargs.py](./agents/workflow_as_agent_kwargs.py) | Pass custom context (data, user tokens) via kwargs through workflow.as_agent() to @ai_function tools | -| Handoff Workflow as Agent | [agents/handoff_workflow_as_agent.py](./agents/handoff_workflow_as_agent.py) | Use a HandoffBuilder workflow as an agent with HITL via FunctionCallContent/FunctionResultContent | ### checkpoint @@ -54,7 +50,7 @@ Once comfortable with these, explore the rest of the samples below. | Checkpoint & Resume | [checkpoint/checkpoint_with_resume.py](./checkpoint/checkpoint_with_resume.py) | Create checkpoints, inspect them, and resume execution | | Checkpoint & HITL Resume | [checkpoint/checkpoint_with_human_in_the_loop.py](./checkpoint/checkpoint_with_human_in_the_loop.py) | Combine checkpointing with human approvals and resume pending HITL requests | | Checkpointed Sub-Workflow | [checkpoint/sub_workflow_checkpoint.py](./checkpoint/sub_workflow_checkpoint.py) | Save and resume a sub-workflow that pauses for human approval | -| Handoff + Tool Approval Resume | [checkpoint/handoff_with_tool_approval_checkpoint_resume.py](./checkpoint/handoff_with_tool_approval_checkpoint_resume.py) | Handoff workflow that captures tool-call approvals in checkpoints and resumes with human decisions | +| Handoff + Tool Approval Resume | [orchestrations/handoff_with_tool_approval_checkpoint_resume.py](./orchestrations/handoff_with_tool_approval_checkpoint_resume.py) | Handoff workflow that captures tool-call approvals in checkpoints and resumes with human decisions | | Workflow as Agent Checkpoint | [checkpoint/workflow_as_agent_checkpoint.py](./checkpoint/workflow_as_agent_checkpoint.py) | Enable checkpointing when using workflow.as_agent() with checkpoint_storage parameter | ### composition @@ -85,19 +81,13 @@ Once comfortable with these, explore the rest of the samples below. | Human-In-The-Loop (Guessing Game) | [human-in-the-loop/guessing_game_with_human_input.py](./human-in-the-loop/guessing_game_with_human_input.py) | Interactive request/response prompts with a human via `ctx.request_info()` | | Agents with Approval Requests in Workflows | [human-in-the-loop/agents_with_approval_requests.py](./human-in-the-loop/agents_with_approval_requests.py) | Agents that create approval requests during workflow execution and wait for human approval to proceed | | Agents with Declaration-Only Tools | [human-in-the-loop/agents_with_declaration_only_tools.py](./human-in-the-loop/agents_with_declaration_only_tools.py) | Workflow pauses when agent calls a client-side tool (`func=None`), caller supplies the result | -| SequentialBuilder Request Info | [human-in-the-loop/sequential_request_info.py](./human-in-the-loop/sequential_request_info.py) | Request info for agent responses mid-workflow using `.with_request_info()` on SequentialBuilder | -| ConcurrentBuilder Request Info | [human-in-the-loop/concurrent_request_info.py](./human-in-the-loop/concurrent_request_info.py) | Review concurrent agent outputs before aggregation using `.with_request_info()` on ConcurrentBuilder | -| GroupChatBuilder Request Info | [human-in-the-loop/group_chat_request_info.py](./human-in-the-loop/group_chat_request_info.py) | Steer group discussions with periodic guidance using `.with_request_info()` on GroupChatBuilder | + +Builder-oriented request-info samples are maintained in the orchestration sample set +(sequential, concurrent, and group-chat builder variants). ### tool-approval -Tool approval samples demonstrate using `@tool(approval_mode="always_require")` to gate sensitive tool executions with human approval. These work with the high-level builder APIs. - -| Sample | File | Concepts | -| ------------------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| SequentialBuilder Tool Approval | [tool-approval/sequential_builder_tool_approval.py](./tool-approval/sequential_builder_tool_approval.py) | Sequential workflow with tool approval gates for sensitive operations | -| ConcurrentBuilder Tool Approval | [tool-approval/concurrent_builder_tool_approval.py](./tool-approval/concurrent_builder_tool_approval.py) | Concurrent workflow with tool approvals across parallel agents | -| GroupChatBuilder Tool Approval | [tool-approval/group_chat_builder_tool_approval.py](./tool-approval/group_chat_builder_tool_approval.py) | Group chat workflow with tool approval for multi-agent collaboration | +Builder-based tool approval samples are maintained in the orchestration sample set. ### observability @@ -105,11 +95,12 @@ Tool approval samples demonstrate using `@tool(approval_mode="always_require")` | ------------------------ | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | Executor I/O Observation | [observability/executor_io_observation.py](./observability/executor_io_observation.py) | Observe executor input/output data via executor_invoked events (type='executor_invoked') and executor_completed events (type='executor_completed') without modifying executor code | -For additional observability samples in Agent Framework, see the [observability getting started samples](../observability/README.md). The [sample](../observability/workflow_observability.py) demonstrates integrating observability into workflows. +For additional observability samples in Agent Framework, see the [observability concept samples](../02-agents/observability/README.md). The [workflow observability sample](../02-agents/observability/workflow_observability.py) demonstrates integrating observability into workflows. ### orchestration -Orchestration samples (Sequential, Concurrent, Handoff, GroupChat, Magentic) have moved to the dedicated [orchestrations samples directory](../orchestrations/README.md). +Orchestration-focused samples (Sequential, Concurrent, Handoff, GroupChat, Magentic), including builder-based +`workflow.as_agent(...)` variants, are documented in the [orchestrations](./orchestrations/README.md) directory. ### parallelism @@ -161,7 +152,7 @@ Notes Sequential orchestration uses a few small adapter nodes for plumbing: -- "input-conversation" normalizes input to `list[ChatMessage]` +- "input-conversation" normalizes input to `list[Message]` - "to-conversation:" converts agent responses into the shared conversation - "complete" publishes the final output event (type='output') These may appear in event streams (executor_invoked/executor_completed). They're analogous to @@ -169,9 +160,9 @@ Sequential orchestration uses a few small adapter nodes for plumbing: ### Environment Variables -- **AzureOpenAIChatClient**: Set Azure OpenAI environment variables as documented [here](https://github.com/microsoft/agent-framework/blob/main/python/samples/getting_started/chat_client/README.md#environment-variables). - These variables are required for samples that construct `AzureOpenAIChatClient` +Workflow samples that use `AzureOpenAIResponsesClient` expect: -- **OpenAI** (used in orchestration samples): - - [OpenAIChatClient env vars](https://github.com/microsoft/agent-framework/blob/main/python/samples/getting_started/agents/openai_chat_client/README.md) - - [OpenAIResponsesClient env vars](https://github.com/microsoft/agent-framework/blob/main/python/samples/getting_started/agents/openai_responses_client/README.md) +- `AZURE_AI_PROJECT_ENDPOINT` (Azure AI Foundry Agent Service (V2) project endpoint) +- `AZURE_AI_MODEL_DEPLOYMENT_NAME` (model deployment name) + +These values are passed directly into the client constructor via `os.getenv()` in sample code. diff --git a/python/samples/getting_started/workflows/_start-here/step1_executors_and_edges.py b/python/samples/03-workflows/_start-here/step1_executors_and_edges.py similarity index 87% rename from python/samples/getting_started/workflows/_start-here/step1_executors_and_edges.py rename to python/samples/03-workflows/_start-here/step1_executors_and_edges.py index 8975795e35..6410e54a05 100644 --- a/python/samples/getting_started/workflows/_start-here/step1_executors_and_edges.py +++ b/python/samples/03-workflows/_start-here/step1_executors_and_edges.py @@ -4,6 +4,7 @@ import asyncio from agent_framework import ( Executor, + Workflow, WorkflowBuilder, WorkflowContext, executor, @@ -48,6 +49,11 @@ What this example shows - Fluent WorkflowBuilder API: add_edge(A, B) to connect nodes, set_start_executor(A), then build() -> Workflow. +- State isolation via helper functions: + Wrapping executor instantiation and workflow building inside a function + (e.g., create_workflow()) ensures each call produces fresh, independent + instances. This is the recommended pattern for reuse. + - Running and results: workflow.run(initial_input) executes the graph. Terminal nodes yield outputs using ctx.yield_output(). The workflow runs until idle. @@ -152,18 +158,28 @@ class ExclamationAdder(Executor): await ctx.send_message(result) # type: ignore +def create_workflow() -> Workflow: + """Create a fresh workflow with isolated state. + + Wrapping workflow construction in a helper function ensures each call + produces independent executor instances. This is the recommended pattern + for reuse — call create_workflow() each time you need a new workflow so + that no state leaks between runs. + """ + upper_case = UpperCase(id="upper_case_executor") + + return WorkflowBuilder(start_executor=upper_case).add_edge(upper_case, reverse_text).build() + + async def main(): """Build and run workflows using the fluent builder API.""" - # Workflow 1: Using introspection-based type detection - # ----------------------------------------------------- - upper_case = UpperCase(id="upper_case_executor") - - # Build the workflow using a fluent pattern: - # 1) start_executor=... in constructor declares the entry point - # 2) add_edge(from_node, to_node) defines a directed edge upper_case -> reverse_text - # 3) build() finalizes and returns an immutable Workflow object - workflow1 = WorkflowBuilder(start_executor=upper_case).add_edge(upper_case, reverse_text).build() + # Workflow 1: Using the helper function pattern for state isolation + # ------------------------------------------------------------------ + # Each call to create_workflow() returns a workflow with fresh executor + # instances. This is the recommended pattern when you need to run the + # same workflow topology multiple times with clean state. + workflow1 = create_workflow() # Run the workflow by sending the initial message to the start node. # The run(...) call returns an event collection; its get_outputs() method @@ -175,6 +191,7 @@ async def main(): # Workflow 2: Using explicit type parameters on @handler # ------------------------------------------------------- + upper_case = UpperCase(id="upper_case_executor") exclamation_adder = ExclamationAdder(id="exclamation_adder") # This workflow demonstrates the explicit input/output feature: diff --git a/python/samples/getting_started/workflows/_start-here/step2_agents_in_a_workflow.py b/python/samples/03-workflows/_start-here/step2_agents_in_a_workflow.py similarity index 80% rename from python/samples/getting_started/workflows/_start-here/step2_agents_in_a_workflow.py rename to python/samples/03-workflows/_start-here/step2_agents_in_a_workflow.py index aa6378c433..5330cf4973 100644 --- a/python/samples/getting_started/workflows/_start-here/step2_agents_in_a_workflow.py +++ b/python/samples/03-workflows/_start-here/step2_agents_in_a_workflow.py @@ -1,10 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from typing import cast from agent_framework import AgentResponse, WorkflowBuilder -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential """ @@ -14,11 +15,12 @@ This sample creates two agents: a Writer agent creates or edits content, and a R evaluates and provides feedback. Purpose: -Show how to create agents from AzureOpenAIChatClient and use them directly in a workflow. Demonstrate +Show how to create agents from AzureOpenAIResponsesClient and use them directly in a workflow. Demonstrate how agents can be used in a workflow. Prerequisites: -- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables. +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for AzureOpenAIResponsesClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. - Basic familiarity with WorkflowBuilder, edges, events, and streaming or non-streaming runs. """ @@ -27,15 +29,19 @@ Prerequisites: async def main(): """Build and run a simple two node agent workflow: Writer then Reviewer.""" # Create the Azure chat client. AzureCliCredential uses your current az login. - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - writer_agent = chat_client.as_agent( + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + writer_agent = client.as_agent( instructions=( "You are an excellent content writer. You create new content and edit contents based on the feedback." ), name="writer", ) - reviewer_agent = chat_client.as_agent( + reviewer_agent = client.as_agent( instructions=( "You are an excellent content reviewer." "Provide actionable feedback to the writer about the provided content." diff --git a/python/samples/getting_started/workflows/_start-here/step3_streaming.py b/python/samples/03-workflows/_start-here/step3_streaming.py similarity index 77% rename from python/samples/getting_started/workflows/_start-here/step3_streaming.py rename to python/samples/03-workflows/_start-here/step3_streaming.py index c9cfa6843d..15e3512c02 100644 --- a/python/samples/getting_started/workflows/_start-here/step3_streaming.py +++ b/python/samples/03-workflows/_start-here/step3_streaming.py @@ -1,9 +1,10 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os -from agent_framework import AgentResponseUpdate, ChatMessage, WorkflowBuilder -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework import AgentResponseUpdate, Message, WorkflowBuilder +from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential """ @@ -13,11 +14,12 @@ This sample creates two agents: a Writer agent creates or edits content, and a R evaluates and provides feedback. Purpose: -Show how to create agents from AzureOpenAIChatClient and use them directly in a workflow. Demonstrate +Show how to create agents from AzureOpenAIResponsesClient and use them directly in a workflow. Demonstrate how agents can be used in a workflow. Prerequisites: -- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables. +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for AzureOpenAIResponsesClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. - Basic familiarity with WorkflowBuilder, executors, edges, events, and streaming runs. """ @@ -26,15 +28,19 @@ Prerequisites: async def main(): """Build the two node workflow and run it with streaming to observe events.""" # Create the Azure chat client. AzureCliCredential uses your current az login. - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - writer_agent = chat_client.as_agent( + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + writer_agent = client.as_agent( instructions=( "You are an excellent content writer. You create new content and edit contents based on the feedback." ), name="writer", ) - reviewer_agent = chat_client.as_agent( + reviewer_agent = client.as_agent( instructions=( "You are an excellent content reviewer." "Provide actionable feedback to the writer about the provided content." @@ -52,7 +58,7 @@ async def main(): # Run the workflow with the user's initial message and stream events as they occur. async for event in workflow.run( - ChatMessage("user", ["Create a slogan for a new electric SUV that is affordable and fun to drive."]), + Message("user", ["Create a slogan for a new electric SUV that is affordable and fun to drive."]), stream=True, ): # The outputs of the workflow are whatever the agents produce. So the events are expected to diff --git a/python/samples/03-workflows/agents/azure_ai_agents_streaming.py b/python/samples/03-workflows/agents/azure_ai_agents_streaming.py new file mode 100644 index 0000000000..ccca56cf36 --- /dev/null +++ b/python/samples/03-workflows/agents/azure_ai_agents_streaming.py @@ -0,0 +1,71 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +from agent_framework import AgentResponseUpdate, WorkflowBuilder +from agent_framework.azure import AzureOpenAIResponsesClient +from azure.identity import AzureCliCredential + +""" +Sample: Azure AI Agents in a Workflow with Streaming + +This sample shows how to create agents backed by Azure OpenAI Responses and use them in a workflow with streaming. + +Prerequisites: +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- AZURE_AI_MODEL_DEPLOYMENT_NAME must be set to your Azure OpenAI model deployment name. +- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. +- Basic familiarity with WorkflowBuilder, edges, events, and streaming runs. +""" + + +async def main() -> None: + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + + # Create two agents: a Writer and a Reviewer. + writer_agent = client.as_agent( + name="Writer", + instructions=( + "You are an excellent content writer. You create new content and edit contents based on the feedback." + ), + ) + + reviewer_agent = client.as_agent( + name="Reviewer", + instructions=( + "You are an excellent content reviewer. " + "Provide actionable feedback to the writer about the provided content. " + "Provide the feedback in the most concise manner possible." + ), + ) + + # Build the workflow by adding agents directly as edges. + # Agents adapt to workflow mode: run(stream=True) for incremental updates, run() for complete responses. + workflow = WorkflowBuilder(start_executor=writer_agent).add_edge(writer_agent, reviewer_agent).build() + + # Track the last author to format streaming output. + last_author: str | None = None + + events = workflow.run("Create a slogan for a new electric SUV that is affordable and fun to drive.", stream=True) + async for event in events: + # The outputs of the workflow are whatever the agents produce. So the events are expected to + # contain `AgentResponseUpdate` from the agents in the workflow. + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + update = event.data + author = update.author_name + if author != last_author: + if last_author is not None: + print() # Newline between different authors + print(f"{author}: {update.text}", end="", flush=True) + last_author = author + else: + print(update.text, end="", flush=True) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/agents/azure_ai_agents_with_shared_session.py b/python/samples/03-workflows/agents/azure_ai_agents_with_shared_session.py new file mode 100644 index 0000000000..988d3f539f --- /dev/null +++ b/python/samples/03-workflows/agents/azure_ai_agents_with_shared_session.py @@ -0,0 +1,105 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +from agent_framework import ( + AgentExecutor, + AgentExecutorRequest, + AgentExecutorResponse, + InMemoryHistoryProvider, + WorkflowBuilder, + WorkflowContext, + WorkflowRunState, + executor, +) +from agent_framework.azure import AzureOpenAIResponsesClient +from azure.identity import AzureCliCredential + +""" +Sample: Agents with a shared thread in a workflow + +A Writer agent generates content, then a Reviewer agent critiques it, sharing a common message thread. + +Purpose: +Show how to use a shared thread between multiple agents in a workflow. +By default, agents have individual threads, but sharing a thread allows them to share all messages. + +Notes: +- Not all agents can share threads; usually only the same type of agents can share threads. + +Demonstrate: +- Creating multiple agents with AzureOpenAIResponsesClient. +- Setting up a shared thread between agents. + +Prerequisites: +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- AZURE_AI_MODEL_DEPLOYMENT_NAME must be set to your Azure OpenAI model deployment name. +- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. +- Basic familiarity with agents, workflows, and executors in the agent framework. +""" + + +@executor(id="intercept_agent_response") +async def intercept_agent_response( + agent_response: AgentExecutorResponse, ctx: WorkflowContext[AgentExecutorRequest] +) -> None: + """This executor intercepts the agent response and sends a request without messages. + + This essentially prevents duplication of messages in the shared thread. Without this + executor, the response will be added to the thread as input of the next agent call. + """ + await ctx.send_message(AgentExecutorRequest(messages=[])) + + +async def main() -> None: + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + + # set the same context provider, with the same source_id, for both agents to share the thread + writer = client.as_agent( + instructions=("You are a concise copywriter. Provide a single, punchy marketing sentence based on the prompt."), + name="writer", + context_providers=[InMemoryHistoryProvider("memory")], + ) + + reviewer = client.as_agent( + instructions=("You are a thoughtful reviewer. Give brief feedback on the previous assistant message."), + name="reviewer", + context_providers=[InMemoryHistoryProvider("memory")], + ) + + # Create the shared session + shared_session = writer.create_session() + writer_executor = AgentExecutor(writer, session=shared_session) + reviewer_executor = AgentExecutor(reviewer, session=shared_session) + + workflow = ( + WorkflowBuilder(start_executor=writer_executor) + .add_chain([writer_executor, intercept_agent_response, reviewer_executor]) + .build() + ) + + result = await workflow.run( + "Write a tagline for a budget-friendly eBike.", + # Keyword arguments will be passed to each agent call. + # Setting store=False to avoid storing messages in the service for this example. + options={"store": False}, + ) + + # The final state should be IDLE since the workflow no longer has messages to + # process after the reviewer agent responds. + assert result.get_final_state() == WorkflowRunState.IDLE + + # The shared session now contains the conversation between the writer and reviewer. Print it out. + print("=== Shared Session Conversation ===") + memory_state = shared_session.state.get("memory", {}) + for message in memory_state.get("messages", []): + print(f"{message.author_name or message.role}: {message.text}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/agents/azure_chat_agents_and_executor.py b/python/samples/03-workflows/agents/azure_chat_agents_and_executor.py similarity index 86% rename from python/samples/getting_started/workflows/agents/azure_chat_agents_and_executor.py rename to python/samples/03-workflows/agents/azure_chat_agents_and_executor.py index 3e3751fd86..ed724332b9 100644 --- a/python/samples/getting_started/workflows/agents/azure_chat_agents_and_executor.py +++ b/python/samples/03-workflows/agents/azure_chat_agents_and_executor.py @@ -1,18 +1,19 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from typing import Final from agent_framework import ( AgentExecutorRequest, AgentExecutorResponse, AgentResponseUpdate, - ChatMessage, + Message, WorkflowBuilder, WorkflowContext, executor, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential """ @@ -30,7 +31,8 @@ Demonstrates: - Consuming an AgentExecutorResponse and forwarding an AgentExecutorRequest for the next agent. Prerequisites: -- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables. +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for AzureOpenAIResponsesClient with required environment variables. - Authentication via azure-identity. Run `az login` before executing. """ @@ -84,7 +86,7 @@ async def enrich_with_references( f"{external_note}\n\n" "Please update the prior assistant answer so it weaves this note into the guidance." ) - conversation.append(ChatMessage("user", [follow_up])) + conversation.append(Message("user", [follow_up])) # Output a new AgentExecutorRequest for the next agent in the workflow. # Agents in workflows handle this type and will generate a response based on the request. @@ -94,14 +96,22 @@ async def enrich_with_references( async def main() -> None: """Run the workflow and stream combined updates from both agents.""" # Create the agents - research_agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + research_agent = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( name="research_agent", instructions=( "Produce a short, bullet-style briefing with two actionable ideas. Label the section as 'Initial Draft'." ), ) - final_editor_agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + final_editor_agent = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( name="final_editor_agent", instructions=( "Use all conversation context (including external notes) to produce the final answer. " diff --git a/python/samples/getting_started/workflows/agents/azure_chat_agents_streaming.py b/python/samples/03-workflows/agents/azure_chat_agents_streaming.py similarity index 76% rename from python/samples/getting_started/workflows/agents/azure_chat_agents_streaming.py rename to python/samples/03-workflows/agents/azure_chat_agents_streaming.py index 04c08a0602..a18a6e7086 100644 --- a/python/samples/getting_started/workflows/agents/azure_chat_agents_streaming.py +++ b/python/samples/03-workflows/agents/azure_chat_agents_streaming.py @@ -1,9 +1,10 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from agent_framework import AgentResponseUpdate, WorkflowBuilder -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential """ @@ -12,7 +13,8 @@ Sample: AzureOpenAI Chat Agents in a Workflow with Streaming This sample shows how to create AzureOpenAI Chat Agents and use them in a workflow with streaming. Prerequisites: -- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables. +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for AzureOpenAIResponsesClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. - Basic familiarity with WorkflowBuilder, edges, events, and streaming runs. """ @@ -21,14 +23,22 @@ Prerequisites: async def main(): """Build and run a simple two node agent workflow: Writer then Reviewer.""" # Create the agents - writer_agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + writer_agent = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( instructions=( "You are an excellent content writer. You create new content and edit contents based on the feedback." ), name="writer", ) - reviewer_agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + reviewer_agent = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( instructions=( "You are an excellent content reviewer." "Provide actionable feedback to the writer about the provided content." diff --git a/python/samples/getting_started/workflows/agents/azure_chat_agents_tool_calls_with_feedback.py b/python/samples/03-workflows/agents/azure_chat_agents_tool_calls_with_feedback.py similarity index 85% rename from python/samples/getting_started/workflows/agents/azure_chat_agents_tool_calls_with_feedback.py rename to python/samples/03-workflows/agents/azure_chat_agents_tool_calls_with_feedback.py index 3515709157..72ecda0609 100644 --- a/python/samples/getting_started/workflows/agents/azure_chat_agents_tool_calls_with_feedback.py +++ b/python/samples/03-workflows/agents/azure_chat_agents_tool_calls_with_feedback.py @@ -2,17 +2,19 @@ import asyncio import json +import os from dataclasses import dataclass, field from typing import Annotated from agent_framework import ( + Agent, + AgentExecutor, AgentExecutorRequest, AgentExecutorResponse, AgentResponse, AgentResponseUpdate, - ChatAgent, - ChatMessage, Executor, + Message, WorkflowBuilder, WorkflowContext, WorkflowEvent, @@ -20,7 +22,7 @@ from agent_framework import ( response_handler, tool, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential from pydantic import Field from typing_extensions import Never @@ -42,14 +44,15 @@ Demonstrates: - Streaming AgentRunUpdateEvent updates alongside human-in-the-loop pauses. Prerequisites: -- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables. +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for AzureOpenAIResponsesClient with required environment variables. - Authentication via azure-identity. Run `az login` before executing. """ # NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/getting_started/tools/function_tool_with_approval.py and -# samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# see samples/02-agents/tools/function_tool_with_approval.py and +# samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def fetch_product_brief( product_name: Annotated[str, Field(description="Product name to look up.")], @@ -89,7 +92,7 @@ class DraftFeedbackRequest: prompt: str = "" draft_text: str = "" - conversation: list[ChatMessage] = field(default_factory=list) # type: ignore[reportUnknownVariableType] + conversation: list[Message] = field(default_factory=list) # type: ignore[reportUnknownVariableType] class Coordinator(Executor): @@ -115,7 +118,7 @@ class Coordinator(Executor): # Writer agent response; request human feedback. # Preserve the full conversation so the final editor # can see tool traces and the initial prompt. - conversation: list[ChatMessage] + conversation: list[Message] if draft.full_conversation is not None: conversation = list(draft.full_conversation) else: @@ -146,7 +149,7 @@ class Coordinator(Executor): # Human approved the draft as-is; forward it unchanged. await ctx.send_message( AgentExecutorRequest( - messages=original_request.conversation + [ChatMessage("user", text="The draft is approved as-is.")], + messages=original_request.conversation + [Message("user", text="The draft is approved as-is.")], should_respond=True, ), target_id=self.final_editor_id, @@ -154,22 +157,26 @@ class Coordinator(Executor): return # Human provided feedback; prompt the writer to revise. - conversation: list[ChatMessage] = list(original_request.conversation) + conversation: list[Message] = list(original_request.conversation) instruction = ( "A human reviewer shared the following guidance:\n" f"{note or 'No specific guidance provided.'}\n\n" "Rewrite the draft from the previous assistant message into a polished final version. " "Keep the response under 120 words and reflect any requested tone adjustments." ) - conversation.append(ChatMessage("user", text=instruction)) + conversation.append(Message("user", text=instruction)) await ctx.send_message( AgentExecutorRequest(messages=conversation, should_respond=True), target_id=self.writer_id ) -def create_writer_agent() -> ChatAgent: +def create_writer_agent() -> Agent: """Creates a writer agent with tools.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( name="writer_agent", instructions=( "You are a marketing writer. Call the available tools before drafting copy so you are precise. " @@ -181,9 +188,13 @@ def create_writer_agent() -> ChatAgent: ) -def create_final_editor_agent() -> ChatAgent: +def create_final_editor_agent() -> Agent: """Creates a final editor agent.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( name="final_editor_agent", instructions=( "You are an editor who polishes marketing copy after human approval. " @@ -239,22 +250,20 @@ async def main() -> None: """Run the workflow and bridge human feedback between two agents.""" # Build the workflow. + writer_agent = AgentExecutor(create_writer_agent()) + final_editor_agent = AgentExecutor(create_final_editor_agent()) + coordinator = Coordinator( + id="coordinator", + writer_id="writer_agent", + final_editor_id="final_editor_agent", + ) + workflow = ( - WorkflowBuilder(start_executor="writer_agent") - .register_agent(create_writer_agent, name="writer_agent") - .register_agent(create_final_editor_agent, name="final_editor_agent") - .register_executor( - lambda: Coordinator( - id="coordinator", - writer_id="writer_agent", - final_editor_id="final_editor_agent", - ), - name="coordinator", - ) - .add_edge("writer_agent", "coordinator") - .add_edge("coordinator", "writer_agent") - .add_edge("final_editor_agent", "coordinator") - .add_edge("coordinator", "final_editor_agent") + WorkflowBuilder(start_executor=writer_agent) + .add_edge(writer_agent, coordinator) + .add_edge(coordinator, writer_agent) + .add_edge(final_editor_agent, coordinator) + .add_edge(coordinator, final_editor_agent) .build() ) diff --git a/python/samples/getting_started/workflows/agents/concurrent_workflow_as_agent.py b/python/samples/03-workflows/agents/concurrent_workflow_as_agent.py similarity index 80% rename from python/samples/getting_started/workflows/agents/concurrent_workflow_as_agent.py rename to python/samples/03-workflows/agents/concurrent_workflow_as_agent.py index 7c10455eaa..c9d3a55920 100644 --- a/python/samples/getting_started/workflows/agents/concurrent_workflow_as_agent.py +++ b/python/samples/03-workflows/agents/concurrent_workflow_as_agent.py @@ -1,8 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.orchestrations import ConcurrentBuilder from azure.identity import AzureCliCredential @@ -19,7 +20,8 @@ Demonstrates: - Workflow completion when idle with no pending work Prerequisites: -- Azure OpenAI access configured for AzureOpenAIChatClient (use az login + env vars) +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI access configured for AzureOpenAIResponsesClient (use az login + env vars) - Familiarity with Workflow events (WorkflowEvent with type "output") """ @@ -37,10 +39,14 @@ def clear_and_redraw(buffers: dict[str, str], agent_order: list[str]) -> None: async def main() -> None: - # 1) Create three domain agents using AzureOpenAIChatClient - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + # 1) Create three domain agents using AzureOpenAIResponsesClient + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) - researcher = chat_client.as_agent( + researcher = client.as_agent( instructions=( "You're an expert market and product researcher. Given a prompt, provide concise, factual insights," " opportunities, and risks." @@ -48,7 +54,7 @@ async def main() -> None: name="researcher", ) - marketer = chat_client.as_agent( + marketer = client.as_agent( instructions=( "You're a creative marketing strategist. Craft compelling value propositions and target messaging" " aligned to the prompt." @@ -56,7 +62,7 @@ async def main() -> None: name="marketer", ) - legal = chat_client.as_agent( + legal = client.as_agent( instructions=( "You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns" " based on the prompt." diff --git a/python/samples/getting_started/workflows/agents/custom_agent_executors.py b/python/samples/03-workflows/agents/custom_agent_executors.py similarity index 70% rename from python/samples/getting_started/workflows/agents/custom_agent_executors.py rename to python/samples/03-workflows/agents/custom_agent_executors.py index c193e7368d..3d6b34a2eb 100644 --- a/python/samples/getting_started/workflows/agents/custom_agent_executors.py +++ b/python/samples/03-workflows/agents/custom_agent_executors.py @@ -1,16 +1,17 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from agent_framework import ( - ChatAgent, - ChatMessage, + Agent, Executor, + Message, WorkflowBuilder, WorkflowContext, handler, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential """ @@ -20,14 +21,15 @@ This sample uses two custom executors. A Writer agent creates or edits content, then hands the conversation to a Reviewer agent which evaluates and finalizes the result. Purpose: -Show how to wrap chat agents created by AzureOpenAIChatClient inside workflow executors. Demonstrate the @handler +Show how to wrap chat agents created by AzureOpenAIResponsesClient inside workflow executors. Demonstrate the @handler pattern with typed inputs and typed WorkflowContext[T] outputs, connect executors with the fluent WorkflowBuilder, and finish by yielding outputs from the terminal node. -Note: When an agent is passed to a workflow, the workflow essenatially wrap the agent in a more sophisticated executor. +Note: When an agent is passed to a workflow, the workflow wraps the agent in a more sophisticated executor. Prerequisites: -- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables. +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for AzureOpenAIResponsesClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. - Basic familiarity with WorkflowBuilder, executors, edges, events, and streaming or non streaming runs. """ @@ -37,15 +39,19 @@ class Writer(Executor): """Custom executor that owns a domain specific agent responsible for generating content. This class demonstrates: - - Attaching a ChatAgent to an Executor so it participates as a node in a workflow. + - Attaching a Agent to an Executor so it participates as a node in a workflow. - Using a @handler method to accept a typed input and forward a typed output via ctx.send_message. """ - agent: ChatAgent + agent: Agent def __init__(self, id: str = "writer"): - # Create a domain specific agent using your configured AzureOpenAIChatClient. - self.agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + # Create a domain specific agent using your configured AzureOpenAIResponsesClient. + self.agent = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( instructions=( "You are an excellent content writer. You create new content and edit contents based on the feedback." ), @@ -54,12 +60,12 @@ class Writer(Executor): super().__init__(id=id) @handler - async def handle(self, message: ChatMessage, ctx: WorkflowContext[list[ChatMessage], str]) -> None: + async def handle(self, message: Message, ctx: WorkflowContext[list[Message], str]) -> None: """Generate content using the agent and forward the updated conversation. Contract for this handler: - - message is the inbound user ChatMessage. - - ctx is a WorkflowContext that expects a list[ChatMessage] to be sent downstream. + - message is the inbound user Message. + - ctx is a WorkflowContext that expects a list[Message] to be sent downstream. Pattern shown here: 1) Seed the conversation with the inbound message. @@ -67,7 +73,7 @@ class Writer(Executor): 3) Forward the cumulative messages to the next executor with ctx.send_message. """ # Start the conversation with the incoming user message. - messages: list[ChatMessage] = [message] + messages: list[Message] = [message] # Run the agent and extend the conversation with the agent's messages. response = await self.agent.run(messages) messages.extend(response.messages) @@ -83,11 +89,15 @@ class Reviewer(Executor): - Yielding the final text outcome to complete the workflow. """ - agent: ChatAgent + agent: Agent def __init__(self, id: str = "reviewer"): # Create a domain specific agent that evaluates and refines content. - self.agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + self.agent = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( instructions=( "You are an excellent content reviewer. You review the content and provide feedback to the writer." ), @@ -95,7 +105,7 @@ class Reviewer(Executor): super().__init__(id=id) @handler - async def handle(self, messages: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage], str]) -> None: + async def handle(self, messages: list[Message], ctx: WorkflowContext[list[Message], str]) -> None: """Review the full conversation transcript and complete with a final string. This node consumes all messages so far. It uses its agent to produce the final text, @@ -118,7 +128,7 @@ async def main(): # Run the workflow with the user's initial message. # For foundational clarity, use run (non streaming) and print the workflow output. events = await workflow.run( - ChatMessage("user", ["Create a slogan for a new electric SUV that is affordable and fun to drive."]) + Message("user", ["Create a slogan for a new electric SUV that is affordable and fun to drive."]) ) # The terminal node yields output; print its contents. outputs = events.get_outputs() diff --git a/python/samples/getting_started/workflows/agents/group_chat_workflow_as_agent.py b/python/samples/03-workflows/agents/group_chat_workflow_as_agent.py similarity index 66% rename from python/samples/getting_started/workflows/agents/group_chat_workflow_as_agent.py rename to python/samples/03-workflows/agents/group_chat_workflow_as_agent.py index 1693aeb642..f5da892d6d 100644 --- a/python/samples/getting_started/workflows/agents/group_chat_workflow_as_agent.py +++ b/python/samples/03-workflows/agents/group_chat_workflow_as_agent.py @@ -1,10 +1,12 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os -from agent_framework import ChatAgent -from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient +from agent_framework import Agent +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.orchestrations import GroupChatBuilder +from azure.identity import AzureCliCredential """ Sample: Group Chat Orchestration @@ -14,23 +16,32 @@ What it does: - The orchestrator coordinates a researcher (chat completions) and a writer (responses API) to solve a task. Prerequisites: -- OpenAI environment variables configured for `OpenAIChatClient` and `OpenAIResponsesClient`. +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Environment variables configured for `AzureOpenAIResponsesClient`. """ async def main() -> None: - researcher = ChatAgent( + researcher = Agent( name="Researcher", description="Collects relevant background information.", instructions="Gather concise facts that help a teammate answer the question.", - chat_client=OpenAIChatClient(model_id="gpt-4o-mini"), + client=AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), ) - writer = ChatAgent( + writer = Agent( name="Writer", description="Synthesizes a polished answer using the gathered notes.", instructions="Compose clear and structured answers using any notes provided.", - chat_client=OpenAIResponsesClient(), + client=AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), ) # intermediate_outputs=True: Enable intermediate outputs to observe the conversation as it unfolds @@ -38,7 +49,11 @@ async def main() -> None: workflow = GroupChatBuilder( participants=[researcher, writer], intermediate_outputs=True, - orchestrator_agent=OpenAIChatClient().as_agent( + orchestrator_agent=AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( name="Orchestrator", instructions="You coordinate a team conversation to solve the user's task.", ), diff --git a/python/samples/getting_started/workflows/agents/handoff_workflow_as_agent.py b/python/samples/03-workflows/agents/handoff_workflow_as_agent.py similarity index 89% rename from python/samples/getting_started/workflows/agents/handoff_workflow_as_agent.py rename to python/samples/03-workflows/agents/handoff_workflow_as_agent.py index f3dcefab7a..9eaa0549ec 100644 --- a/python/samples/getting_started/workflows/agents/handoff_workflow_as_agent.py +++ b/python/samples/03-workflows/agents/handoff_workflow_as_agent.py @@ -1,17 +1,18 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from typing import Annotated from agent_framework import ( + Agent, AgentResponse, - ChatAgent, - ChatMessage, Content, + Message, WorkflowAgent, tool, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder from azure.identity import AzureCliCredential @@ -24,8 +25,9 @@ A handoff workflow defines a pattern that assembles agents in a mesh topology, a them to transfer control to each other based on the conversation context. Prerequisites: + - AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - `az login` (Azure CLI authentication) - - Environment variables configured for AzureOpenAIChatClient (AZURE_OPENAI_ENDPOINT, etc.) + - Environment variables configured for AzureOpenAIResponsesClient (AZURE_AI_MODEL_DEPLOYMENT_NAME) Key Concepts: - Auto-registered handoff tools: HandoffBuilder automatically creates handoff tools @@ -37,8 +39,8 @@ Key Concepts: # NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; # See: -# samples/getting_started/tools/function_tool_with_approval.py -# samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# samples/02-agents/tools/function_tool_with_approval.py +# samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def process_refund(order_number: Annotated[str, "Order number to process refund for"]) -> str: """Simulated function to process a refund for a given order number.""" @@ -57,17 +59,17 @@ def process_return(order_number: Annotated[str, "Order number to process return return f"Return initiated successfully for order {order_number}. You will receive return instructions via email." -def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent, ChatAgent]: +def create_agents(client: AzureOpenAIResponsesClient) -> tuple[Agent, Agent, Agent, Agent]: """Create and configure the triage and specialist agents. Args: - chat_client: The AzureOpenAIChatClient to use for creating agents. + client: The AzureOpenAIResponsesClient to use for creating agents. Returns: Tuple of (triage_agent, refund_agent, order_agent, return_agent) """ # Triage agent: Acts as the frontline dispatcher - triage_agent = chat_client.as_agent( + triage_agent = client.as_agent( instructions=( "You are frontline support triage. Route customer issues to the appropriate specialist agents " "based on the problem described." @@ -76,7 +78,7 @@ def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAg ) # Refund specialist: Handles refund requests - refund_agent = chat_client.as_agent( + refund_agent = client.as_agent( instructions="You process refund requests.", name="refund_agent", # In a real application, an agent can have multiple tools; here we keep it simple @@ -84,7 +86,7 @@ def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAg ) # Order/shipping specialist: Resolves delivery issues - order_agent = chat_client.as_agent( + order_agent = client.as_agent( instructions="You handle order and shipping inquiries.", name="order_agent", # In a real application, an agent can have multiple tools; here we keep it simple @@ -92,7 +94,7 @@ def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAg ) # Return specialist: Handles return requests - return_agent = chat_client.as_agent( + return_agent = client.as_agent( instructions="You manage product return requests.", name="return_agent", # In a real application, an agent can have multiple tools; here we keep it simple @@ -147,10 +149,14 @@ async def main() -> None: replace the scripted_responses with actual user input collection. """ # Initialize the Azure OpenAI chat client - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) # Create all agents: triage + specialists - triage, refund, order, support = create_agents(chat_client) + triage, refund, order, support = create_agents(client) # Build the handoff workflow # - participants: All agents that can participate in the workflow @@ -213,7 +219,7 @@ async def main() -> None: function_results = [ Content.from_function_result(call_id=req_id, result=response) for req_id, response in responses.items() ] - response = await agent.run(ChatMessage("tool", function_results)) + response = await agent.run(Message("tool", function_results)) pending_requests = handle_response_and_requests(response) diff --git a/python/samples/getting_started/workflows/agents/magentic_workflow_as_agent.py b/python/samples/03-workflows/agents/magentic_workflow_as_agent.py similarity index 72% rename from python/samples/getting_started/workflows/agents/magentic_workflow_as_agent.py rename to python/samples/03-workflows/agents/magentic_workflow_as_agent.py index 4d687514c1..ecceeeacd4 100644 --- a/python/samples/getting_started/workflows/agents/magentic_workflow_as_agent.py +++ b/python/samples/03-workflows/agents/magentic_workflow_as_agent.py @@ -1,13 +1,14 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from agent_framework import ( - ChatAgent, - HostedCodeInterpreterTool, + Agent, ) -from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.orchestrations import MagenticBuilder +from azure.identity import AzureCliCredential """ Sample: Build a Magentic orchestration and wrap it as an agent. @@ -17,35 +18,52 @@ orchestration through `workflow.as_agent(...)` so the entire Magentic loop can b like any other agent while still emitting callback telemetry. Prerequisites: -- OpenAI credentials configured for `OpenAIChatClient` and `OpenAIResponsesClient`. +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- OpenAI credentials configured for `AzureOpenAIResponsesClient` and `AzureOpenAIResponsesClient`. """ async def main() -> None: - researcher_agent = ChatAgent( + researcher_agent = Agent( name="ResearcherAgent", description="Specialist in research and information gathering", instructions=( "You are a Researcher. You find information without additional computation or quantitative analysis." ), # This agent requires the gpt-4o-search-preview model to perform web searches. - chat_client=OpenAIChatClient(model_id="gpt-4o-search-preview"), + client=AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), ) - coder_agent = ChatAgent( + # Create code interpreter tool using instance method + coder_client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + code_interpreter_tool = coder_client.get_code_interpreter_tool() + + coder_agent = Agent( name="CoderAgent", description="A helpful assistant that writes and executes code to process and analyze data.", instructions="You solve questions using code. Please provide detailed analysis and computation process.", - chat_client=OpenAIResponsesClient(), - tools=HostedCodeInterpreterTool(), + client=coder_client, + tools=code_interpreter_tool, ) # Create a manager agent for orchestration - manager_agent = ChatAgent( + manager_agent = Agent( name="MagenticManager", description="Orchestrator that coordinates the research and coding workflow", instructions="You coordinate a team to complete complex tasks efficiently.", - chat_client=OpenAIChatClient(), + client=AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), ) print("\nBuilding Magentic Workflow...") diff --git a/python/samples/getting_started/workflows/agents/sequential_workflow_as_agent.py b/python/samples/03-workflows/agents/sequential_workflow_as_agent.py similarity index 85% rename from python/samples/getting_started/workflows/agents/sequential_workflow_as_agent.py rename to python/samples/03-workflows/agents/sequential_workflow_as_agent.py index 7fc1720cbc..1b2a6c6af4 100644 --- a/python/samples/getting_started/workflows/agents/sequential_workflow_as_agent.py +++ b/python/samples/03-workflows/agents/sequential_workflow_as_agent.py @@ -1,8 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.orchestrations import SequentialBuilder from azure.identity import AzureCliCredential @@ -21,20 +22,25 @@ Note on internal adapters: You can safely ignore them when focusing on agent progress. Prerequisites: -- Azure OpenAI access configured for AzureOpenAIChatClient (use az login + env vars) +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI access configured for AzureOpenAIResponsesClient (use az login + env vars) """ async def main() -> None: # 1) Create agents - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) - writer = chat_client.as_agent( + writer = client.as_agent( instructions=("You are a concise copywriter. Provide a single, punchy marketing sentence based on the prompt."), name="writer", ) - reviewer = chat_client.as_agent( + reviewer = client.as_agent( instructions=("You are a thoughtful reviewer. Give brief feedback on the previous assistant message."), name="reviewer", ) diff --git a/python/samples/getting_started/workflows/agents/workflow_as_agent_human_in_the_loop.py b/python/samples/03-workflows/agents/workflow_as_agent_human_in_the_loop.py similarity index 83% rename from python/samples/getting_started/workflows/agents/workflow_as_agent_human_in_the_loop.py rename to python/samples/03-workflows/agents/workflow_as_agent_human_in_the_loop.py index af405084dc..dfe510762d 100644 --- a/python/samples/getting_started/workflows/agents/workflow_as_agent_human_in_the_loop.py +++ b/python/samples/03-workflows/agents/workflow_as_agent_human_in_the_loop.py @@ -1,31 +1,36 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os import sys from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path from typing import Any -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential -# Ensure local getting_started package can be imported when running as a script. +# Ensure local package can be imported when running as a script. _SAMPLES_ROOT = Path(__file__).resolve().parents[3] if str(_SAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_SAMPLES_ROOT)) +# Also add the current directory for sibling imports +_CURRENT_DIR = str(Path(__file__).resolve().parent) +if _CURRENT_DIR not in sys.path: + sys.path.insert(0, _CURRENT_DIR) from agent_framework import ( # noqa: E402 - ChatMessage, Content, Executor, + Message, WorkflowAgent, WorkflowBuilder, WorkflowContext, handler, response_handler, ) -from getting_started.workflows.agents.workflow_as_agent_reflection_pattern import ( # noqa: E402 +from workflow_as_agent_reflection_pattern import ( # noqa: E402 ReviewRequest, ReviewResponse, Worker, @@ -42,7 +47,8 @@ to a human, receives the human response, and then forwards that response back to the Worker. The workflow completes when idle. Prerequisites: -- OpenAI account configured and accessible for OpenAIChatClient. +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- OpenAI account configured and accessible for AzureOpenAIResponsesClient. - Familiarity with WorkflowBuilder, Executor, and WorkflowContext from agent_framework. - Understanding of request-response message handling in executors. - (Optional) Review of reflection and escalation patterns, such as those in @@ -98,21 +104,20 @@ async def main() -> None: print("Building workflow with Worker-Reviewer cycle...") # Build a workflow with bidirectional communication between Worker and Reviewer, # and escalation paths for human review. + worker = Worker( + id="worker", + chat_client=AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), + ) + reviewer = ReviewerWithHumanInTheLoop(worker_id="worker") + agent = ( - WorkflowBuilder(start_executor="worker") - .register_executor( - lambda: Worker( - id="sub-worker", - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), - ), - name="worker", - ) - .register_executor( - lambda: ReviewerWithHumanInTheLoop(worker_id="sub-worker"), - name="reviewer", - ) - .add_edge("worker", "reviewer") # Worker sends requests to Reviewer - .add_edge("reviewer", "worker") # Reviewer sends feedback to Worker + WorkflowBuilder(start_executor=worker) + .add_edge(worker, reviewer) # Worker sends requests to Reviewer + .add_edge(reviewer, worker) # Reviewer sends feedback to Worker .build() .as_agent() # Convert workflow into an agent interface ) @@ -164,7 +169,7 @@ async def main() -> None: result=human_response, ) # Send the human review result back to the agent. - response = await agent.run(ChatMessage("tool", [human_review_function_result])) + response = await agent.run(Message("tool", [human_review_function_result])) print(f"📤 Agent Response: {response.messages[-1].text}") print("=" * 50) diff --git a/python/samples/getting_started/workflows/agents/workflow_as_agent_kwargs.py b/python/samples/03-workflows/agents/workflow_as_agent_kwargs.py similarity index 87% rename from python/samples/getting_started/workflows/agents/workflow_as_agent_kwargs.py rename to python/samples/03-workflows/agents/workflow_as_agent_kwargs.py index aefcf9b1e5..539fdfc540 100644 --- a/python/samples/getting_started/workflows/agents/workflow_as_agent_kwargs.py +++ b/python/samples/03-workflows/agents/workflow_as_agent_kwargs.py @@ -2,11 +2,13 @@ import asyncio import json +import os from typing import Annotated, Any from agent_framework import tool -from agent_framework.openai import OpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.orchestrations import SequentialBuilder +from azure.identity import AzureCliCredential from pydantic import Field """ @@ -28,14 +30,15 @@ When to use workflow.as_agent(): - To maintain a consistent agent interface for callers Prerequisites: -- OpenAI environment variables configured +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Environment variables configured """ # Define tools that accept custom context via **kwargs # NOTE: approval_mode="never_require" is for sample brevity. -# Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and -# samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and +# samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_user_data( query: Annotated[str, Field(description="What user data to retrieve")], @@ -80,10 +83,14 @@ async def main() -> None: print("=" * 70) # Create chat client - chat_client = OpenAIChatClient() + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) # Create agent with tools that use kwargs - agent = chat_client.as_agent( + agent = client.as_agent( name="assistant", instructions=( "You are a helpful assistant. Use the available tools to help users. " diff --git a/python/samples/getting_started/workflows/agents/workflow_as_agent_reflection_pattern.py b/python/samples/03-workflows/agents/workflow_as_agent_reflection_pattern.py similarity index 78% rename from python/samples/getting_started/workflows/agents/workflow_as_agent_reflection_pattern.py rename to python/samples/03-workflows/agents/workflow_as_agent_reflection_pattern.py index 3d205cbbb2..e0dde3eacf 100644 --- a/python/samples/getting_started/workflows/agents/workflow_as_agent_reflection_pattern.py +++ b/python/samples/03-workflows/agents/workflow_as_agent_reflection_pattern.py @@ -1,19 +1,21 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from dataclasses import dataclass from uuid import uuid4 from agent_framework import ( AgentResponse, - ChatClientProtocol, - ChatMessage, Executor, + Message, + SupportsChatGetResponse, WorkflowBuilder, WorkflowContext, handler, ) -from agent_framework.openai import OpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient +from azure.identity import AzureCliCredential from pydantic import BaseModel """ @@ -33,7 +35,8 @@ Key Concepts Demonstrated: - State management for pending requests and retry logic. Prerequisites: -- OpenAI account configured and accessible for OpenAIChatClient. +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- OpenAI account configured and accessible for AzureOpenAIResponsesClient. - Familiarity with WorkflowBuilder, Executor, WorkflowContext, and event handling. - Understanding of how agent messages are generated, reviewed, and re-submitted. """ @@ -44,8 +47,8 @@ class ReviewRequest: """Structured request passed from Worker to Reviewer for evaluation.""" request_id: str - user_messages: list[ChatMessage] - agent_messages: list[ChatMessage] + user_messages: list[Message] + agent_messages: list[Message] @dataclass @@ -60,9 +63,9 @@ class ReviewResponse: class Reviewer(Executor): """Executor that reviews agent responses and provides structured feedback.""" - def __init__(self, id: str, chat_client: ChatClientProtocol) -> None: + def __init__(self, id: str, client: SupportsChatGetResponse) -> None: super().__init__(id=id) - self._chat_client = chat_client + self._chat_client = client @handler async def review(self, request: ReviewRequest, ctx: WorkflowContext[ReviewResponse]) -> None: @@ -75,7 +78,7 @@ class Reviewer(Executor): # Construct review instructions and context. messages = [ - ChatMessage( + Message( role="system", text=( "You are a reviewer for an AI agent. Provide feedback on the " @@ -93,7 +96,7 @@ class Reviewer(Executor): messages.extend(request.agent_messages) # Add explicit review instruction. - messages.append(ChatMessage("user", ["Please review the agent's responses."])) + messages.append(Message("user", ["Please review the agent's responses."])) print("Reviewer: Sending review request to LLM...") response = await self._chat_client.get_response(messages=messages, options={"response_format": _Response}) @@ -112,17 +115,17 @@ class Reviewer(Executor): class Worker(Executor): """Executor that generates responses and incorporates feedback when necessary.""" - def __init__(self, id: str, chat_client: ChatClientProtocol) -> None: + def __init__(self, id: str, client: SupportsChatGetResponse) -> None: super().__init__(id=id) - self._chat_client = chat_client - self._pending_requests: dict[str, tuple[ReviewRequest, list[ChatMessage]]] = {} + self._chat_client = client + self._pending_requests: dict[str, tuple[ReviewRequest, list[Message]]] = {} @handler - async def handle_user_messages(self, user_messages: list[ChatMessage], ctx: WorkflowContext[ReviewRequest]) -> None: + async def handle_user_messages(self, user_messages: list[Message], ctx: WorkflowContext[ReviewRequest]) -> None: print("Worker: Received user messages, generating response...") # Initialize chat with system prompt. - messages = [ChatMessage("system", ["You are a helpful assistant."])] + messages = [Message("system", ["You are a helpful assistant."])] messages.extend(user_messages) print("Worker: Calling LLM to generate response...") @@ -161,8 +164,8 @@ class Worker(Executor): print("Worker: Regenerating response with feedback...") # Incorporate review feedback. - messages.append(ChatMessage("system", [review.feedback])) - messages.append(ChatMessage("system", ["Please incorporate the feedback and regenerate the response."])) + messages.append(Message("system", [review.feedback])) + messages.append(Message("system", ["Please incorporate the feedback and regenerate the response."])) messages.extend(request.user_messages) # Retry with updated prompt. @@ -186,18 +189,27 @@ async def main() -> None: print("=" * 50) print("Building workflow with Worker ↔ Reviewer cycle...") + worker = Worker( + id="worker", + client=AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), + ) + reviewer = Reviewer( + id="reviewer", + client=AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), + ) + agent = ( - WorkflowBuilder(start_executor="worker") - .register_executor( - lambda: Worker(id="worker", chat_client=OpenAIChatClient(model_id="gpt-4.1-nano")), - name="worker", - ) - .register_executor( - lambda: Reviewer(id="reviewer", chat_client=OpenAIChatClient(model_id="gpt-4.1")), - name="reviewer", - ) - .add_edge("worker", "reviewer") # Worker sends responses to Reviewer - .add_edge("reviewer", "worker") # Reviewer provides feedback to Worker + WorkflowBuilder(start_executor=worker) + .add_edge(worker, reviewer) # Worker sends responses to Reviewer + .add_edge(reviewer, worker) # Reviewer provides feedback to Worker .build() .as_agent() # Wrap workflow as an agent ) diff --git a/python/samples/03-workflows/agents/workflow_as_agent_with_session.py b/python/samples/03-workflows/agents/workflow_as_agent_with_session.py new file mode 100644 index 0000000000..6a8716ce4c --- /dev/null +++ b/python/samples/03-workflows/agents/workflow_as_agent_with_session.py @@ -0,0 +1,173 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +from agent_framework import AgentSession +from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.orchestrations import SequentialBuilder +from azure.identity import AzureCliCredential + +""" +Sample: Workflow as Agent with Session Conversation History and Checkpointing + +This sample demonstrates how to use AgentSession with a workflow wrapped as an agent +to maintain conversation history across multiple invocations. When using as_agent(), +the session's history is included in each workflow run, enabling +the workflow participants to reference prior conversation context. + +It also demonstrates how to enable checkpointing for workflow execution state +persistence, allowing workflows to be paused and resumed. + +Key concepts: +- Workflows can be wrapped as agents using workflow.as_agent() +- AgentSession preserves conversation history +- Each call to agent.run() includes session history + new message +- Participants in the workflow see the full conversation context +- checkpoint_storage parameter enables workflow state persistence + +Use cases: +- Multi-turn conversations with workflow-based orchestrations +- Stateful workflows that need context from previous interactions +- Building conversational agents that leverage workflow patterns +- Long-running workflows that need pause/resume capability + +Prerequisites: +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Environment variables configured for AzureOpenAIResponsesClient +""" + + +async def main() -> None: + # Create a chat client + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + + assistant = client.as_agent( + name="assistant", + instructions=( + "You are a helpful assistant. Answer questions based on the conversation " + "history. If the user asks about something mentioned earlier, reference it." + ), + ) + + summarizer = client.as_agent( + name="summarizer", + instructions=( + "You are a summarizer. After the assistant responds, provide a brief " + "one-sentence summary of the key point from the conversation so far." + ), + ) + + # Build a sequential workflow: assistant -> summarizer + workflow = SequentialBuilder(participants=[assistant, summarizer]).build() + + # Wrap the workflow as an agent + agent = workflow.as_agent(name="ConversationalWorkflowAgent") + + # Create a session to maintain history + session = agent.create_session() + + print("=" * 60) + print("Workflow as Agent with Session - Multi-turn Conversation") + print("=" * 60) + + # First turn: Introduce a topic + query1 = "My name is Alex and I'm learning about machine learning." + print(f"\n[Turn 1] User: {query1}") + + response1 = await agent.run(query1, session=session) + if response1.messages: + for msg in response1.messages: + speaker = msg.author_name or msg.role + print(f"[{speaker}]: {msg.text}") + + # Second turn: Reference the previous topic + query2 = "What was my name again, and what am I learning about?" + print(f"\n[Turn 2] User: {query2}") + + response2 = await agent.run(query2, session=session) + if response2.messages: + for msg in response2.messages: + speaker = msg.author_name or msg.role + print(f"[{speaker}]: {msg.text}") + + # Third turn: Ask a follow-up question + query3 = "Can you suggest a good first project for me to try?" + print(f"\n[Turn 3] User: {query3}") + + response3 = await agent.run(query3, session=session) + if response3.messages: + for msg in response3.messages: + speaker = msg.author_name or msg.role + print(f"[{speaker}]: {msg.text}") + + # Show the accumulated conversation history + print("\n" + "=" * 60) + print("Full Session History") + print("=" * 60) + memory_state = session.state.get("memory", {}) + history = memory_state.get("messages", []) + for i, msg in enumerate(history, start=1): + role = msg.role if hasattr(msg.role, "value") else str(msg.role) + speaker = msg.author_name or role + text_preview = msg.text[:80] + "..." if len(msg.text) > 80 else msg.text + print(f"{i:02d}. [{speaker}]: {text_preview}") + + +async def demonstrate_session_serialization() -> None: + """ + Demonstrates serializing and resuming a session with a workflow agent. + + This shows how conversation history can be persisted and restored, + enabling long-running conversational workflows. + """ + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + + memory_assistant = client.as_agent( + name="memory_assistant", + instructions="You are a helpful assistant with good memory. Remember details from our conversation.", + ) + + workflow = SequentialBuilder(participants=[memory_assistant]).build() + agent = workflow.as_agent(name="MemoryWorkflowAgent") + + # Create initial session and have a conversation + session = agent.create_session() + + print("\n" + "=" * 60) + print("Session Serialization Demo") + print("=" * 60) + + # First interaction + query = "Remember this: the secret code is ALPHA-7." + print(f"\n[Session 1] User: {query}") + response = await agent.run(query, session=session) + if response.messages: + print(f"[assistant]: {response.messages[0].text}") + + # Serialize session state (could be saved to database/file) + serialized_state = session.to_dict() + print("\n[Serialized session state for persistence]") + + # Simulate a new session by creating a new session from serialized state + restored_session = AgentSession.from_dict(serialized_state) + + # Continue conversation with restored session + query = "What was the secret code I told you?" + print(f"\n[Session 2 - Restored] User: {query}") + response = await agent.run(query, session=restored_session) + if response.messages: + print(f"[assistant]: {response.messages[0].text}") + + +if __name__ == "__main__": + asyncio.run(main()) + asyncio.run(demonstrate_session_serialization()) diff --git a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py b/python/samples/03-workflows/checkpoint/checkpoint_with_human_in_the_loop.py similarity index 81% rename from python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py rename to python/samples/03-workflows/checkpoint/checkpoint_with_human_in_the_loop.py index fd5bda8551..b26d4dd8e8 100644 --- a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py +++ b/python/samples/03-workflows/checkpoint/checkpoint_with_human_in_the_loop.py @@ -1,11 +1,15 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os import sys from dataclasses import dataclass +from datetime import datetime from pathlib import Path from typing import Any +from azure.identity import AzureCliCredential + if sys.version_info >= (3, 12): from typing import override # type: ignore # pragma: no cover else: @@ -17,21 +21,19 @@ else: # `agent_framework.builtin` chat client or mock the writer executor. We keep the # concrete import here so readers can see an end-to-end configuration. from agent_framework import ( + AgentExecutor, AgentExecutorRequest, AgentExecutorResponse, - ChatMessage, Executor, FileCheckpointStorage, + Message, Workflow, WorkflowBuilder, - WorkflowCheckpoint, WorkflowContext, - get_checkpoint_summary, handler, response_handler, ) -from agent_framework.azure import AzureOpenAIChatClient -from azure.identity import AzureCliCredential +from agent_framework.azure import AzureOpenAIResponsesClient """ Sample: Checkpoint + human-in-the-loop quickstart. @@ -96,7 +98,7 @@ class BriefPreparer(Executor): # Hand the prompt to the writer agent. We always route through the # workflow context so the runtime can capture messages for checkpointing. await ctx.send_message( - AgentExecutorRequest(messages=[ChatMessage("user", text=prompt)], should_respond=True), + AgentExecutorRequest(messages=[Message("user", text=prompt)], should_respond=True), target_id=self._agent_id, ) @@ -158,7 +160,7 @@ class ReviewGateway(Executor): f"Human guidance: {reply}" ) await ctx.send_message( - AgentExecutorRequest(messages=[ChatMessage("user", text=prompt)], should_respond=True), + AgentExecutorRequest(messages=[Message("user", text=prompt)], should_respond=True), target_id=self._writer_id, ) @@ -178,46 +180,28 @@ def create_workflow(checkpoint_storage: FileCheckpointStorage) -> Workflow: # Wire the workflow DAG. Edges mirror the numbered steps described in the # module docstring. Because `WorkflowBuilder` is declarative, reading these # edges is often the quickest way to understand execution order. + writer_agent = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( + instructions="Write concise, warm release notes that sound human and helpful.", + name="writer", + ) + writer = AgentExecutor(writer_agent) + review_gateway = ReviewGateway(id="review_gateway", writer_id="writer") + prepare_brief = BriefPreparer(id="prepare_brief", agent_id="writer") + workflow_builder = ( - WorkflowBuilder( - max_iterations=6, start_executor="prepare_brief", checkpoint_storage=checkpoint_storage - ) - .register_agent( - lambda: AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( - instructions="Write concise, warm release notes that sound human and helpful.", - # The agent name is stable across runs which keeps checkpoints deterministic. - name="writer", - ), - name="writer", - ) - .register_executor(lambda: ReviewGateway(id="review_gateway", writer_id="writer"), name="review_gateway") - .register_executor(lambda: BriefPreparer(id="prepare_brief", agent_id="writer"), name="prepare_brief") - .add_edge("prepare_brief", "writer") - .add_edge("writer", "review_gateway") - .add_edge("review_gateway", "writer") # revisions loop + 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 ) 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.""" @@ -305,16 +289,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}") @@ -338,10 +318,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/03-workflows/checkpoint/checkpoint_with_resume.py similarity index 91% rename from python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py rename to python/samples/03-workflows/checkpoint/checkpoint_with_resume.py index 7d453b6126..572dd4f0ee 100644 --- a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py +++ b/python/samples/03-workflows/checkpoint/checkpoint_with_resume.py @@ -105,12 +105,12 @@ class WorkerExecutor(Executor): async def main(): # Build workflow with checkpointing enabled checkpoint_storage = InMemoryCheckpointStorage() + start = StartExecutor(id="start") + worker = WorkerExecutor(id="worker") workflow_builder = ( - WorkflowBuilder(start_executor="start", checkpoint_storage=checkpoint_storage) - .register_executor(lambda: StartExecutor(id="start"), name="start") - .register_executor(lambda: WorkerExecutor(id="worker"), name="worker") - .add_edge("start", "worker") - .add_edge("worker", "worker") # Self-loop for iterative processing + WorkflowBuilder(start_executor=start, checkpoint_storage=checkpoint_storage) + .add_edge(start, worker) + .add_edge(worker, worker) # Self-loop for iterative processing ) # Run workflow with automatic checkpoint recovery @@ -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/sub_workflow_checkpoint.py b/python/samples/03-workflows/checkpoint/sub_workflow_checkpoint.py similarity index 94% rename from python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py rename to python/samples/03-workflows/checkpoint/sub_workflow_checkpoint.py index c975a10ae1..833bd7c920 100644 --- a/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py +++ b/python/samples/03-workflows/checkpoint/sub_workflow_checkpoint.py @@ -297,14 +297,14 @@ class LaunchCoordinator(Executor): def build_sub_workflow() -> WorkflowExecutor: """Assemble the sub-workflow used by the parent workflow executor.""" + writer = DraftWriter() + router = DraftReviewRouter() + finaliser = DraftFinaliser() sub_workflow = ( - WorkflowBuilder(start_executor="writer") - .register_executor(DraftWriter, name="writer") - .register_executor(DraftReviewRouter, name="router") - .register_executor(DraftFinaliser, name="finaliser") - .add_edge("writer", "router") - .add_edge("router", "finaliser") - .add_edge("finaliser", "writer") # permits revision loops + WorkflowBuilder(start_executor=writer) + .add_edge(writer, router) + .add_edge(router, finaliser) + .add_edge(finaliser, writer) # permits revision loops .build() ) @@ -313,12 +313,12 @@ def build_sub_workflow() -> WorkflowExecutor: def build_parent_workflow(storage: FileCheckpointStorage) -> Workflow: """Assemble the parent workflow that embeds the sub-workflow.""" + coordinator = LaunchCoordinator() + sub_executor = build_sub_workflow() return ( - WorkflowBuilder(start_executor="coordinator", checkpoint_storage=storage) - .register_executor(LaunchCoordinator, name="coordinator") - .register_executor(build_sub_workflow, name="sub_executor") - .add_edge("coordinator", "sub_executor") - .add_edge("sub_executor", "coordinator") + WorkflowBuilder(start_executor=coordinator, checkpoint_storage=storage) + .add_edge(coordinator, sub_executor) + .add_edge(sub_executor, coordinator) .build() ) @@ -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/03-workflows/checkpoint/workflow_as_agent_checkpoint.py similarity index 59% rename from python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py rename to python/samples/03-workflows/checkpoint/workflow_as_agent_checkpoint.py index 18a0cf9258..4fb9fbbe77 100644 --- a/python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py +++ b/python/samples/03-workflows/checkpoint/workflow_as_agent_checkpoint.py @@ -15,24 +15,24 @@ What you learn: - How to resume a workflow-as-agent from a checkpoint Key concepts: -- Thread (AgentThread): Maintains conversation history across agent invocations +- Thread (AgentSession): Maintains conversation history across agent invocations - Checkpoint: Persists workflow execution state for pause/resume capability -- These are complementary: threads track conversation, checkpoints track workflow state +- These are complementary: sessions track conversation, checkpoints track workflow state Prerequisites: -- OpenAI environment variables configured for OpenAIChatClient +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Environment variables configured for AzureOpenAIResponsesClient """ import asyncio +import os from agent_framework import ( - AgentThread, - ChatAgent, - ChatMessageStore, InMemoryCheckpointStorage, ) -from agent_framework.openai import OpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.orchestrations import SequentialBuilder +from azure.identity import AzureCliCredential async def basic_checkpointing() -> None: @@ -41,22 +41,23 @@ async def basic_checkpointing() -> None: print("Basic Checkpointing with Workflow as Agent") print("=" * 60) - chat_client = OpenAIChatClient() + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) - def create_assistant() -> ChatAgent: - return chat_client.as_agent( - name="assistant", - instructions="You are a helpful assistant. Keep responses brief.", - ) + assistant = client.as_agent( + name="assistant", + instructions="You are a helpful assistant. Keep responses brief.", + ) - def create_reviewer() -> ChatAgent: - return chat_client.as_agent( - name="reviewer", - instructions="You are a reviewer. Provide a one-sentence summary of the assistant's response.", - ) + reviewer = client.as_agent( + name="reviewer", + instructions="You are a reviewer. Provide a one-sentence summary of the assistant's response.", + ) - # Build sequential workflow with participant factories - workflow = SequentialBuilder(participant_factories=[create_assistant, create_reviewer]).build() + workflow = SequentialBuilder(participants=[assistant, reviewer]).build() agent = workflow.as_agent(name="CheckpointedAgent") # Create checkpoint storage @@ -73,7 +74,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}") @@ -85,42 +86,45 @@ async def checkpointing_with_thread() -> None: print("Checkpointing with Thread Conversation History") print("=" * 60) - chat_client = OpenAIChatClient() + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) - def create_assistant() -> ChatAgent: - return chat_client.as_agent( - name="memory_assistant", - instructions="You are a helpful assistant with good memory. Reference previous conversation when relevant.", - ) + assistant = client.as_agent( + name="memory_assistant", + instructions="You are a helpful assistant with good memory. Reference previous conversation when relevant.", + ) - workflow = SequentialBuilder(participant_factories=[create_assistant]).build() + workflow = SequentialBuilder(participants=[assistant]).build() agent = workflow.as_agent(name="MemoryAgent") - # Create both thread (for conversation) and checkpoint storage (for workflow state) - thread = AgentThread(message_store=ChatMessageStore()) + # Create both session (for conversation) and checkpoint storage (for workflow state) + session = agent.create_session() checkpoint_storage = InMemoryCheckpointStorage() # First turn query1 = "My favorite color is blue. Remember that." print(f"\n[Turn 1] User: {query1}") - response1 = await agent.run(query1, thread=thread, checkpoint_storage=checkpoint_storage) + response1 = await agent.run(query1, session=session, checkpoint_storage=checkpoint_storage) if response1.messages: print(f"[assistant]: {response1.messages[0].text}") - # Second turn - agent should remember from thread history + # Second turn - agent should remember from session history query2 = "What's my favorite color?" print(f"\n[Turn 2] User: {query2}") - response2 = await agent.run(query2, thread=thread, checkpoint_storage=checkpoint_storage) + response2 = await agent.run(query2, session=session, checkpoint_storage=checkpoint_storage) if response2.messages: 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: - history = await thread.message_store.list_messages() - print(f"Messages in thread history: {len(history)}") + memory_state = session.state.get("memory", {}) + history = memory_state.get("messages", []) + print(f"Messages in session history: {len(history)}") async def streaming_with_checkpoints() -> None: @@ -129,15 +133,18 @@ async def streaming_with_checkpoints() -> None: print("Streaming with Checkpointing") print("=" * 60) - chat_client = OpenAIChatClient() + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) - def create_assistant() -> ChatAgent: - return chat_client.as_agent( - name="streaming_assistant", - instructions="You are a helpful assistant.", - ) + assistant = client.as_agent( + name="streaming_assistant", + instructions="You are a helpful assistant.", + ) - workflow = SequentialBuilder(participant_factories=[create_assistant]).build() + workflow = SequentialBuilder(participants=[assistant]).build() agent = workflow.as_agent(name="StreamingCheckpointAgent") checkpoint_storage = InMemoryCheckpointStorage() @@ -153,7 +160,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/samples/getting_started/workflows/composition/sub_workflow_basics.py b/python/samples/03-workflows/composition/sub_workflow_basics.py similarity index 75% rename from python/samples/getting_started/workflows/composition/sub_workflow_basics.py rename to python/samples/03-workflows/composition/sub_workflow_basics.py index 9d5168db80..fff4efbc7b 100644 --- a/python/samples/getting_started/workflows/composition/sub_workflow_basics.py +++ b/python/samples/03-workflows/composition/sub_workflow_basics.py @@ -58,13 +58,13 @@ class TextProcessor(Executor): ) -> None: """Process a text string and return statistics.""" text_preview = f"'{request.text[:50]}{'...' if len(request.text) > 50 else ''}'" - print(f"🔍 Sub-workflow processing text (Task {request.task_id}): {text_preview}") + print(f"Sub-workflow processing text (Task {request.task_id}): {text_preview}") # Simple text processing word_count = len(request.text.split()) if request.text.strip() else 0 char_count = len(request.text) - print(f"📊 Task {request.task_id}: {word_count} words, {char_count} characters") + print(f"Task {request.task_id}: {word_count} words, {char_count} characters") # Create result result = TextProcessingResult( @@ -74,7 +74,7 @@ class TextProcessor(Executor): char_count=char_count, ) - print(f"✅ Sub-workflow completed task {request.task_id}") + print(f"Sub-workflow completed task {request.task_id}") # Signal completion by yielding the result await ctx.yield_output(result) @@ -92,7 +92,7 @@ class TextProcessingOrchestrator(Executor): @handler async def start_processing(self, texts: list[str], ctx: WorkflowContext[TextProcessingRequest]) -> None: """Start processing multiple text strings.""" - print(f"📄 Starting processing of {len(texts)} text strings") + print(f"Starting processing of {len(texts)} text strings") print("=" * 60) self.expected_count = len(texts) @@ -101,7 +101,7 @@ class TextProcessingOrchestrator(Executor): for i, text in enumerate(texts): task_id = f"task_{i + 1}" request = TextProcessingRequest(text=text, task_id=task_id) - print(f"📤 Dispatching {task_id} to sub-workflow") + print(f"Dispatching {task_id} to sub-workflow") await ctx.send_message(request, target_id="text_processor_workflow") @handler @@ -111,12 +111,12 @@ class TextProcessingOrchestrator(Executor): ctx: WorkflowContext[Never, list[TextProcessingResult]], ) -> None: """Collect results from sub-workflows.""" - print(f"📥 Collected result from {result.task_id}") + print(f"Collected result from {result.task_id}") self.results.append(result) # Check if all results are collected if len(self.results) == self.expected_count: - print("\n🎉 All tasks completed!") + print("\nAll tasks completed!") await ctx.yield_output(self.results) @@ -138,11 +138,11 @@ def get_result_summary(results: list[TextProcessingResult]) -> dict[str, Any]: def create_sub_workflow() -> WorkflowExecutor: """Create the text processing sub-workflow.""" - print("🚀 Setting up sub-workflow...") + print("Setting up sub-workflow...") + text_processor = TextProcessor() processing_workflow = ( - WorkflowBuilder(start_executor="text_processor") - .register_executor(TextProcessor, name="text_processor") + WorkflowBuilder(start_executor=text_processor) .build() ) @@ -151,14 +151,14 @@ def create_sub_workflow() -> WorkflowExecutor: async def main(): """Main function to run the basic sub-workflow example.""" - print("🔧 Setting up parent workflow...") + print("Setting up parent workflow...") # Step 1: Create the parent workflow + orchestrator = TextProcessingOrchestrator() + sub_workflow_executor = create_sub_workflow() main_workflow = ( - WorkflowBuilder(start_executor="text_orchestrator") - .register_executor(TextProcessingOrchestrator, name="text_orchestrator") - .register_executor(create_sub_workflow, name="text_processor_workflow") - .add_edge("text_orchestrator", "text_processor_workflow") - .add_edge("text_processor_workflow", "text_orchestrator") + WorkflowBuilder(start_executor=orchestrator) + .add_edge(orchestrator, sub_workflow_executor) + .add_edge(sub_workflow_executor, orchestrator) .build() ) @@ -172,14 +172,14 @@ async def main(): " Spaces around text ", ] - print(f"\n🧪 Testing with {len(test_texts)} text strings") + print(f"\nTesting with {len(test_texts)} text strings") print("=" * 60) # Step 3: Run the workflow result = await main_workflow.run(test_texts) # Step 4: Display results - print("\n📊 Processing Results:") + print("\nProcessing Results:") print("=" * 60) # Sort results by task_id for consistent display @@ -190,19 +190,19 @@ async def main(): for result in sorted_results: preview = result.text[:30] + "..." if len(result.text) > 30 else result.text preview = preview.replace("\n", " ").strip() or "(empty)" - print(f"✅ {result.task_id}: '{preview}' -> {result.word_count} words, {result.char_count} chars") + print(f"{result.task_id}: '{preview}' -> {result.word_count} words, {result.char_count} chars") # Step 6: Display summary summary = get_result_summary(sorted_results) - print("\n📈 Summary:") + print("\nSummary:") print("=" * 60) - print(f"📄 Total texts processed: {summary['total_texts']}") - print(f"📝 Total words: {summary['total_words']}") - print(f"🔤 Total characters: {summary['total_characters']}") - print(f"📊 Average words per text: {summary['average_words_per_text']}") - print(f"📏 Average characters per text: {summary['average_characters_per_text']}") + print(f"Total texts processed: {summary['total_texts']}") + print(f"Total words: {summary['total_words']}") + print(f"Total characters: {summary['total_characters']}") + print(f"Average words per text: {summary['average_words_per_text']}") + print(f"Average characters per text: {summary['average_characters_per_text']}") - print("\n🏁 Processing complete!") + print("\nProcessing complete!") if __name__ == "__main__": diff --git a/python/samples/getting_started/workflows/composition/sub_workflow_kwargs.py b/python/samples/03-workflows/composition/sub_workflow_kwargs.py similarity index 90% rename from python/samples/getting_started/workflows/composition/sub_workflow_kwargs.py rename to python/samples/03-workflows/composition/sub_workflow_kwargs.py index 5d74ec42d3..47950ea087 100644 --- a/python/samples/getting_started/workflows/composition/sub_workflow_kwargs.py +++ b/python/samples/03-workflows/composition/sub_workflow_kwargs.py @@ -2,15 +2,17 @@ import asyncio import json +import os from typing import Annotated, Any from agent_framework import ( - ChatMessage, + Message, WorkflowExecutor, tool, ) -from agent_framework.openai import OpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.orchestrations import SequentialBuilder +from azure.identity import AzureCliCredential """ Sample: Sub-Workflow kwargs Propagation @@ -26,14 +28,15 @@ Key Concepts: - Useful for passing authentication tokens, configuration, or request context Prerequisites: -- OpenAI environment variables configured +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Environment variables configured """ # Define tools that access custom context via **kwargs # NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/getting_started/tools/function_tool_with_approval.py and -# samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# see samples/02-agents/tools/function_tool_with_approval.py and +# samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_authenticated_data( resource: Annotated[str, "The resource to fetch"], @@ -74,10 +77,14 @@ async def main() -> None: print("=" * 70) # Create chat client - chat_client = OpenAIChatClient() + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) # Create an agent with tools that use kwargs - inner_agent = chat_client.as_agent( + inner_agent = client.as_agent( name="data_agent", instructions=( "You are a data access agent. Use the available tools to help users. " @@ -134,7 +141,7 @@ async def main() -> None: output_data = event.data if isinstance(output_data, list): for item in output_data: # type: ignore - if isinstance(item, ChatMessage) and item.text: + if isinstance(item, Message) and item.text: print(f"\n[Final Answer]: {item.text}") print("\n" + "=" * 70) diff --git a/python/samples/getting_started/workflows/composition/sub_workflow_parallel_requests.py b/python/samples/03-workflows/composition/sub_workflow_parallel_requests.py similarity index 87% rename from python/samples/getting_started/workflows/composition/sub_workflow_parallel_requests.py rename to python/samples/03-workflows/composition/sub_workflow_parallel_requests.py index c272d7d21c..b373883db3 100644 --- a/python/samples/getting_started/workflows/composition/sub_workflow_parallel_requests.py +++ b/python/samples/03-workflows/composition/sub_workflow_parallel_requests.py @@ -169,17 +169,18 @@ def build_resource_request_distribution_workflow() -> Workflow: elif len(self._responses) > self._request_count: raise ValueError("Received more responses than expected") + orchestrator = RequestDistribution("orchestrator") + resource_requester = ResourceRequester("resource_requester") + policy_checker = PolicyChecker("policy_checker") + result_collector = ResultCollector("result_collector") + return ( - WorkflowBuilder(start_executor="orchestrator") - .register_executor(lambda: RequestDistribution("orchestrator"), name="orchestrator") - .register_executor(lambda: ResourceRequester("resource_requester"), name="resource_requester") - .register_executor(lambda: PolicyChecker("policy_checker"), name="policy_checker") - .register_executor(lambda: ResultCollector("result_collector"), name="result_collector") - .add_edge("orchestrator", "resource_requester") - .add_edge("orchestrator", "policy_checker") - .add_edge("resource_requester", "result_collector") - .add_edge("policy_checker", "result_collector") - .add_edge("orchestrator", "result_collector") # For request count + WorkflowBuilder(start_executor=orchestrator) + .add_edge(orchestrator, resource_requester) + .add_edge(orchestrator, policy_checker) + .add_edge(resource_requester, result_collector) + .add_edge(policy_checker, result_collector) + .add_edge(orchestrator, result_collector) # For request count .build() ) @@ -287,25 +288,22 @@ class PolicyEngine(Executor): async def main() -> None: # Build the main workflow + resource_allocator = ResourceAllocator("resource_allocator") + policy_engine = PolicyEngine("policy_engine") + sub_workflow_executor = WorkflowExecutor( + build_resource_request_distribution_workflow(), + "sub_workflow_executor", + # Setting allow_direct_output=True to let the sub-workflow output directly. + # This is because the sub-workflow is the both the entry point and the exit + # point of the main workflow. + allow_direct_output=True, + ) main_workflow = ( - WorkflowBuilder(start_executor="sub_workflow_executor") - .register_executor(lambda: ResourceAllocator("resource_allocator"), name="resource_allocator") - .register_executor(lambda: PolicyEngine("policy_engine"), name="policy_engine") - .register_executor( - lambda: WorkflowExecutor( - build_resource_request_distribution_workflow(), - "sub_workflow_executor", - # Setting allow_direct_output=True to let the sub-workflow output directly. - # This is because the sub-workflow is the both the entry point and the exit - # point of the main workflow. - allow_direct_output=True, - ), - name="sub_workflow_executor", - ) - .add_edge("sub_workflow_executor", "resource_allocator") - .add_edge("resource_allocator", "sub_workflow_executor") - .add_edge("sub_workflow_executor", "policy_engine") - .add_edge("policy_engine", "sub_workflow_executor") + WorkflowBuilder(start_executor=sub_workflow_executor) + .add_edge(sub_workflow_executor, resource_allocator) + .add_edge(resource_allocator, sub_workflow_executor) + .add_edge(sub_workflow_executor, policy_engine) + .add_edge(policy_engine, sub_workflow_executor) .build() ) @@ -321,14 +319,14 @@ async def main() -> None: ] # Run the workflow - print(f"🧪 Testing with {len(test_requests)} mixed requests.") - print("🚀 Starting main workflow...") + print(f"Testing with {len(test_requests)} mixed requests.") + print("Starting main workflow...") run_result = await main_workflow.run(test_requests) # Handle request info events request_info_events = run_result.get_request_info_events() if request_info_events: - print(f"\n🔍 Handling {len(request_info_events)} request info events...\n") + print(f"\nHandling {len(request_info_events)} request info events...\n") responses: dict[str, ResourceResponse | PolicyResponse] = {} for event in request_info_events: diff --git a/python/samples/getting_started/workflows/composition/sub_workflow_request_interception.py b/python/samples/03-workflows/composition/sub_workflow_request_interception.py similarity index 81% rename from python/samples/getting_started/workflows/composition/sub_workflow_request_interception.py rename to python/samples/03-workflows/composition/sub_workflow_request_interception.py index b5fe3fb7b4..4e475f6c3a 100644 --- a/python/samples/getting_started/workflows/composition/sub_workflow_request_interception.py +++ b/python/samples/03-workflows/composition/sub_workflow_request_interception.py @@ -73,7 +73,7 @@ def build_email_address_validation_workflow() -> Workflow: email address to the next executor in the workflow. """ sanitized = email_address.strip() - print(f"✂️ Sanitized email address: '{sanitized}'") + print(f"Sanitized email address: '{sanitized}'") await ctx.send_message(SanitizedEmailResult(original=email_address, sanitized=sanitized, is_valid=False)) class EmailFormatValidator(Executor): @@ -91,14 +91,14 @@ def build_email_address_validation_workflow() -> Workflow: When the format is valid, it sends the validated email address to the next executor in the workflow. """ if "@" not in partial_result.sanitized or "." not in partial_result.sanitized.split("@")[-1]: - print(f"❌ Invalid email format: '{partial_result.sanitized}'") + print(f"Invalid email format: '{partial_result.sanitized}'") await ctx.yield_output( SanitizedEmailResult( original=partial_result.original, sanitized=partial_result.sanitized, is_valid=False ) ) return - print(f"✅ Validated email format: '{partial_result.sanitized}'") + print(f"Validated email format: '{partial_result.sanitized}'") await ctx.send_message( SanitizedEmailResult( original=partial_result.original, sanitized=partial_result.sanitized, is_valid=False @@ -120,7 +120,7 @@ def build_email_address_validation_workflow() -> Workflow: to an external system to user for validation. """ domain = partial_result.sanitized.split("@")[-1] - print(f"🔍 Validating domain: '{domain}'") + print(f"Validating domain: '{domain}'") self._pending_domains[domain] = partial_result # Send a request to the external system via the request_info mechanism await ctx.request_info(request_data=domain, response_type=bool) @@ -138,14 +138,14 @@ def build_email_address_validation_workflow() -> Workflow: raise ValueError(f"Received response for unknown domain: '{original_request}'") partial_result = self._pending_domains.pop(original_request) if is_valid: - print(f"✅ Domain '{original_request}' is valid.") + print(f"Domain '{original_request}' is valid.") await ctx.yield_output( SanitizedEmailResult( original=partial_result.original, sanitized=partial_result.sanitized, is_valid=True ) ) else: - print(f"❌ Domain '{original_request}' is invalid.") + print(f"Domain '{original_request}' is invalid.") await ctx.yield_output( SanitizedEmailResult( original=partial_result.original, sanitized=partial_result.sanitized, is_valid=False @@ -153,13 +153,14 @@ def build_email_address_validation_workflow() -> Workflow: ) # Build the workflow + email_sanitizer = EmailSanitizer(id="email_sanitizer") + email_format_validator = EmailFormatValidator(id="email_format_validator") + domain_validator = DomainValidator(id="domain_validator") + return ( - WorkflowBuilder(start_executor="email_sanitizer") - .register_executor(lambda: EmailSanitizer(id="email_sanitizer"), name="email_sanitizer") - .register_executor(lambda: EmailFormatValidator(id="email_format_validator"), name="email_format_validator") - .register_executor(lambda: DomainValidator(id="domain_validator"), name="domain_validator") - .add_edge("email_sanitizer", "email_format_validator") - .add_edge("email_format_validator", "domain_validator") + WorkflowBuilder(start_executor=email_sanitizer) + .add_edge(email_sanitizer, email_format_validator) + .add_edge(email_format_validator, domain_validator) .build() ) @@ -200,15 +201,15 @@ class SmartEmailOrchestrator(Executor): """ recipient = email.recipient if recipient in self._approved_recipients: - print(f"📧 Recipient '{recipient}' has been previously approved.") + print(f"Recipient '{recipient}' has been previously approved.") await ctx.send_message(email) return if recipient in self._disapproved_recipients: - print(f"🚫 Blocking email to previously disapproved recipient: '{recipient}'") + print(f"Blocking email to previously disapproved recipient: '{recipient}'") await ctx.yield_output(False) return - print(f"🔍 Validating new recipient email address: '{recipient}'") + print(f"Validating new recipient email address: '{recipient}'") self._pending_emails[recipient] = email await ctx.send_message(recipient) @@ -226,7 +227,7 @@ class SmartEmailOrchestrator(Executor): raise TypeError(f"Expected domain string, got {type(request.source_event.data)}") domain = request.source_event.data is_valid = domain in self._approved_domains - print(f"🌐 External domain validation for '{domain}': {'valid' if is_valid else 'invalid'}") + print(f"External domain validation for '{domain}': {'valid' if is_valid else 'invalid'}") await ctx.send_message(request.create_response(is_valid), target_id=request.executor_id) @handler @@ -242,11 +243,11 @@ class SmartEmailOrchestrator(Executor): email = self._pending_emails.pop(result.original) email.recipient = result.sanitized # Use the sanitized email address if result.is_valid: - print(f"✅ Email address '{result.original}' is valid.") + print(f"Email address '{result.original}' is valid.") self._approved_recipients.add(result.original) await ctx.send_message(email) else: - print(f"🚫 Email address '{result.original}' is invalid. Blocking email.") + print(f"Email address '{result.original}' is invalid. Blocking email.") self._disapproved_recipients.add(result.original) await ctx.yield_output(False) @@ -257,9 +258,9 @@ class EmailDelivery(Executor): @handler async def handle(self, email: Email, ctx: WorkflowContext[Never, bool]) -> None: """Simulate sending the email and yield True as the final result.""" - print(f"📤 Sending email to '{email.recipient}' with subject '{email.subject}'") + print(f"Sending email to '{email.recipient}' with subject '{email.subject}'") await asyncio.sleep(1) # Simulate network delay - print(f"✅ Email sent to '{email.recipient}' successfully.") + print(f"Email sent to '{email.recipient}' successfully.") await ctx.yield_output(True) @@ -268,20 +269,15 @@ async def main() -> None: approved_domains = {"example.com", "company.com"} # Build the main workflow + smart_email_orchestrator = SmartEmailOrchestrator(id="smart_email_orchestrator", approved_domains=approved_domains) + email_delivery = EmailDelivery(id="email_delivery") + email_validation_workflow = WorkflowExecutor(build_email_address_validation_workflow(), id="email_validation_workflow") + workflow = ( - WorkflowBuilder(start_executor="smart_email_orchestrator") - .register_executor( - lambda: SmartEmailOrchestrator(id="smart_email_orchestrator", approved_domains=approved_domains), - name="smart_email_orchestrator", - ) - .register_executor(lambda: EmailDelivery(id="email_delivery"), name="email_delivery") - .register_executor( - lambda: WorkflowExecutor(build_email_address_validation_workflow(), id="email_validation_workflow"), - name="email_validation_workflow", - ) - .add_edge("smart_email_orchestrator", "email_validation_workflow") - .add_edge("email_validation_workflow", "smart_email_orchestrator") - .add_edge("smart_email_orchestrator", "email_delivery") + WorkflowBuilder(start_executor=smart_email_orchestrator) + .add_edge(smart_email_orchestrator, email_validation_workflow) + .add_edge(email_validation_workflow, smart_email_orchestrator) + .add_edge(smart_email_orchestrator, email_delivery) .build() ) @@ -298,10 +294,10 @@ async def main() -> None: # Execute the workflow for email in test_emails: - print(f"\n🚀 Processing email to '{email.recipient}'") + print(f"\nProcessing email to '{email.recipient}'") async for event in workflow.run(email, stream=True): if event.type == "output": - print(f"🎉 Final result for '{email.recipient}': {'Delivered' if event.data else 'Blocked'}") + print(f"Final result for '{email.recipient}': {'Delivered' if event.data else 'Blocked'}") if __name__ == "__main__": diff --git a/python/samples/getting_started/workflows/control-flow/edge_condition.py b/python/samples/03-workflows/control-flow/edge_condition.py similarity index 82% rename from python/samples/getting_started/workflows/control-flow/edge_condition.py rename to python/samples/03-workflows/control-flow/edge_condition.py index 1f5636764d..01fdd9a256 100644 --- a/python/samples/getting_started/workflows/control-flow/edge_condition.py +++ b/python/samples/03-workflows/control-flow/edge_condition.py @@ -5,15 +5,16 @@ import os from typing import Any from agent_framework import ( # Core chat primitives used to build requests + Agent, + AgentExecutor, AgentExecutorRequest, # Input message bundle for an AgentExecutor AgentExecutorResponse, - ChatAgent, # Output from an AgentExecutor - ChatMessage, + Message, WorkflowBuilder, # Fluent builder for wiring executors and edges WorkflowContext, # Per-run context and event bus executor, # Decorator to declare a Python function as a workflow executor ) -from agent_framework.azure import AzureOpenAIChatClient # Thin client wrapper for Azure OpenAI chat models +from agent_framework.azure import AzureOpenAIResponsesClient # Thin client wrapper for Azure OpenAI chat models from azure.identity import AzureCliCredential # Uses your az CLI login for credentials from pydantic import BaseModel # Structured outputs for safer parsing from typing_extensions import Never @@ -31,10 +32,11 @@ Purpose: - Illustrate how to transform one agent's structured result into a new AgentExecutorRequest for a downstream agent. Prerequisites: +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - You understand the basics of WorkflowBuilder, executors, and events in this framework. - You know the concept of edge conditions and how they gate routes using a predicate function. -- Azure OpenAI access is configured for AzureOpenAIChatClient. You should be logged in with Azure CLI (AzureCliCredential) -and have the Azure OpenAI environment variables set as documented in the getting started chat client README. +- Azure OpenAI access is configured for AzureOpenAIResponsesClient. You should be logged in with Azure CLI (AzureCliCredential) +and have the Foundry V2 Project environment variables set as documented in the getting started chat client README. - The sample email resource file exists at workflow/resources/email.txt. High level flow: @@ -121,16 +123,20 @@ async def to_email_assistant_request( Extracts DetectionResult.email_content and forwards it as a user message. """ - # Bridge executor. Converts a structured DetectionResult into a ChatMessage and forwards it as a new request. + # Bridge executor. Converts a structured DetectionResult into a Message and forwards it as a new request. detection = DetectionResult.model_validate_json(response.agent_response.text) - user_msg = ChatMessage("user", text=detection.email_content) + user_msg = Message("user", text=detection.email_content) await ctx.send_message(AgentExecutorRequest(messages=[user_msg], should_respond=True)) -def create_spam_detector_agent() -> ChatAgent: +def create_spam_detector_agent() -> Agent: """Helper to create a spam detection agent.""" # AzureCliCredential uses your current az login. This avoids embedding secrets in code. - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( instructions=( "You are a spam detection assistant that identifies spam emails. " "Always return JSON with fields is_spam (bool), reason (string), and email_content (string). " @@ -141,10 +147,14 @@ def create_spam_detector_agent() -> ChatAgent: ) -def create_email_assistant_agent() -> ChatAgent: +def create_email_assistant_agent() -> Agent: """Helper to create an email assistant agent.""" # AzureCliCredential uses your current az login. This avoids embedding secrets in code. - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( instructions=( "You are an email assistant that helps users draft professional responses to emails. " "Your input may be a JSON object that includes 'email_content'; base your reply on that content. " @@ -161,32 +171,30 @@ async def main() -> None: # If not spam, hop to a transformer that creates a new AgentExecutorRequest, # then call the email assistant, then finalize. # If spam, go directly to the spam handler and finalize. + spam_detection_agent = AgentExecutor(create_spam_detector_agent()) + email_assistant_agent = AgentExecutor(create_email_assistant_agent()) + workflow = ( - WorkflowBuilder(start_executor="spam_detection_agent") - .register_agent(create_spam_detector_agent, name="spam_detection_agent") - .register_agent(create_email_assistant_agent, name="email_assistant_agent") - .register_executor(lambda: to_email_assistant_request, name="to_email_assistant_request") - .register_executor(lambda: handle_email_response, name="send_email") - .register_executor(lambda: handle_spam_classifier_response, name="handle_spam") + WorkflowBuilder(start_executor=spam_detection_agent) # Not spam path: transform response -> request for assistant -> assistant -> send email - .add_edge("spam_detection_agent", "to_email_assistant_request", condition=get_condition(False)) - .add_edge("to_email_assistant_request", "email_assistant_agent") - .add_edge("email_assistant_agent", "send_email") + .add_edge(spam_detection_agent, to_email_assistant_request, condition=get_condition(False)) + .add_edge(to_email_assistant_request, email_assistant_agent) + .add_edge(email_assistant_agent, handle_email_response) # Spam path: send to spam handler - .add_edge("spam_detection_agent", "handle_spam", condition=get_condition(True)) + .add_edge(spam_detection_agent, handle_spam_classifier_response, condition=get_condition(True)) .build() ) # Read Email content from the sample resource file. # This keeps the sample deterministic since the model sees the same email every run. - email_path = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "resources", "email.txt") + email_path = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "resources", "email.txt") # noqa: ASYNC240 with open(email_path) as email_file: # noqa: ASYNC230 email = email_file.read() # Execute the workflow. Since the start is an AgentExecutor, pass an AgentExecutorRequest. # The workflow completes when it becomes idle (no more work to do). - request = AgentExecutorRequest(messages=[ChatMessage("user", text=email)], should_respond=True) + request = AgentExecutorRequest(messages=[Message("user", text=email)], should_respond=True) events = await workflow.run(request) outputs = events.get_outputs() if outputs: diff --git a/python/samples/getting_started/workflows/control-flow/multi_selection_edge_group.py b/python/samples/03-workflows/control-flow/multi_selection_edge_group.py similarity index 78% rename from python/samples/getting_started/workflows/control-flow/multi_selection_edge_group.py rename to python/samples/03-workflows/control-flow/multi_selection_edge_group.py index d2739b410e..05002a2f0c 100644 --- a/python/samples/getting_started/workflows/control-flow/multi_selection_edge_group.py +++ b/python/samples/03-workflows/control-flow/multi_selection_edge_group.py @@ -9,16 +9,18 @@ from typing import Literal from uuid import uuid4 from agent_framework import ( + Agent, + AgentExecutor, AgentExecutorRequest, AgentExecutorResponse, - ChatAgent, - ChatMessage, + AgentResponseUpdate, + Message, WorkflowBuilder, WorkflowContext, WorkflowEvent, executor, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential from pydantic import BaseModel from typing_extensions import Never @@ -41,6 +43,7 @@ Show how to: - Apply conditional persistence logic (short vs long emails). Prerequisites: +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - Familiarity with WorkflowBuilder, executors, edges, and events. - Understanding of multi-selection edge groups and how their selection function maps to target ids. - Experience with workflow state for persisting and reusing objects. @@ -90,7 +93,7 @@ async def store_email(email_text: str, ctx: WorkflowContext[AgentExecutorRequest ctx.set_state(CURRENT_EMAIL_ID_KEY, new_email.email_id) await ctx.send_message( - AgentExecutorRequest(messages=[ChatMessage("user", text=new_email.email_content)], should_respond=True) + AgentExecutorRequest(messages=[Message("user", text=new_email.email_content)], should_respond=True) ) @@ -117,7 +120,7 @@ async def submit_to_email_assistant(analysis: AnalysisResult, ctx: WorkflowConte email: Email = ctx.get_state(f"{EMAIL_STATE_PREFIX}{analysis.email_id}") await ctx.send_message( - AgentExecutorRequest(messages=[ChatMessage("user", text=email.email_content)], should_respond=True) + AgentExecutorRequest(messages=[Message("user", text=email.email_content)], should_respond=True) ) @@ -132,7 +135,7 @@ async def summarize_email(analysis: AnalysisResult, ctx: WorkflowContext[AgentEx # Only called for long NotSpam emails by selection_func email: Email = ctx.get_state(f"{EMAIL_STATE_PREFIX}{analysis.email_id}") await ctx.send_message( - AgentExecutorRequest(messages=[ChatMessage("user", text=email.email_content)], should_respond=True) + AgentExecutorRequest(messages=[Message("user", text=email.email_content)], should_respond=True) ) @@ -176,12 +179,16 @@ async def handle_uncertain(analysis: AnalysisResult, ctx: WorkflowContext[Never, async def database_access(analysis: AnalysisResult, ctx: WorkflowContext[Never, str]) -> None: # Simulate DB writes for email and analysis (and summary if present) await asyncio.sleep(0.05) - await ctx.add_event(DatabaseEvent(f"Email {analysis.email_id} saved to database.")) + await ctx.add_event(DatabaseEvent(type="database_event", data=f"Email {analysis.email_id} saved to database.")) # type: ignore -def create_email_analysis_agent() -> ChatAgent: +def create_email_analysis_agent() -> Agent: """Creates the email analysis agent.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( instructions=( "You are a spam detection assistant that identifies spam emails. " "Always return JSON with fields 'spam_decision' (one of NotSpam, Spam, Uncertain) " @@ -192,18 +199,26 @@ def create_email_analysis_agent() -> ChatAgent: ) -def create_email_assistant_agent() -> ChatAgent: +def create_email_assistant_agent() -> Agent: """Creates the email assistant agent.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( instructions=("You are an email assistant that helps users draft responses to emails with professionalism."), name="email_assistant_agent", default_options={"response_format": EmailResponse}, ) -def create_email_summary_agent() -> ChatAgent: +def create_email_summary_agent() -> Agent: """Creates the email summary agent.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( instructions=("You are an assistant that helps users summarize emails."), name="email_summary_agent", default_options={"response_format": EmailSummaryModel}, @@ -212,6 +227,10 @@ def create_email_summary_agent() -> ChatAgent: async def main() -> None: # Build the workflow + email_analysis_agent = AgentExecutor(create_email_analysis_agent()) + email_assistant_agent = AgentExecutor(create_email_assistant_agent()) + email_summary_agent = AgentExecutor(create_email_summary_agent()) + def select_targets(analysis: AnalysisResult, target_ids: list[str]) -> list[str]: # Order: [handle_spam, submit_to_email_assistant, summarize_email, handle_uncertain] handle_spam_id, submit_to_email_assistant_id, summarize_email_id, handle_uncertain_id = target_ids @@ -224,39 +243,23 @@ async def main() -> None: return targets return [handle_uncertain_id] - workflow_builder = ( - WorkflowBuilder(start_executor="store_email") - .register_agent(create_email_analysis_agent, name="email_analysis_agent") - .register_agent(create_email_assistant_agent, name="email_assistant_agent") - .register_agent(create_email_summary_agent, name="email_summary_agent") - .register_executor(lambda: store_email, name="store_email") - .register_executor(lambda: to_analysis_result, name="to_analysis_result") - .register_executor(lambda: submit_to_email_assistant, name="submit_to_email_assistant") - .register_executor(lambda: finalize_and_send, name="finalize_and_send") - .register_executor(lambda: summarize_email, name="summarize_email") - .register_executor(lambda: merge_summary, name="merge_summary") - .register_executor(lambda: handle_spam, name="handle_spam") - .register_executor(lambda: handle_uncertain, name="handle_uncertain") - .register_executor(lambda: database_access, name="database_access") - ) - workflow = ( - workflow_builder - .add_edge("store_email", "email_analysis_agent") - .add_edge("email_analysis_agent", "to_analysis_result") + WorkflowBuilder(start_executor=store_email) + .add_edge(store_email, email_analysis_agent) + .add_edge(email_analysis_agent, to_analysis_result) .add_multi_selection_edge_group( - "to_analysis_result", - ["handle_spam", "submit_to_email_assistant", "summarize_email", "handle_uncertain"], + to_analysis_result, + [handle_spam, submit_to_email_assistant, summarize_email, handle_uncertain], selection_func=select_targets, ) - .add_edge("submit_to_email_assistant", "email_assistant_agent") - .add_edge("email_assistant_agent", "finalize_and_send") - .add_edge("summarize_email", "email_summary_agent") - .add_edge("email_summary_agent", "merge_summary") + .add_edge(submit_to_email_assistant, email_assistant_agent) + .add_edge(email_assistant_agent, finalize_and_send) + .add_edge(summarize_email, email_summary_agent) + .add_edge(email_summary_agent, merge_summary) # Save to DB if short (no summary path) - .add_edge("to_analysis_result", "database_access", condition=lambda r: r.email_length <= LONG_EMAIL_THRESHOLD) + .add_edge(to_analysis_result, database_access, condition=lambda r: r.email_length <= LONG_EMAIL_THRESHOLD) # Save to DB with summary when long - .add_edge("merge_summary", "database_access") + .add_edge(merge_summary, database_access) .build() ) @@ -278,6 +281,10 @@ async def main() -> None: if isinstance(event, DatabaseEvent): print(f"{event}") elif event.type == "output": + if isinstance(event.data, AgentResponseUpdate): + # Agent executors stream token-level updates. Skip these to keep sample + # output focused on final workflow results. + continue print(f"Workflow output: {event.data}") """ diff --git a/python/samples/getting_started/workflows/control-flow/sequential_executors.py b/python/samples/03-workflows/control-flow/sequential_executors.py similarity index 88% rename from python/samples/getting_started/workflows/control-flow/sequential_executors.py rename to python/samples/03-workflows/control-flow/sequential_executors.py index bae05bf302..77b33c5af6 100644 --- a/python/samples/getting_started/workflows/control-flow/sequential_executors.py +++ b/python/samples/03-workflows/control-flow/sequential_executors.py @@ -62,11 +62,12 @@ async def main() -> None: """Build a two step sequential workflow and run it with streaming to observe events.""" # Step 1: Build the workflow graph. # Order matters. We connect upper_case_executor -> reverse_text_executor and set the start. + upper_case_executor = UpperCaseExecutor(id="upper_case_executor") + reverse_text_executor = ReverseTextExecutor(id="reverse_text_executor") + workflow = ( - WorkflowBuilder(start_executor="upper_case_executor") - .register_executor(lambda: UpperCaseExecutor(id="upper_case_executor"), name="upper_case_executor") - .register_executor(lambda: ReverseTextExecutor(id="reverse_text_executor"), name="reverse_text_executor") - .add_edge("upper_case_executor", "reverse_text_executor") + WorkflowBuilder(start_executor=upper_case_executor) + .add_edge(upper_case_executor, reverse_text_executor) .build() ) diff --git a/python/samples/getting_started/workflows/control-flow/sequential_streaming.py b/python/samples/03-workflows/control-flow/sequential_streaming.py similarity index 91% rename from python/samples/getting_started/workflows/control-flow/sequential_streaming.py rename to python/samples/03-workflows/control-flow/sequential_streaming.py index 86ad69652a..40244499ed 100644 --- a/python/samples/getting_started/workflows/control-flow/sequential_streaming.py +++ b/python/samples/03-workflows/control-flow/sequential_streaming.py @@ -56,10 +56,8 @@ async def main(): # Step 1: Build the workflow with the defined edges. # Order matters. upper_case_executor runs first, then reverse_text_executor. workflow = ( - WorkflowBuilder(start_executor="upper_case_executor") - .register_executor(lambda: to_upper_case, name="upper_case_executor") - .register_executor(lambda: reverse_text, name="reverse_text_executor") - .add_edge("upper_case_executor", "reverse_text_executor") + WorkflowBuilder(start_executor=to_upper_case) + .add_edge(to_upper_case, reverse_text) .build() ) diff --git a/python/samples/getting_started/workflows/control-flow/simple_loop.py b/python/samples/03-workflows/control-flow/simple_loop.py similarity index 76% rename from python/samples/getting_started/workflows/control-flow/simple_loop.py rename to python/samples/03-workflows/control-flow/simple_loop.py index 21e7907a5f..571d90c70b 100644 --- a/python/samples/getting_started/workflows/control-flow/simple_loop.py +++ b/python/samples/03-workflows/control-flow/simple_loop.py @@ -1,19 +1,22 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from enum import Enum from agent_framework import ( + Agent, + AgentExecutor, AgentExecutorRequest, AgentExecutorResponse, - ChatAgent, - ChatMessage, + AgentResponseUpdate, Executor, + Message, WorkflowBuilder, WorkflowContext, handler, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential """ @@ -25,7 +28,8 @@ What it does: - The workflow completes when the correct number is guessed. Prerequisites: -- Azure AI/ Azure OpenAI for `AzureOpenAIChatClient` agent. +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure AI/ Azure OpenAI for `AzureOpenAIResponsesClient` agent. - Authentication via `azure-identity` — uses `AzureCliCredential()` (run `az login`). """ @@ -94,7 +98,7 @@ class SubmitToJudgeAgent(Executor): f"Target: {self._target}\nGuess: {guess}\nResponse:" ) await ctx.send_message( - AgentExecutorRequest(messages=[ChatMessage("user", text=prompt)], should_respond=True), + AgentExecutorRequest(messages=[Message("user", text=prompt)], should_respond=True), target_id=self._judge_agent_id, ) @@ -113,9 +117,13 @@ class ParseJudgeResponse(Executor): await ctx.send_message(NumberSignal.BELOW) -def create_judge_agent() -> ChatAgent: +def create_judge_agent() -> Agent: """Create a judge agent that evaluates guesses.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( instructions=("You strictly respond with one of: MATCHED, ABOVE, BELOW based on the given target and guess."), name="judge_agent", ) @@ -125,25 +133,30 @@ async def main(): """Main function to run the workflow.""" # Step 1: Build the workflow with the defined edges. # This time we are creating a loop in the workflow. + guess_number = GuessNumberExecutor((1, 100), "guess_number") + judge_agent = AgentExecutor(create_judge_agent()) + submit_judge = SubmitToJudgeAgent(judge_agent_id="judge_agent", target=30) + parse_judge = ParseJudgeResponse(id="parse_judge") + workflow = ( - WorkflowBuilder(start_executor="guess_number") - .register_executor(lambda: GuessNumberExecutor((1, 100), "guess_number"), name="guess_number") - .register_agent(create_judge_agent, name="judge_agent") - .register_executor(lambda: SubmitToJudgeAgent(judge_agent_id="judge_agent", target=30), name="submit_judge") - .register_executor(lambda: ParseJudgeResponse(id="parse_judge"), name="parse_judge") - .add_edge("guess_number", "submit_judge") - .add_edge("submit_judge", "judge_agent") - .add_edge("judge_agent", "parse_judge") - .add_edge("parse_judge", "guess_number") + WorkflowBuilder(start_executor=guess_number) + .add_edge(guess_number, submit_judge) + .add_edge(submit_judge, judge_agent) + .add_edge(judge_agent, parse_judge) + .add_edge(parse_judge, guess_number) .build() ) - # Step 2: Run the workflow and print the events. + # Step 2: Run the workflow with concise streaming output. iterations = 0 async for event in workflow.run(NumberSignal.INIT, stream=True): if event.type == "executor_completed" and event.executor_id == "guess_number": iterations += 1 - print(f"Event: {event}") + elif event.type == "output": + if isinstance(event.data, AgentResponseUpdate): + # Agent executor streams token-level updates; skip to avoid noisy logs. + continue + print(f"Workflow output: {event.data}") # This is essentially a binary search, so the number of iterations should be logarithmic. # The maximum number of iterations is [log2(range size)]. For a range of 1 to 100, this is log2(100) which is 7. diff --git a/python/samples/getting_started/workflows/control-flow/switch_case_edge_group.py b/python/samples/03-workflows/control-flow/switch_case_edge_group.py similarity index 80% rename from python/samples/getting_started/workflows/control-flow/switch_case_edge_group.py rename to python/samples/03-workflows/control-flow/switch_case_edge_group.py index 640119347c..994796e096 100644 --- a/python/samples/getting_started/workflows/control-flow/switch_case_edge_group.py +++ b/python/samples/03-workflows/control-flow/switch_case_edge_group.py @@ -7,17 +7,18 @@ from typing import Any, Literal from uuid import uuid4 from agent_framework import ( # Core chat primitives used to form LLM requests + Agent, + AgentExecutor, AgentExecutorRequest, # Message bundle sent to an AgentExecutor AgentExecutorResponse, # Result returned by an AgentExecutor Case, - ChatAgent, # Case entry for a switch-case edge group - ChatMessage, Default, # Default branch when no cases match + Message, WorkflowBuilder, # Fluent builder for assembling the graph WorkflowContext, # Per-run context and event bus executor, # Decorator to turn a function into a workflow executor ) -from agent_framework.azure import AzureOpenAIChatClient # Thin client for Azure OpenAI chat models +from agent_framework.azure import AzureOpenAIResponsesClient # Thin client for Azure OpenAI chat models from azure.identity import AzureCliCredential # Uses your az CLI login for credentials from pydantic import BaseModel # Structured outputs with validation from typing_extensions import Never @@ -38,9 +39,10 @@ on that type. - Use ctx.yield_output() to provide workflow results - the workflow completes when idle with no pending work. Prerequisites: +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - Familiarity with WorkflowBuilder, executors, edges, and events. - Understanding of switch-case edge groups and how Case and Default are evaluated in order. -- Working Azure OpenAI configuration for AzureOpenAIChatClient, with Azure CLI login and required environment variables. +- Working Azure OpenAI configuration for AzureOpenAIResponsesClient, with Azure CLI login and required environment variables. - Access to workflow/resources/ambiguous_email.txt, or accept the inline fallback string. """ @@ -98,7 +100,7 @@ async def store_email(email_text: str, ctx: WorkflowContext[AgentExecutorRequest # Kick off the detector by forwarding the email as a user message to the spam_detection_agent. await ctx.send_message( - AgentExecutorRequest(messages=[ChatMessage("user", text=new_email.email_content)], should_respond=True) + AgentExecutorRequest(messages=[Message("user", text=new_email.email_content)], should_respond=True) ) @@ -119,7 +121,7 @@ async def submit_to_email_assistant(detection: DetectionResult, ctx: WorkflowCon # Load the original content from workflow state using the id carried in DetectionResult. email: Email = ctx.get_state(f"{EMAIL_STATE_PREFIX}{detection.email_id}") await ctx.send_message( - AgentExecutorRequest(messages=[ChatMessage("user", text=email.email_content)], should_respond=True) + AgentExecutorRequest(messages=[Message("user", text=email.email_content)], should_respond=True) ) @@ -151,9 +153,13 @@ async def handle_uncertain(detection: DetectionResult, ctx: WorkflowContext[Neve raise RuntimeError("This executor should only handle Uncertain messages.") -def create_spam_detection_agent() -> ChatAgent: +def create_spam_detection_agent() -> Agent: """Create and return the spam detection agent.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( instructions=( "You are a spam detection assistant that identifies spam emails. " "Be less confident in your assessments. " @@ -165,9 +171,13 @@ def create_spam_detection_agent() -> ChatAgent: ) -def create_email_assistant_agent() -> ChatAgent: +def create_email_assistant_agent() -> Agent: """Create and return the email assistant agent.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( instructions=("You are an email assistant that helps users draft responses to emails with professionalism."), name="email_assistant_agent", default_options={"response_format": EmailResponse}, @@ -178,28 +188,23 @@ async def main(): """Main function to run the workflow.""" # Build workflow: store -> detection agent -> to_detection_result -> switch (NotSpam or Spam or Default). # The switch-case group evaluates cases in order, then falls back to Default when none match. + spam_detection_agent = AgentExecutor(create_spam_detection_agent()) + email_assistant_agent = AgentExecutor(create_email_assistant_agent()) + workflow = ( - WorkflowBuilder(start_executor="store_email") - .register_agent(create_spam_detection_agent, name="spam_detection_agent") - .register_agent(create_email_assistant_agent, name="email_assistant_agent") - .register_executor(lambda: store_email, name="store_email") - .register_executor(lambda: to_detection_result, name="to_detection_result") - .register_executor(lambda: submit_to_email_assistant, name="submit_to_email_assistant") - .register_executor(lambda: finalize_and_send, name="finalize_and_send") - .register_executor(lambda: handle_spam, name="handle_spam") - .register_executor(lambda: handle_uncertain, name="handle_uncertain") - .add_edge("store_email", "spam_detection_agent") - .add_edge("spam_detection_agent", "to_detection_result") + WorkflowBuilder(start_executor=store_email) + .add_edge(store_email, spam_detection_agent) + .add_edge(spam_detection_agent, to_detection_result) .add_switch_case_edge_group( - "to_detection_result", + to_detection_result, [ - Case(condition=get_case("NotSpam"), target="submit_to_email_assistant"), - Case(condition=get_case("Spam"), target="handle_spam"), - Default(target="handle_uncertain"), + Case(condition=get_case("NotSpam"), target=submit_to_email_assistant), + Case(condition=get_case("Spam"), target=handle_spam), + Default(target=handle_uncertain), ], ) - .add_edge("submit_to_email_assistant", "email_assistant_agent") - .add_edge("email_assistant_agent", "finalize_and_send") + .add_edge(submit_to_email_assistant, email_assistant_agent) + .add_edge(email_assistant_agent, finalize_and_send) .build() ) diff --git a/python/samples/getting_started/workflows/control-flow/workflow_cancellation.py b/python/samples/03-workflows/control-flow/workflow_cancellation.py similarity index 90% rename from python/samples/getting_started/workflows/control-flow/workflow_cancellation.py rename to python/samples/03-workflows/control-flow/workflow_cancellation.py index d553331fad..5eefbf0c65 100644 --- a/python/samples/getting_started/workflows/control-flow/workflow_cancellation.py +++ b/python/samples/03-workflows/control-flow/workflow_cancellation.py @@ -51,12 +51,9 @@ async def step3(text: str, ctx: WorkflowContext[Never, str]) -> None: def build_workflow(): """Build a simple 3-step sequential workflow (~6 seconds total).""" return ( - WorkflowBuilder(start_executor="step1") - .register_executor(lambda: step1, name="step1") - .register_executor(lambda: step2, name="step2") - .register_executor(lambda: step3, name="step3") - .add_edge("step1", "step2") - .add_edge("step2", "step3") + WorkflowBuilder(start_executor=step1) + .add_edge(step1, step2) + .add_edge(step2, step3) .build() ) diff --git a/python/samples/getting_started/workflows/declarative/README.md b/python/samples/03-workflows/declarative/README.md similarity index 100% rename from python/samples/getting_started/workflows/declarative/README.md rename to python/samples/03-workflows/declarative/README.md diff --git a/python/samples/getting_started/workflows/declarative/__init__.py b/python/samples/03-workflows/declarative/__init__.py similarity index 100% rename from python/samples/getting_started/workflows/declarative/__init__.py rename to python/samples/03-workflows/declarative/__init__.py diff --git a/python/samples/getting_started/workflows/declarative/conditional_workflow/README.md b/python/samples/03-workflows/declarative/conditional_workflow/README.md similarity index 100% rename from python/samples/getting_started/workflows/declarative/conditional_workflow/README.md rename to python/samples/03-workflows/declarative/conditional_workflow/README.md diff --git a/python/samples/getting_started/workflows/declarative/conditional_workflow/main.py b/python/samples/03-workflows/declarative/conditional_workflow/main.py similarity index 100% rename from python/samples/getting_started/workflows/declarative/conditional_workflow/main.py rename to python/samples/03-workflows/declarative/conditional_workflow/main.py diff --git a/python/samples/getting_started/workflows/declarative/conditional_workflow/workflow.yaml b/python/samples/03-workflows/declarative/conditional_workflow/workflow.yaml similarity index 100% rename from python/samples/getting_started/workflows/declarative/conditional_workflow/workflow.yaml rename to python/samples/03-workflows/declarative/conditional_workflow/workflow.yaml diff --git a/python/samples/getting_started/workflows/declarative/customer_support/README.md b/python/samples/03-workflows/declarative/customer_support/README.md similarity index 100% rename from python/samples/getting_started/workflows/declarative/customer_support/README.md rename to python/samples/03-workflows/declarative/customer_support/README.md diff --git a/python/samples/getting_started/workflows/declarative/customer_support/__init__.py b/python/samples/03-workflows/declarative/customer_support/__init__.py similarity index 100% rename from python/samples/getting_started/workflows/declarative/customer_support/__init__.py rename to python/samples/03-workflows/declarative/customer_support/__init__.py diff --git a/python/samples/getting_started/workflows/declarative/customer_support/main.py b/python/samples/03-workflows/declarative/customer_support/main.py similarity index 94% rename from python/samples/getting_started/workflows/declarative/customer_support/main.py rename to python/samples/03-workflows/declarative/customer_support/main.py index b06633524f..cbb4eefb9e 100644 --- a/python/samples/getting_started/workflows/declarative/customer_support/main.py +++ b/python/samples/03-workflows/declarative/customer_support/main.py @@ -23,10 +23,11 @@ The workflow: import asyncio import json import logging +import os import uuid from pathlib import Path -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.declarative import ( AgentExternalInputRequest, AgentExternalInputResponse, @@ -164,43 +165,47 @@ async def main() -> None: plugin = TicketingPlugin() # Create Azure OpenAI client - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) # Create agents with structured outputs - self_service_agent = chat_client.as_agent( + self_service_agent = client.as_agent( name="SelfServiceAgent", instructions=SELF_SERVICE_INSTRUCTIONS, default_options={"response_format": SelfServiceResponse}, ) - ticketing_agent = chat_client.as_agent( + ticketing_agent = client.as_agent( name="TicketingAgent", instructions=TICKETING_INSTRUCTIONS, tools=plugin.get_functions(), default_options={"response_format": TicketingResponse}, ) - routing_agent = chat_client.as_agent( + routing_agent = client.as_agent( name="TicketRoutingAgent", instructions=TICKET_ROUTING_INSTRUCTIONS, tools=[plugin.get_ticket], default_options={"response_format": RoutingResponse}, ) - windows_support_agent = chat_client.as_agent( + windows_support_agent = client.as_agent( name="WindowsSupportAgent", instructions=WINDOWS_SUPPORT_INSTRUCTIONS, tools=[plugin.get_ticket], default_options={"response_format": SupportResponse}, ) - resolution_agent = chat_client.as_agent( + resolution_agent = client.as_agent( name="TicketResolutionAgent", instructions=RESOLUTION_INSTRUCTIONS, tools=[plugin.resolve_ticket], ) - escalation_agent = chat_client.as_agent( + escalation_agent = client.as_agent( name="TicketEscalationAgent", instructions=ESCALATION_INSTRUCTIONS, tools=[plugin.get_ticket, plugin.send_notification], @@ -260,7 +265,9 @@ async def main() -> None: async for event in stream: if event.type == "output": data = event.data - source_id = getattr(event, "source_executor_id", "") + # source_executor_id is only available on request_info events. + # For output events, use executor_id to identify the emitting node. + source_id = event.executor_id or "" # Check if this is a SendActivity output (activity text from log_ticket, log_route, etc.) if "log_" in source_id.lower(): diff --git a/python/samples/getting_started/workflows/declarative/customer_support/ticketing_plugin.py b/python/samples/03-workflows/declarative/customer_support/ticketing_plugin.py similarity index 100% rename from python/samples/getting_started/workflows/declarative/customer_support/ticketing_plugin.py rename to python/samples/03-workflows/declarative/customer_support/ticketing_plugin.py diff --git a/python/samples/getting_started/workflows/declarative/customer_support/workflow.yaml b/python/samples/03-workflows/declarative/customer_support/workflow.yaml similarity index 100% rename from python/samples/getting_started/workflows/declarative/customer_support/workflow.yaml rename to python/samples/03-workflows/declarative/customer_support/workflow.yaml diff --git a/python/samples/getting_started/workflows/declarative/deep_research/README.md b/python/samples/03-workflows/declarative/deep_research/README.md similarity index 100% rename from python/samples/getting_started/workflows/declarative/deep_research/README.md rename to python/samples/03-workflows/declarative/deep_research/README.md diff --git a/python/samples/getting_started/workflows/declarative/deep_research/__init__.py b/python/samples/03-workflows/declarative/deep_research/__init__.py similarity index 100% rename from python/samples/getting_started/workflows/declarative/deep_research/__init__.py rename to python/samples/03-workflows/declarative/deep_research/__init__.py diff --git a/python/samples/getting_started/workflows/declarative/deep_research/main.py b/python/samples/03-workflows/declarative/deep_research/main.py similarity index 93% rename from python/samples/getting_started/workflows/declarative/deep_research/main.py rename to python/samples/03-workflows/declarative/deep_research/main.py index 3e4ecf7d19..a36f3f49a1 100644 --- a/python/samples/getting_started/workflows/declarative/deep_research/main.py +++ b/python/samples/03-workflows/declarative/deep_research/main.py @@ -22,9 +22,10 @@ Usage: """ import asyncio +import os from pathlib import Path -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.declarative import WorkflowFactory from azure.identity import AzureCliCredential from pydantic import BaseModel, Field @@ -122,41 +123,45 @@ class ManagerResponse(BaseModel): async def main() -> None: """Run the deep research workflow.""" # Create Azure OpenAI client - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) # Create agents - research_agent = chat_client.as_agent( + research_agent = client.as_agent( name="ResearchAgent", instructions=RESEARCH_INSTRUCTIONS, ) - planner_agent = chat_client.as_agent( + planner_agent = client.as_agent( name="PlannerAgent", instructions=PLANNER_INSTRUCTIONS, ) - manager_agent = chat_client.as_agent( + manager_agent = client.as_agent( name="ManagerAgent", instructions=MANAGER_INSTRUCTIONS, default_options={"response_format": ManagerResponse}, ) - summary_agent = chat_client.as_agent( + summary_agent = client.as_agent( name="SummaryAgent", instructions=SUMMARY_INSTRUCTIONS, ) - knowledge_agent = chat_client.as_agent( + knowledge_agent = client.as_agent( name="KnowledgeAgent", instructions=KNOWLEDGE_INSTRUCTIONS, ) - coder_agent = chat_client.as_agent( + coder_agent = client.as_agent( name="CoderAgent", instructions=CODER_INSTRUCTIONS, ) - weather_agent = chat_client.as_agent( + weather_agent = client.as_agent( name="WeatherAgent", instructions=WEATHER_INSTRUCTIONS, ) diff --git a/python/samples/getting_started/workflows/declarative/function_tools/README.md b/python/samples/03-workflows/declarative/function_tools/README.md similarity index 95% rename from python/samples/getting_started/workflows/declarative/function_tools/README.md rename to python/samples/03-workflows/declarative/function_tools/README.md index 42f3dc6497..78e7cf361e 100644 --- a/python/samples/getting_started/workflows/declarative/function_tools/README.md +++ b/python/samples/03-workflows/declarative/function_tools/README.md @@ -72,8 +72,8 @@ Session Complete ```python # Create the agent with tools -chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) -menu_agent = chat_client.as_agent( +client = AzureOpenAIChatClient(credential=AzureCliCredential()) +menu_agent = client.as_agent( name="MenuAgent", instructions="You are a helpful restaurant menu assistant...", tools=[get_menu, get_specials, get_item_price], diff --git a/python/samples/getting_started/workflows/declarative/function_tools/main.py b/python/samples/03-workflows/declarative/function_tools/main.py similarity index 88% rename from python/samples/getting_started/workflows/declarative/function_tools/main.py rename to python/samples/03-workflows/declarative/function_tools/main.py index 6e4b3f272c..521639b1ad 100644 --- a/python/samples/getting_started/workflows/declarative/function_tools/main.py +++ b/python/samples/03-workflows/declarative/function_tools/main.py @@ -6,12 +6,13 @@ function tools assigned. Exits the loop when the user enters "exit". """ import asyncio +import os from dataclasses import dataclass from pathlib import Path from typing import Annotated, Any from agent_framework import FileCheckpointStorage, tool -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework_declarative import ExternalInputRequest, ExternalInputResponse, WorkflowFactory from azure.identity import AzureCliCredential from pydantic import Field @@ -38,7 +39,7 @@ MENU_ITEMS = [ ] -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_menu() -> list[dict[str, Any]]: """Get all menu items.""" @@ -62,8 +63,12 @@ def get_item_price(name: Annotated[str, Field(description="Menu item name")]) -> async def main(): # Create agent with tools - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - menu_agent = chat_client.as_agent( + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + menu_agent = client.as_agent( name="MenuAgent", instructions="Answer questions about menu items, specials, and prices.", tools=[get_menu, get_specials, get_item_price], diff --git a/python/samples/getting_started/workflows/declarative/function_tools/workflow.yaml b/python/samples/03-workflows/declarative/function_tools/workflow.yaml similarity index 100% rename from python/samples/getting_started/workflows/declarative/function_tools/workflow.yaml rename to python/samples/03-workflows/declarative/function_tools/workflow.yaml diff --git a/python/samples/getting_started/workflows/declarative/human_in_loop/README.md b/python/samples/03-workflows/declarative/human_in_loop/README.md similarity index 100% rename from python/samples/getting_started/workflows/declarative/human_in_loop/README.md rename to python/samples/03-workflows/declarative/human_in_loop/README.md diff --git a/python/samples/getting_started/workflows/declarative/human_in_loop/main.py b/python/samples/03-workflows/declarative/human_in_loop/main.py similarity index 100% rename from python/samples/getting_started/workflows/declarative/human_in_loop/main.py rename to python/samples/03-workflows/declarative/human_in_loop/main.py diff --git a/python/samples/getting_started/workflows/declarative/human_in_loop/workflow.yaml b/python/samples/03-workflows/declarative/human_in_loop/workflow.yaml similarity index 100% rename from python/samples/getting_started/workflows/declarative/human_in_loop/workflow.yaml rename to python/samples/03-workflows/declarative/human_in_loop/workflow.yaml diff --git a/python/samples/getting_started/workflows/declarative/marketing/README.md b/python/samples/03-workflows/declarative/marketing/README.md similarity index 100% rename from python/samples/getting_started/workflows/declarative/marketing/README.md rename to python/samples/03-workflows/declarative/marketing/README.md diff --git a/python/samples/getting_started/workflows/declarative/marketing/main.py b/python/samples/03-workflows/declarative/marketing/main.py similarity index 86% rename from python/samples/getting_started/workflows/declarative/marketing/main.py rename to python/samples/03-workflows/declarative/marketing/main.py index 2f5e999aa7..f59b19947e 100644 --- a/python/samples/getting_started/workflows/declarative/marketing/main.py +++ b/python/samples/03-workflows/declarative/marketing/main.py @@ -13,9 +13,10 @@ Demonstrates sequential multi-agent pipeline: """ import asyncio +import os from pathlib import Path -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.declarative import WorkflowFactory from azure.identity import AzureCliCredential @@ -49,17 +50,21 @@ Return the final polished version.""" async def main() -> None: """Run the marketing workflow with real Azure AI agents.""" - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) - analyst_agent = chat_client.as_agent( + analyst_agent = client.as_agent( name="AnalystAgent", instructions=ANALYST_INSTRUCTIONS, ) - writer_agent = chat_client.as_agent( + writer_agent = client.as_agent( name="WriterAgent", instructions=WRITER_INSTRUCTIONS, ) - editor_agent = chat_client.as_agent( + editor_agent = client.as_agent( name="EditorAgent", instructions=EDITOR_INSTRUCTIONS, ) diff --git a/python/samples/getting_started/workflows/declarative/marketing/workflow.yaml b/python/samples/03-workflows/declarative/marketing/workflow.yaml similarity index 100% rename from python/samples/getting_started/workflows/declarative/marketing/workflow.yaml rename to python/samples/03-workflows/declarative/marketing/workflow.yaml diff --git a/python/samples/getting_started/workflows/declarative/simple_workflow/README.md b/python/samples/03-workflows/declarative/simple_workflow/README.md similarity index 100% rename from python/samples/getting_started/workflows/declarative/simple_workflow/README.md rename to python/samples/03-workflows/declarative/simple_workflow/README.md diff --git a/python/samples/getting_started/workflows/declarative/simple_workflow/main.py b/python/samples/03-workflows/declarative/simple_workflow/main.py similarity index 100% rename from python/samples/getting_started/workflows/declarative/simple_workflow/main.py rename to python/samples/03-workflows/declarative/simple_workflow/main.py diff --git a/python/samples/getting_started/workflows/declarative/simple_workflow/workflow.yaml b/python/samples/03-workflows/declarative/simple_workflow/workflow.yaml similarity index 100% rename from python/samples/getting_started/workflows/declarative/simple_workflow/workflow.yaml rename to python/samples/03-workflows/declarative/simple_workflow/workflow.yaml diff --git a/python/samples/getting_started/workflows/declarative/student_teacher/README.md b/python/samples/03-workflows/declarative/student_teacher/README.md similarity index 100% rename from python/samples/getting_started/workflows/declarative/student_teacher/README.md rename to python/samples/03-workflows/declarative/student_teacher/README.md diff --git a/python/samples/getting_started/workflows/declarative/student_teacher/main.py b/python/samples/03-workflows/declarative/student_teacher/main.py similarity index 83% rename from python/samples/getting_started/workflows/declarative/student_teacher/main.py rename to python/samples/03-workflows/declarative/student_teacher/main.py index ec06c4fc7d..1984265aa8 100644 --- a/python/samples/getting_started/workflows/declarative/student_teacher/main.py +++ b/python/samples/03-workflows/declarative/student_teacher/main.py @@ -15,14 +15,15 @@ The workflow loops until the teacher gives congratulations or max turns reached. Prerequisites: - Azure OpenAI deployment with chat completion capability - Environment variables: - AZURE_OPENAI_ENDPOINT: Your Azure OpenAI endpoint - AZURE_OPENAI_DEPLOYMENT_NAME: Your deployment name (optional, defaults to gpt-4o) + AZURE_AI_PROJECT_ENDPOINT: Your Azure AI Foundry Agent Service (V2) project endpoint + AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name """ import asyncio +import os from pathlib import Path -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.declarative import WorkflowFactory from azure.identity import AzureCliCredential @@ -51,15 +52,19 @@ Focus on building understanding, not just getting the right answer.""" async def main() -> None: """Run the student-teacher workflow with real Azure AI agents.""" # Create chat client - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) # Create student and teacher agents - student_agent = chat_client.as_agent( + student_agent = client.as_agent( name="StudentAgent", instructions=STUDENT_INSTRUCTIONS, ) - teacher_agent = chat_client.as_agent( + teacher_agent = client.as_agent( name="TeacherAgent", instructions=TEACHER_INSTRUCTIONS, ) diff --git a/python/samples/getting_started/workflows/declarative/student_teacher/workflow.yaml b/python/samples/03-workflows/declarative/student_teacher/workflow.yaml similarity index 100% rename from python/samples/getting_started/workflows/declarative/student_teacher/workflow.yaml rename to python/samples/03-workflows/declarative/student_teacher/workflow.yaml diff --git a/python/samples/getting_started/workflows/human-in-the-loop/agents_with_HITL.py b/python/samples/03-workflows/human-in-the-loop/agents_with_HITL.py similarity index 87% rename from python/samples/getting_started/workflows/human-in-the-loop/agents_with_HITL.py rename to python/samples/03-workflows/human-in-the-loop/agents_with_HITL.py index 7923bced7a..4398b269d9 100644 --- a/python/samples/getting_started/workflows/human-in-the-loop/agents_with_HITL.py +++ b/python/samples/03-workflows/human-in-the-loop/agents_with_HITL.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from collections.abc import AsyncIterable from dataclasses import dataclass, field @@ -9,15 +10,15 @@ from agent_framework import ( AgentExecutorResponse, AgentResponse, AgentResponseUpdate, - ChatMessage, Executor, + Message, WorkflowBuilder, WorkflowContext, WorkflowEvent, handler, response_handler, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential from typing_extensions import Never @@ -37,7 +38,8 @@ Demonstrates: - Handling human feedback and routing it to the appropriate agents. Prerequisites: -- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables. +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for AzureOpenAIResponsesClient with required environment variables. - Authentication via azure-identity. Run `az login` before executing. """ @@ -47,7 +49,7 @@ class DraftFeedbackRequest: """Payload sent for human review.""" prompt: str = "" - conversation: list[ChatMessage] = field(default_factory=lambda: []) + conversation: list[Message] = field(default_factory=lambda: []) class Coordinator(Executor): @@ -71,7 +73,7 @@ class Coordinator(Executor): # Writer agent response; request human feedback. # Preserve the full conversation so that the final editor has context. - conversation: list[ChatMessage] + conversation: list[Message] if draft.full_conversation is not None: conversation = list(draft.full_conversation) else: @@ -100,7 +102,7 @@ class Coordinator(Executor): # Human approved the draft as-is; forward it unchanged. await ctx.send_message( AgentExecutorRequest( - messages=original_request.conversation + [ChatMessage("user", text="The draft is approved as-is.")], + messages=original_request.conversation + [Message("user", text="The draft is approved as-is.")], should_respond=True, ), target_id=self.final_editor_name, @@ -108,14 +110,14 @@ class Coordinator(Executor): return # Human provided feedback; prompt the writer to revise. - conversation: list[ChatMessage] = list(original_request.conversation) + conversation: list[Message] = list(original_request.conversation) instruction = ( "A human reviewer shared the following guidance:\n" f"{note or 'No specific guidance provided.'}\n\n" "Rewrite the draft from the previous assistant message into a polished final version. " "Keep the response under 120 words and reflect any requested tone adjustments." ) - conversation.append(ChatMessage("user", text=instruction)) + conversation.append(Message("user", text=instruction)) await ctx.send_message( AgentExecutorRequest(messages=conversation, should_respond=True), target_id=self.writer_name ) @@ -161,13 +163,21 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: """Run the workflow and bridge human feedback between two agents.""" # Create the agents - writer_agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + writer_agent = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( name="writer_agent", instructions=("You are a marketing writer."), tool_choice="required", ) - final_editor_agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + final_editor_agent = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( name="final_editor_agent", instructions=( "You are an editor who polishes marketing copy after human approval. " diff --git a/python/samples/getting_started/workflows/human-in-the-loop/agents_with_approval_requests.py b/python/samples/03-workflows/human-in-the-loop/agents_with_approval_requests.py similarity index 91% rename from python/samples/getting_started/workflows/human-in-the-loop/agents_with_approval_requests.py rename to python/samples/03-workflows/human-in-the-loop/agents_with_approval_requests.py index c0d935bc03..7d0c050457 100644 --- a/python/samples/getting_started/workflows/human-in-the-loop/agents_with_approval_requests.py +++ b/python/samples/03-workflows/human-in-the-loop/agents_with_approval_requests.py @@ -2,6 +2,7 @@ import asyncio import json +import os from dataclasses import dataclass from typing import Annotated @@ -15,7 +16,8 @@ from agent_framework import ( handler, tool, ) -from agent_framework.openai import OpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient +from azure.identity import AzureCliCredential from typing_extensions import Never """ @@ -45,6 +47,7 @@ Demonstrate: - Handling approval requests during workflow execution. Prerequisites: +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - Azure AI Agent Service configured, along with the required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. - Basic familiarity with WorkflowBuilder, edges, events, request_info events (type='request_info'), and streaming runs. @@ -53,8 +56,8 @@ Prerequisites: # NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; # See: -# samples/getting_started/tools/function_tool_with_approval.py -# samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# samples/02-agents/tools/function_tool_with_approval.py +# samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_current_date() -> str: """Get the current date in YYYY-MM-DD format.""" @@ -193,13 +196,20 @@ class EmailPreprocessor(Executor): @handler async def preprocess(self, email: Email, ctx: WorkflowContext[str]) -> None: """Preprocess the incoming email.""" - message = str(email) + email_payload = ( + f"Incoming email:\n" + f"From: {email.sender}\n" + f"Subject: {email.subject}\n" + f"Body: {email.body}" + ) + message = email_payload if email.sender in self.special_email_addresses: note = ( - "Pay special attention to this sender. This email is very important. " - "Gather relevant information from all previous emails within my team before responding." + "Priority sender context: this message is business-critical. " + "If additional context is needed, use available tools to retrieve only the minimum relevant " + "prior team communication related to this request." ) - message = f"{note}\n\n{message}" + message = f"{note}\n\n{email_payload}" await ctx.send_message(message) @@ -215,7 +225,11 @@ async def conclude_workflow( async def main() -> None: # Create agent - email_writer_agent = OpenAIChatClient().as_agent( + email_writer_agent = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( name="EmailWriter", instructions=("You are an excellent email assistant. You respond to incoming emails."), # tools with `approval_mode="always_require"` will trigger approval requests diff --git a/python/samples/getting_started/workflows/human-in-the-loop/agents_with_declaration_only_tools.py b/python/samples/03-workflows/human-in-the-loop/agents_with_declaration_only_tools.py similarity index 89% rename from python/samples/getting_started/workflows/human-in-the-loop/agents_with_declaration_only_tools.py rename to python/samples/03-workflows/human-in-the-loop/agents_with_declaration_only_tools.py index b203c2d522..a9a68593be 100644 --- a/python/samples/getting_started/workflows/human-in-the-loop/agents_with_declaration_only_tools.py +++ b/python/samples/03-workflows/human-in-the-loop/agents_with_declaration_only_tools.py @@ -16,16 +16,18 @@ Flow: 4. The workflow resumes — the agent sees the tool result and finishes. Prerequisites: + - AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - Azure OpenAI endpoint configured via environment variables. - `az login` for AzureCliCredential. """ import asyncio import json +import os from typing import Any from agent_framework import Content, FunctionTool, WorkflowBuilder -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential # A declaration-only tool: the schema is sent to the LLM, but the framework @@ -45,7 +47,11 @@ get_user_location = FunctionTool( async def main() -> None: - agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + agent = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( name="WeatherBot", instructions=( "You are a helpful weather assistant. " diff --git a/python/samples/getting_started/workflows/human-in-the-loop/concurrent_request_info.py b/python/samples/03-workflows/human-in-the-loop/concurrent_request_info.py similarity index 91% rename from python/samples/getting_started/workflows/human-in-the-loop/concurrent_request_info.py rename to python/samples/03-workflows/human-in-the-loop/concurrent_request_info.py index fbc996038c..80a98df1bb 100644 --- a/python/samples/getting_started/workflows/human-in-the-loop/concurrent_request_info.py +++ b/python/samples/03-workflows/human-in-the-loop/concurrent_request_info.py @@ -17,25 +17,27 @@ Demonstrate: - Injecting human guidance for specific agents before aggregation Prerequisites: -- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for AzureOpenAIResponsesClient with required environment variables - Authentication via azure-identity (run az login before executing) """ import asyncio +import os from collections.abc import AsyncIterable from typing import Any from agent_framework import ( AgentExecutorResponse, - ChatMessage, + Message, WorkflowEvent, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.orchestrations import AgentRequestInfoResponse, ConcurrentBuilder from azure.identity import AzureCliCredential # Store chat client at module level for aggregator access -_chat_client: AzureOpenAIChatClient | None = None +_chat_client: AzureOpenAIResponsesClient | None = None async def aggregate_with_synthesis(results: list[AgentExecutorResponse]) -> Any: @@ -76,7 +78,7 @@ async def aggregate_with_synthesis(results: list[AgentExecutorResponse]) -> Any: # Build prompt with human guidance if provided guidance_text = f"\n\nHuman guidance: {human_guidance}" if human_guidance else "" - system_msg = ChatMessage( + system_msg = Message( "system", text=( "You are a synthesis expert. Consolidate the following analyst perspectives " @@ -84,7 +86,7 @@ async def aggregate_with_synthesis(results: list[AgentExecutorResponse]) -> Any: "prioritize aspects as directed." ), ) - user_msg = ChatMessage("user", text="\n\n".join(expert_sections) + guidance_text) + user_msg = Message("user", text="\n\n".join(expert_sections) + guidance_text) response = await _chat_client.get_response([system_msg, user_msg]) return response.messages[-1].text if response.messages else "" @@ -142,7 +144,11 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: global _chat_client - _chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + _chat_client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) # Create agents that analyze from different perspectives technical_analyst = _chat_client.as_agent( diff --git a/python/samples/getting_started/workflows/human-in-the-loop/group_chat_request_info.py b/python/samples/03-workflows/human-in-the-loop/group_chat_request_info.py similarity index 90% rename from python/samples/getting_started/workflows/human-in-the-loop/group_chat_request_info.py rename to python/samples/03-workflows/human-in-the-loop/group_chat_request_info.py index 6a400a5bab..40186ac7fd 100644 --- a/python/samples/getting_started/workflows/human-in-the-loop/group_chat_request_info.py +++ b/python/samples/03-workflows/human-in-the-loop/group_chat_request_info.py @@ -18,20 +18,22 @@ Demonstrate: - Steering agent behavior with pre-agent human input Prerequisites: -- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for AzureOpenAIResponsesClient with required environment variables - Authentication via azure-identity (run az login before executing) """ import asyncio +import os from collections.abc import AsyncIterable from typing import cast from agent_framework import ( AgentExecutorResponse, - ChatMessage, + Message, WorkflowEvent, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.orchestrations import AgentRequestInfoResponse, GroupChatBuilder from azure.identity import AzureCliCredential @@ -51,7 +53,7 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str print("=" * 60) print("Final discussion summary:") # To make the type checker happy, we cast event.data to the expected type - outputs = cast(list[ChatMessage], event.data) + outputs = cast(list[Message], event.data) for msg in outputs: speaker = msg.author_name or msg.role print(f"[{speaker}]: {msg.text}") @@ -91,10 +93,14 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) # Create agents for a group discussion - optimist = chat_client.as_agent( + optimist = client.as_agent( name="optimist", instructions=( "You are an optimistic team member. You see opportunities and potential " @@ -103,7 +109,7 @@ async def main() -> None: ), ) - pragmatist = chat_client.as_agent( + pragmatist = client.as_agent( name="pragmatist", instructions=( "You are a pragmatic team member. You focus on practical implementation " @@ -112,7 +118,7 @@ async def main() -> None: ), ) - creative = chat_client.as_agent( + creative = client.as_agent( name="creative", instructions=( "You are a creative team member. You propose innovative solutions and " @@ -122,7 +128,7 @@ async def main() -> None: ) # Orchestrator coordinates the discussion - orchestrator = chat_client.as_agent( + orchestrator = client.as_agent( name="orchestrator", instructions=( "You are a discussion manager coordinating a team conversation between participants. " diff --git a/python/samples/getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py b/python/samples/03-workflows/human-in-the-loop/guessing_game_with_human_input.py similarity index 93% rename from python/samples/getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py rename to python/samples/03-workflows/human-in-the-loop/guessing_game_with_human_input.py index fcadfe1575..3ceb284ed6 100644 --- a/python/samples/getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py +++ b/python/samples/03-workflows/human-in-the-loop/guessing_game_with_human_input.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from collections.abc import AsyncIterable from dataclasses import dataclass @@ -8,15 +9,15 @@ from agent_framework import ( AgentExecutorRequest, AgentExecutorResponse, AgentResponseUpdate, - ChatMessage, Executor, + Message, WorkflowBuilder, WorkflowContext, WorkflowEvent, handler, response_handler, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential from pydantic import BaseModel @@ -37,7 +38,8 @@ Demonstrate: - Driving the loop in application code with run and responses parameter. Prerequisites: -- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables. +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for AzureOpenAIResponsesClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. - Basic familiarity with WorkflowBuilder, executors, edges, events, and streaming runs. """ @@ -84,7 +86,7 @@ class TurnManager(Executor): - Input is a simple starter token (ignored here). - Output is an AgentExecutorRequest that triggers the agent to produce a guess. """ - user = ChatMessage("user", text="Start by making your first guess.") + user = Message("user", text="Start by making your first guess.") await ctx.send_message(AgentExecutorRequest(messages=[user], should_respond=True)) @handler @@ -136,7 +138,7 @@ class TurnManager(Executor): f"Feedback: {reply}. Your last guess was {last_guess}. " f"Use this feedback to adjust and make your next guess (1-10)." ) - user_msg = ChatMessage("user", text=feedback_text) + user_msg = Message("user", text=feedback_text) await ctx.send_message(AgentExecutorRequest(messages=[user_msg], should_respond=True)) @@ -183,7 +185,11 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: """Run the human-in-the-loop guessing game workflow.""" # Create agent and executor - guessing_agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + guessing_agent = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( name="GuessingAgent", instructions=( "You guess a number between 1 and 10. " diff --git a/python/samples/getting_started/workflows/human-in-the-loop/sequential_request_info.py b/python/samples/03-workflows/human-in-the-loop/sequential_request_info.py similarity index 88% rename from python/samples/getting_started/workflows/human-in-the-loop/sequential_request_info.py rename to python/samples/03-workflows/human-in-the-loop/sequential_request_info.py index 503f016a71..633f218632 100644 --- a/python/samples/getting_started/workflows/human-in-the-loop/sequential_request_info.py +++ b/python/samples/03-workflows/human-in-the-loop/sequential_request_info.py @@ -17,20 +17,22 @@ Demonstrate: - Injecting responses back into the workflow via run(responses=..., stream=True) Prerequisites: -- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for AzureOpenAIResponsesClient with required environment variables - Authentication via azure-identity (run az login before executing) """ import asyncio +import os from collections.abc import AsyncIterable from typing import cast from agent_framework import ( AgentExecutorResponse, - ChatMessage, + Message, WorkflowEvent, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.orchestrations import AgentRequestInfoResponse, SequentialBuilder from azure.identity import AzureCliCredential @@ -49,7 +51,7 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str print("WORKFLOW COMPLETE") print("=" * 60) print("Final output:") - outputs = cast(list[ChatMessage], event.data) + outputs = cast(list[Message], event.data) for message in outputs: print(f"[{message.author_name or message.role}]: {message.text}") @@ -88,15 +90,19 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) # Create agents for a sequential document review workflow - drafter = chat_client.as_agent( + drafter = client.as_agent( name="drafter", instructions=("You are a document drafter. When given a topic, create a brief draft (2-3 sentences)."), ) - editor = chat_client.as_agent( + editor = client.as_agent( name="editor", instructions=( "You are an editor. Review the draft and make improvements. " @@ -104,7 +110,7 @@ async def main() -> None: ), ) - finalizer = chat_client.as_agent( + finalizer = client.as_agent( name="finalizer", instructions=( "You are a finalizer. Take the edited content and create a polished final version. " diff --git a/python/samples/getting_started/workflows/observability/executor_io_observation.py b/python/samples/03-workflows/observability/executor_io_observation.py similarity index 100% rename from python/samples/getting_started/workflows/observability/executor_io_observation.py rename to python/samples/03-workflows/observability/executor_io_observation.py diff --git a/python/samples/03-workflows/orchestrations/README.md b/python/samples/03-workflows/orchestrations/README.md new file mode 100644 index 0000000000..3ce85cd1ab --- /dev/null +++ b/python/samples/03-workflows/orchestrations/README.md @@ -0,0 +1,102 @@ +# Orchestration Getting Started Samples + +## Installation + +The orchestrations package is included when you install `agent-framework` (which pulls in all optional packages): + +```bash +pip install agent-framework +``` + +Or install the orchestrations package directly: + +```bash +pip install agent-framework-orchestrations +``` + +Orchestration builders are available via the `agent_framework.orchestrations` submodule: + +```python +from agent_framework.orchestrations import ( + SequentialBuilder, + ConcurrentBuilder, + HandoffBuilder, + GroupChatBuilder, + MagenticBuilder, +) +``` + +## Samples Overview (by directory) + +### concurrent + +| Sample | File | Concepts | +| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| Concurrent Orchestration (Default Aggregator) | [concurrent_agents.py](./concurrent_agents.py) | Fan-out to multiple agents; fan-in with default aggregator returning combined Messages | +| Concurrent Orchestration (Custom Aggregator) | [concurrent_custom_aggregator.py](./concurrent_custom_aggregator.py) | Override aggregator via callback; summarize results with an LLM | +| Concurrent Orchestration (Custom Agent Executors) | [concurrent_custom_agent_executors.py](./concurrent_custom_agent_executors.py) | Child executors own Agents; concurrent fan-out/fan-in via ConcurrentBuilder | +| Concurrent Orchestration as Agent | [concurrent_workflow_as_agent.py](../agents/concurrent_workflow_as_agent.py) | Build a ConcurrentBuilder workflow and expose it as an agent via `workflow.as_agent(...)` | +| Tool Approval with ConcurrentBuilder | [concurrent_builder_tool_approval.py](../tool-approval/concurrent_builder_tool_approval.py) | Require human approval for sensitive tools across concurrent participants | +| ConcurrentBuilder Request Info | [concurrent_request_info.py](../human-in-the-loop/concurrent_request_info.py) | Review concurrent agent outputs before aggregation using `.with_request_info()` | + +### sequential + +| Sample | File | Concepts | +| ------------------------------------------ | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| Sequential Orchestration (Agents) | [sequential_agents.py](./sequential_agents.py) | Chain agents sequentially with shared conversation context | +| Sequential Orchestration (Custom Executor) | [sequential_custom_executors.py](./sequential_custom_executors.py) | Mix agents with a summarizer that appends a compact summary | +| Sequential Orchestration as Agent | [sequential_workflow_as_agent.py](../agents/sequential_workflow_as_agent.py) | Build a SequentialBuilder workflow and expose it as an agent via `workflow.as_agent(...)` | +| Tool Approval with SequentialBuilder | [sequential_builder_tool_approval.py](../tool-approval/sequential_builder_tool_approval.py) | Require human approval for sensitive tools in SequentialBuilder workflows | +| SequentialBuilder Request Info | [sequential_request_info.py](../human-in-the-loop/sequential_request_info.py) | Request info for agent responses mid-orchestration using `.with_request_info()` | + +### group-chat + +| Sample | File | Concepts | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | +| Group Chat with Agent Manager | [group_chat_agent_manager.py](./group_chat_agent_manager.py) | Agent-based manager using `with_orchestrator(agent=)` to select next speaker | +| Group Chat Philosophical Debate | [group_chat_philosophical_debate.py](./group_chat_philosophical_debate.py) | Agent manager moderates long-form, multi-round debate across diverse participants | +| Group Chat with Simple Selector | [group_chat_simple_selector.py](./group_chat_simple_selector.py) | Group chat with a simple function selector for next speaker | +| Group Chat Orchestration as Agent | [group_chat_workflow_as_agent.py](../agents/group_chat_workflow_as_agent.py) | Build a GroupChatBuilder workflow and wrap it as an agent for composition | +| Tool Approval with GroupChatBuilder | [group_chat_builder_tool_approval.py](../tool-approval/group_chat_builder_tool_approval.py) | Require human approval for sensitive tools in group chat orchestration | +| GroupChatBuilder Request Info | [group_chat_request_info.py](../human-in-the-loop/group_chat_request_info.py) | Steer group discussions with periodic guidance using `.with_request_info()` | + +### handoff + +| Sample | File | Concepts | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| Handoff (Simple) | [handoff_simple.py](./handoff_simple.py) | Single-tier routing: triage agent routes to specialists, control returns to user after each specialist response | +| Handoff (Autonomous) | [handoff_autonomous.py](./handoff_autonomous.py) | Autonomous mode: specialists iterate independently until invoking a handoff tool using `.with_autonomous_mode()` | +| Handoff with Code Interpreter | [handoff_with_code_interpreter_file.py](./handoff_with_code_interpreter_file.py) | Retrieve file IDs from code interpreter output in handoff workflow | +| Handoff with Tool Approval + Checkpoint | [handoff_with_tool_approval_checkpoint_resume.py](./handoff_with_tool_approval_checkpoint_resume.py) | Capture tool-approval decisions in checkpoints and resume from persisted state | +| Handoff Orchestration as Agent | [handoff_workflow_as_agent.py](../agents/handoff_workflow_as_agent.py) | Build a HandoffBuilder workflow and expose it as an agent, including HITL request/response flow | + +### magentic + +| Sample | File | Concepts | +| ---------------------------- | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | +| Magentic Workflow | [magentic.py](./magentic.py) | Orchestrate multiple agents with a Magentic manager and streaming | +| Magentic + Human Plan Review | [magentic_human_plan_review.py](./magentic_human_plan_review.py) | Human reviews or updates the plan before execution | +| Magentic + Checkpoint Resume | [magentic_checkpoint.py](./magentic_checkpoint.py) | Resume Magentic orchestration from saved checkpoints | +| Magentic Orchestration as Agent | [magentic_workflow_as_agent.py](../agents/magentic_workflow_as_agent.py) | Build a MagenticBuilder workflow and reuse it as an agent | + +## Tips + +**Magentic checkpointing tip**: Treat `MagenticBuilder.participants` keys as stable identifiers. When resuming from a checkpoint, the rebuilt workflow must reuse the same participant names; otherwise the checkpoint cannot be applied and the run will fail fast. + +**Handoff workflow tip**: Handoff workflows maintain the full conversation history including any `Message.additional_properties` emitted by your agents. This ensures routing metadata remains intact across all agent transitions. For specialist-to-specialist handoffs, use `.add_handoff(source, targets)` to configure which agents can route to which others with a fluent, type-safe API. + +**Sequential orchestration note**: Sequential orchestration uses a few small adapter nodes for plumbing: +- `input-conversation` normalizes input to `list[Message]` +- `to-conversation:` converts agent responses into the shared conversation +- `complete` publishes the final output event (type='output') + +These may appear in event streams (executor_invoked/executor_completed). They're analogous to concurrent's dispatcher and aggregator and can be ignored if you only care about agent activity. + +## Environment Variables + +Orchestration samples that use `AzureOpenAIResponsesClient` expect: + +- `AZURE_AI_PROJECT_ENDPOINT` (Azure AI Foundry Agent Service (V2) project endpoint) +- `AZURE_AI_MODEL_DEPLOYMENT_NAME` (model deployment name) + +These values are passed directly into the client constructor via `os.getenv()` in sample code. diff --git a/python/samples/getting_started/orchestrations/concurrent_agents.py b/python/samples/03-workflows/orchestrations/concurrent_agents.py similarity index 93% rename from python/samples/getting_started/orchestrations/concurrent_agents.py rename to python/samples/03-workflows/orchestrations/concurrent_agents.py index 8333b91c89..2d216a131b 100644 --- a/python/samples/getting_started/orchestrations/concurrent_agents.py +++ b/python/samples/03-workflows/orchestrations/concurrent_agents.py @@ -3,7 +3,7 @@ import asyncio from typing import Any -from agent_framework import ChatMessage +from agent_framework import Message from agent_framework.azure import AzureOpenAIChatClient from agent_framework.orchestrations import ConcurrentBuilder from azure.identity import AzureCliCredential @@ -14,7 +14,7 @@ Sample: Concurrent fan-out/fan-in (agent-only API) with default aggregator Build a high-level concurrent workflow using ConcurrentBuilder and three domain agents. The default dispatcher fans out the same user prompt to all agents in parallel. The default aggregator fans in their results and yields output containing -a list[ChatMessage] representing the concatenated conversations from all agents. +a list[Message] representing the concatenated conversations from all agents. Demonstrates: - Minimal wiring with ConcurrentBuilder(participants=[...]).build() @@ -29,9 +29,9 @@ Prerequisites: async def main() -> None: # 1) Create three domain agents using AzureOpenAIChatClient - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIChatClient(credential=AzureCliCredential()) - researcher = chat_client.as_agent( + researcher = client.as_agent( instructions=( "You're an expert market and product researcher. Given a prompt, provide concise, factual insights," " opportunities, and risks." @@ -39,7 +39,7 @@ async def main() -> None: name="researcher", ) - marketer = chat_client.as_agent( + marketer = client.as_agent( instructions=( "You're a creative marketing strategist. Craft compelling value propositions and target messaging" " aligned to the prompt." @@ -47,7 +47,7 @@ async def main() -> None: name="marketer", ) - legal = chat_client.as_agent( + legal = client.as_agent( instructions=( "You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns" " based on the prompt." @@ -66,7 +66,7 @@ async def main() -> None: if outputs: print("===== Final Aggregated Conversation (messages) =====") for output in outputs: - messages: list[ChatMessage] | Any = output + messages: list[Message] | Any = output for i, msg in enumerate(messages, start=1): name = msg.author_name if msg.author_name else "user" print(f"{'-' * 60}\n\n{i:02d} [{name}]:\n{msg.text}") diff --git a/python/samples/getting_started/orchestrations/concurrent_custom_agent_executors.py b/python/samples/03-workflows/orchestrations/concurrent_custom_agent_executors.py similarity index 85% rename from python/samples/getting_started/orchestrations/concurrent_custom_agent_executors.py rename to python/samples/03-workflows/orchestrations/concurrent_custom_agent_executors.py index 9463ba1915..bd3b8b93a5 100644 --- a/python/samples/getting_started/orchestrations/concurrent_custom_agent_executors.py +++ b/python/samples/03-workflows/orchestrations/concurrent_custom_agent_executors.py @@ -4,11 +4,11 @@ import asyncio from typing import Any from agent_framework import ( + Agent, AgentExecutorRequest, AgentExecutorResponse, - ChatAgent, - ChatMessage, Executor, + Message, WorkflowContext, handler, ) @@ -20,15 +20,15 @@ from azure.identity import AzureCliCredential Sample: Concurrent Orchestration with Custom Agent Executors This sample shows a concurrent fan-out/fan-in pattern using child Executor classes -that each own their ChatAgent. The executors accept AgentExecutorRequest inputs +that each own their Agent. The executors accept AgentExecutorRequest inputs and emit AgentExecutorResponse outputs, which allows reuse of the high-level ConcurrentBuilder API and the default aggregator. Demonstrates: -- Executors that create their ChatAgent in __init__ (via AzureOpenAIChatClient) +- Executors that create their Agent in __init__ (via AzureOpenAIChatClient) - A @handler that converts AgentExecutorRequest -> AgentExecutorResponse - ConcurrentBuilder(participants=[...]) to build fan-out/fan-in -- Default aggregator returning list[ChatMessage] (one user + one assistant per agent) +- Default aggregator returning list[Message] (one user + one assistant per agent) - Workflow completion when all participants become idle Prerequisites: @@ -37,10 +37,10 @@ Prerequisites: class ResearcherExec(Executor): - agent: ChatAgent + agent: Agent - def __init__(self, chat_client: AzureOpenAIChatClient, id: str = "researcher"): - self.agent = chat_client.as_agent( + def __init__(self, client: AzureOpenAIChatClient, id: str = "researcher"): + self.agent = client.as_agent( instructions=( "You're an expert market and product researcher. Given a prompt, provide concise, factual insights," " opportunities, and risks." @@ -57,10 +57,10 @@ class ResearcherExec(Executor): class MarketerExec(Executor): - agent: ChatAgent + agent: Agent - def __init__(self, chat_client: AzureOpenAIChatClient, id: str = "marketer"): - self.agent = chat_client.as_agent( + def __init__(self, client: AzureOpenAIChatClient, id: str = "marketer"): + self.agent = client.as_agent( instructions=( "You're a creative marketing strategist. Craft compelling value propositions and target messaging" " aligned to the prompt." @@ -77,10 +77,10 @@ class MarketerExec(Executor): class LegalExec(Executor): - agent: ChatAgent + agent: Agent - def __init__(self, chat_client: AzureOpenAIChatClient, id: str = "legal"): - self.agent = chat_client.as_agent( + def __init__(self, client: AzureOpenAIChatClient, id: str = "legal"): + self.agent = client.as_agent( instructions=( "You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns" " based on the prompt." @@ -97,11 +97,11 @@ class LegalExec(Executor): async def main() -> None: - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIChatClient(credential=AzureCliCredential()) - researcher = ResearcherExec(chat_client) - marketer = MarketerExec(chat_client) - legal = LegalExec(chat_client) + researcher = ResearcherExec(client) + marketer = MarketerExec(client) + legal = LegalExec(client) workflow = ConcurrentBuilder(participants=[researcher, marketer, legal]).build() @@ -110,7 +110,7 @@ async def main() -> None: if outputs: print("===== Final Aggregated Conversation (messages) =====") - messages: list[ChatMessage] | Any = outputs[0] # Get the first (and typically only) output + messages: list[Message] | Any = outputs[0] # Get the first (and typically only) output for i, msg in enumerate(messages, start=1): name = msg.author_name if msg.author_name else "user" print(f"{'-' * 60}\n\n{i:02d} [{name}]:\n{msg.text}") diff --git a/python/samples/getting_started/orchestrations/concurrent_custom_aggregator.py b/python/samples/03-workflows/orchestrations/concurrent_custom_aggregator.py similarity index 90% rename from python/samples/getting_started/orchestrations/concurrent_custom_aggregator.py rename to python/samples/03-workflows/orchestrations/concurrent_custom_aggregator.py index a15cae06fd..17b1496e0b 100644 --- a/python/samples/getting_started/orchestrations/concurrent_custom_aggregator.py +++ b/python/samples/03-workflows/orchestrations/concurrent_custom_aggregator.py @@ -3,7 +3,7 @@ import asyncio from typing import Any -from agent_framework import ChatMessage +from agent_framework import Message from agent_framework.azure import AzureOpenAIChatClient from agent_framework.orchestrations import ConcurrentBuilder from azure.identity import AzureCliCredential @@ -20,7 +20,7 @@ The workflow completes when all participants become idle. Demonstrates: - ConcurrentBuilder(participants=[...]).with_aggregator(callback) - Fan-out to agents and fan-in at an aggregator -- Aggregation implemented via an LLM call (chat_client.get_response) +- Aggregation implemented via an LLM call (client.get_response) - Workflow output yielded with the synthesized summary string Prerequisites: @@ -29,23 +29,23 @@ Prerequisites: async def main() -> None: - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIChatClient(credential=AzureCliCredential()) - researcher = chat_client.as_agent( + researcher = client.as_agent( instructions=( "You're an expert market and product researcher. Given a prompt, provide concise, factual insights," " opportunities, and risks." ), name="researcher", ) - marketer = chat_client.as_agent( + marketer = client.as_agent( instructions=( "You're a creative marketing strategist. Craft compelling value propositions and target messaging" " aligned to the prompt." ), name="marketer", ) - legal = chat_client.as_agent( + legal = client.as_agent( instructions=( "You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns" " based on the prompt." @@ -66,16 +66,16 @@ async def main() -> None: expert_sections.append(f"{getattr(r, 'executor_id', 'expert')}: (error: {type(e).__name__}: {e})") # Ask the model to synthesize a concise summary of the experts' outputs - system_msg = ChatMessage( + system_msg = Message( "system", text=( "You are a helpful assistant that consolidates multiple domain expert outputs " "into one cohesive, concise summary with clear takeaways. Keep it under 200 words." ), ) - user_msg = ChatMessage("user", text="\n\n".join(expert_sections)) + user_msg = Message("user", text="\n\n".join(expert_sections)) - response = await chat_client.get_response([system_msg, user_msg]) + response = await client.get_response([system_msg, user_msg]) # Return the model's final assistant text as the completion result return response.messages[-1].text if response.messages else "" @@ -83,7 +83,7 @@ async def main() -> None: # - participants([...]) accepts SupportsAgentRun (agents) or Executor instances. # Each participant becomes a parallel branch (fan-out) from an internal dispatcher. # - with_aggregator(...) overrides the default aggregator: - # • Default aggregator -> returns list[ChatMessage] (one user + one assistant per agent) + # • Default aggregator -> returns list[Message] (one user + one assistant per agent) # • Custom callback -> return value becomes workflow output (string here) # The callback can be sync or async; it receives list[AgentExecutorResponse]. workflow = ( diff --git a/python/samples/getting_started/orchestrations/group_chat_agent_manager.py b/python/samples/03-workflows/orchestrations/group_chat_agent_manager.py similarity index 90% rename from python/samples/getting_started/orchestrations/group_chat_agent_manager.py rename to python/samples/03-workflows/orchestrations/group_chat_agent_manager.py index 33d62d98da..78eb8535ae 100644 --- a/python/samples/getting_started/orchestrations/group_chat_agent_manager.py +++ b/python/samples/03-workflows/orchestrations/group_chat_agent_manager.py @@ -4,9 +4,9 @@ import asyncio from typing import cast from agent_framework import ( + Agent, AgentResponseUpdate, - ChatAgent, - ChatMessage, + Message, ) from agent_framework.azure import AzureOpenAIChatClient from agent_framework.orchestrations import GroupChatBuilder @@ -17,7 +17,7 @@ Sample: Group Chat with Agent-Based Manager What it does: - Demonstrates the new set_manager() API for agent-based coordination -- Manager is a full ChatAgent with access to tools, context, and observability +- Manager is a full Agent with access to tools, context, and observability - Coordinates a researcher and writer agent to solve tasks collaboratively Prerequisites: @@ -36,32 +36,32 @@ Guidelines: async def main() -> None: # Create a chat client using Azure OpenAI and Azure CLI credentials for all agents - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIChatClient(credential=AzureCliCredential()) # Orchestrator agent that manages the conversation # Note: This agent (and the underlying chat client) must support structured outputs. # The group chat workflow relies on this to parse the orchestrator's decisions. # `response_format` is set internally by the GroupChat workflow when the agent is invoked. - orchestrator_agent = ChatAgent( + orchestrator_agent = Agent( name="Orchestrator", description="Coordinates multi-agent collaboration by selecting speakers", instructions=ORCHESTRATOR_AGENT_INSTRUCTIONS, - chat_client=chat_client, + client=client, ) # Participant agents - researcher = ChatAgent( + researcher = Agent( name="Researcher", description="Collects relevant background information", instructions="Gather concise facts that help a teammate answer the question.", - chat_client=chat_client, + client=client, ) - writer = ChatAgent( + writer = Agent( name="Writer", description="Synthesizes polished answers from gathered information", instructions="Compose clear and structured answers using any notes provided.", - chat_client=chat_client, + client=client, ) # Build the group chat workflow @@ -103,7 +103,7 @@ async def main() -> None: print(data.text, end="", flush=True) elif event.type == "output": # The output of the group chat workflow is a collection of chat messages from all participants - outputs = cast(list[ChatMessage], event.data) + outputs = cast(list[Message], event.data) print("\n" + "=" * 80) print("\nFinal Conversation Transcript:\n") for message in outputs: diff --git a/python/samples/getting_started/orchestrations/group_chat_philosophical_debate.py b/python/samples/03-workflows/orchestrations/group_chat_philosophical_debate.py similarity index 96% rename from python/samples/getting_started/orchestrations/group_chat_philosophical_debate.py rename to python/samples/03-workflows/orchestrations/group_chat_philosophical_debate.py index be2579f496..e4723c01e0 100644 --- a/python/samples/getting_started/orchestrations/group_chat_philosophical_debate.py +++ b/python/samples/03-workflows/orchestrations/group_chat_philosophical_debate.py @@ -5,9 +5,9 @@ import logging from typing import cast from agent_framework import ( + Agent, AgentResponseUpdate, - ChatAgent, - ChatMessage, + Message, ) from agent_framework.azure import AzureOpenAIChatClient from agent_framework.orchestrations import GroupChatBuilder @@ -48,7 +48,7 @@ def _get_chat_client() -> AzureOpenAIChatClient: async def main() -> None: # Create debate moderator with structured output for speaker selection # Note: Participant names and descriptions are automatically injected by the orchestrator - moderator = ChatAgent( + moderator = Agent( name="Moderator", description="Guides philosophical discussion by selecting next speaker", instructions=""" @@ -75,10 +75,10 @@ Finish when: In your final_message, provide a brief synthesis highlighting key themes that emerged. """, - chat_client=_get_chat_client(), + client=_get_chat_client(), ) - farmer = ChatAgent( + farmer = Agent( name="Farmer", description="A rural farmer from Southeast Asia", instructions=""" @@ -91,10 +91,10 @@ Share your perspective authentically. Feel free to: - Use concrete examples from your experience - Keep responses thoughtful but concise (2-4 sentences) """, - chat_client=_get_chat_client(), + client=_get_chat_client(), ) - developer = ChatAgent( + developer = Agent( name="Developer", description="An urban software developer from the United States", instructions=""" @@ -107,10 +107,10 @@ Share your perspective authentically. Feel free to: - Use concrete examples from your experience - Keep responses thoughtful but concise (2-4 sentences) """, - chat_client=_get_chat_client(), + client=_get_chat_client(), ) - teacher = ChatAgent( + teacher = Agent( name="Teacher", description="A retired history teacher from Eastern Europe", instructions=""" @@ -124,10 +124,10 @@ Share your perspective authentically. Feel free to: - Use concrete examples from history or your teaching experience - Keep responses thoughtful but concise (2-4 sentences) """, - chat_client=_get_chat_client(), + client=_get_chat_client(), ) - activist = ChatAgent( + activist = Agent( name="Activist", description="A young activist from South America", instructions=""" @@ -140,10 +140,10 @@ Share your perspective authentically. Feel free to: - Use concrete examples from your activism - Keep responses thoughtful but concise (2-4 sentences) """, - chat_client=_get_chat_client(), + client=_get_chat_client(), ) - spiritual_leader = ChatAgent( + spiritual_leader = Agent( name="SpiritualLeader", description="A spiritual leader from the Middle East", instructions=""" @@ -156,10 +156,10 @@ Share your perspective authentically. Feel free to: - Use examples from spiritual teachings or community work - Keep responses thoughtful but concise (2-4 sentences) """, - chat_client=_get_chat_client(), + client=_get_chat_client(), ) - artist = ChatAgent( + artist = Agent( name="Artist", description="An artist from Africa", instructions=""" @@ -172,10 +172,10 @@ Share your perspective authentically. Feel free to: - Use examples from your art or cultural traditions - Keep responses thoughtful but concise (2-4 sentences) """, - chat_client=_get_chat_client(), + client=_get_chat_client(), ) - immigrant = ChatAgent( + immigrant = Agent( name="Immigrant", description="An immigrant entrepreneur from Asia living in Canada", instructions=""" @@ -188,10 +188,10 @@ Share your perspective authentically. Feel free to: - Use examples from your immigrant and entrepreneurial journey - Keep responses thoughtful but concise (2-4 sentences) """, - chat_client=_get_chat_client(), + client=_get_chat_client(), ) - doctor = ChatAgent( + doctor = Agent( name="Doctor", description="A doctor from Scandinavia", instructions=""" @@ -204,7 +204,7 @@ Share your perspective authentically. Feel free to: - Use examples from healthcare and societal systems - Keep responses thoughtful but concise (2-4 sentences) """, - chat_client=_get_chat_client(), + client=_get_chat_client(), ) # termination_condition: stop after 10 assistant messages @@ -255,7 +255,7 @@ Share your perspective authentically. Feel free to: print(data.text, end="", flush=True) elif event.type == "output": # The output of the group chat workflow is a collection of chat messages from all participants - outputs = cast(list[ChatMessage], event.data) + outputs = cast(list[Message], event.data) print("\n" + "=" * 80) print("\nFinal Conversation Transcript:\n") for message in outputs: diff --git a/python/samples/getting_started/orchestrations/group_chat_simple_selector.py b/python/samples/03-workflows/orchestrations/group_chat_simple_selector.py similarity index 93% rename from python/samples/getting_started/orchestrations/group_chat_simple_selector.py rename to python/samples/03-workflows/orchestrations/group_chat_simple_selector.py index bb76e97de1..13cd3d3e5a 100644 --- a/python/samples/getting_started/orchestrations/group_chat_simple_selector.py +++ b/python/samples/03-workflows/orchestrations/group_chat_simple_selector.py @@ -4,9 +4,9 @@ import asyncio from typing import cast from agent_framework import ( + Agent, AgentResponseUpdate, - ChatAgent, - ChatMessage, + Message, ) from agent_framework.azure import AzureOpenAIChatClient from agent_framework.orchestrations import GroupChatBuilder, GroupChatState @@ -33,20 +33,20 @@ def round_robin_selector(state: GroupChatState) -> str: async def main() -> None: # Create a chat client using Azure OpenAI and Azure CLI credentials for all agents - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIChatClient(credential=AzureCliCredential()) # Participant agents - expert = ChatAgent( + expert = Agent( name="PythonExpert", instructions=( "You are an expert in Python in a workgroup. " "Your job is to answer Python related questions and refine your answer " "based on feedback from all the other participants." ), - chat_client=chat_client, + client=client, ) - verifier = ChatAgent( + verifier = Agent( name="AnswerVerifier", instructions=( "You are a programming expert in a workgroup. " @@ -54,10 +54,10 @@ async def main() -> None: "out statements that are technically true but practically dangerous." "If there is nothing woth pointing out, respond with 'The answer looks good to me.'" ), - chat_client=chat_client, + client=client, ) - clarifier = ChatAgent( + clarifier = Agent( name="AnswerClarifier", instructions=( "You are an accessibility expert in a workgroup. " @@ -65,10 +65,10 @@ async def main() -> None: "out jargons or complex terms that may be difficult for a beginner to understand." "If there is nothing worth pointing out, respond with 'The answer looks clear to me.'" ), - chat_client=chat_client, + client=client, ) - skeptic = ChatAgent( + skeptic = Agent( name="Skeptic", instructions=( "You are a devil's advocate in a workgroup. " @@ -76,7 +76,7 @@ async def main() -> None: "out caveats, exceptions, and alternative perspectives." "If there is nothing worth pointing out, respond with 'I have no further questions.'" ), - chat_client=chat_client, + client=client, ) # Build the group chat workflow @@ -124,7 +124,7 @@ async def main() -> None: print(data.text, end="", flush=True) elif event.type == "output": # The output of the group chat workflow is a collection of chat messages from all participants - outputs = cast(list[ChatMessage], event.data) + outputs = cast(list[Message], event.data) print("\n" + "=" * 80) print("\nFinal Conversation Transcript:\n") for message in outputs: diff --git a/python/samples/getting_started/orchestrations/handoff_autonomous.py b/python/samples/03-workflows/orchestrations/handoff_autonomous.py similarity index 91% rename from python/samples/getting_started/orchestrations/handoff_autonomous.py rename to python/samples/03-workflows/orchestrations/handoff_autonomous.py index 9b151b656a..997d854ef2 100644 --- a/python/samples/getting_started/orchestrations/handoff_autonomous.py +++ b/python/samples/03-workflows/orchestrations/handoff_autonomous.py @@ -5,9 +5,9 @@ import logging from typing import cast from agent_framework import ( + Agent, AgentResponseUpdate, - ChatAgent, - ChatMessage, + Message, resolve_agent_id, ) from agent_framework.azure import AzureOpenAIChatClient @@ -37,10 +37,10 @@ Key Concepts: def create_agents( - chat_client: AzureOpenAIChatClient, -) -> tuple[ChatAgent, ChatAgent, ChatAgent]: + client: AzureOpenAIChatClient, +) -> tuple[Agent, Agent, Agent]: """Create coordinator and specialists for autonomous iteration.""" - coordinator = chat_client.as_agent( + coordinator = client.as_agent( instructions=( "You are a coordinator. You break down a user query into a research task and a summary task. " "Assign the two tasks to the appropriate specialists, one after the other." @@ -48,7 +48,7 @@ def create_agents( name="coordinator", ) - research_agent = chat_client.as_agent( + research_agent = client.as_agent( instructions=( "You are a research specialist that explores topics thoroughly using web search. " "When given a research task, break it down into multiple aspects and explore each one. " @@ -60,7 +60,7 @@ def create_agents( name="research_agent", ) - summary_agent = chat_client.as_agent( + summary_agent = client.as_agent( instructions=( "You summarize research findings. Provide a concise, well-organized summary. When done, return " "control to the coordinator." @@ -73,8 +73,8 @@ def create_agents( async def main() -> None: """Run an autonomous handoff workflow with specialist iteration enabled.""" - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - coordinator, research_agent, summary_agent = create_agents(chat_client) + client = AzureOpenAIChatClient(credential=AzureCliCredential()) + coordinator, research_agent, summary_agent = create_agents(client) # Build the workflow with autonomous mode # In autonomous mode, agents continue iterating until they invoke a handoff tool @@ -83,10 +83,9 @@ async def main() -> None: HandoffBuilder( name="autonomous_iteration_handoff", participants=[coordinator, research_agent, summary_agent], - termination_condition=lambda conv: sum( - 1 for msg in conv if msg.author_name == "coordinator" and msg.role == "assistant" - ) - >= 5, + termination_condition=lambda conv: ( + sum(1 for msg in conv if msg.author_name == "coordinator" and msg.role == "assistant") >= 5 + ), ) .with_start_agent(coordinator) .add_handoff(coordinator, [research_agent, summary_agent]) @@ -129,7 +128,7 @@ async def main() -> None: print(data.text, end="", flush=True) elif event.type == "output": # The output of the handoff workflow is a collection of chat messages from all participants - outputs = cast(list[ChatMessage], event.data) + outputs = cast(list[Message], event.data) print("\n" + "=" * 80) print("\nFinal Conversation Transcript:\n") for message in outputs: diff --git a/python/samples/getting_started/orchestrations/handoff_simple.py b/python/samples/03-workflows/orchestrations/handoff_simple.py similarity index 94% rename from python/samples/getting_started/orchestrations/handoff_simple.py rename to python/samples/03-workflows/orchestrations/handoff_simple.py index 53e6bbcd60..9bfe73491e 100644 --- a/python/samples/getting_started/orchestrations/handoff_simple.py +++ b/python/samples/03-workflows/orchestrations/handoff_simple.py @@ -4,9 +4,9 @@ import asyncio from typing import Annotated, cast from agent_framework import ( + Agent, AgentResponse, - ChatAgent, - ChatMessage, + Message, WorkflowEvent, WorkflowRunState, tool, @@ -34,8 +34,8 @@ Key Concepts: # NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; # See: -# samples/getting_started/tools/function_tool_with_approval.py -# samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# samples/02-agents/tools/function_tool_with_approval.py +# samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def process_refund(order_number: Annotated[str, "Order number to process refund for"]) -> str: """Simulated function to process a refund for a given order number.""" @@ -54,17 +54,17 @@ def process_return(order_number: Annotated[str, "Order number to process return return f"Return initiated successfully for order {order_number}. You will receive return instructions via email." -def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent, ChatAgent]: +def create_agents(client: AzureOpenAIChatClient) -> tuple[Agent, Agent, Agent, Agent]: """Create and configure the triage and specialist agents. Args: - chat_client: The AzureOpenAIChatClient to use for creating agents. + client: The AzureOpenAIChatClient to use for creating agents. Returns: Tuple of (triage_agent, refund_agent, order_agent, return_agent) """ # Triage agent: Acts as the frontline dispatcher - triage_agent = chat_client.as_agent( + triage_agent = client.as_agent( instructions=( "You are frontline support triage. Route customer issues to the appropriate specialist agents " "based on the problem described." @@ -73,7 +73,7 @@ def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAg ) # Refund specialist: Handles refund requests - refund_agent = chat_client.as_agent( + refund_agent = client.as_agent( instructions="You process refund requests.", name="refund_agent", # In a real application, an agent can have multiple tools; here we keep it simple @@ -81,7 +81,7 @@ def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAg ) # Order/shipping specialist: Resolves delivery issues - order_agent = chat_client.as_agent( + order_agent = client.as_agent( instructions="You handle order and shipping inquiries.", name="order_agent", # In a real application, an agent can have multiple tools; here we keep it simple @@ -89,7 +89,7 @@ def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAg ) # Return specialist: Handles return requests - return_agent = chat_client.as_agent( + return_agent = client.as_agent( instructions="You manage product return requests.", name="return_agent", # In a real application, an agent can have multiple tools; here we keep it simple @@ -138,7 +138,7 @@ def _handle_events(events: list[WorkflowEvent]) -> list[WorkflowEvent[HandoffAge print(f"- {speaker}: {message.text}") elif event.type == "output": # The output of the handoff workflow is a collection of chat messages from all participants - conversation = cast(list[ChatMessage], event.data) + conversation = cast(list[Message], event.data) if isinstance(conversation, list): print("\n=== Final Conversation Snapshot ===") for message in conversation: @@ -189,10 +189,10 @@ async def main() -> None: replace the scripted_responses with actual user input collection. """ # Initialize the Azure OpenAI chat client - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIChatClient(credential=AzureCliCredential()) # Create all agents: triage + specialists - triage, refund, order, support = create_agents(chat_client) + triage, refund, order, support = create_agents(client) # Build the handoff workflow # - participants: All agents that can participate in the workflow diff --git a/python/samples/03-workflows/orchestrations/handoff_with_code_interpreter_file.py b/python/samples/03-workflows/orchestrations/handoff_with_code_interpreter_file.py new file mode 100644 index 0000000000..9801d64888 --- /dev/null +++ b/python/samples/03-workflows/orchestrations/handoff_with_code_interpreter_file.py @@ -0,0 +1,186 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Handoff Workflow with Code Interpreter File Generation Sample + +This sample demonstrates retrieving file IDs from code interpreter output +in a handoff workflow context. A triage agent routes to a code specialist +that generates a text file, and we verify the file_id is captured correctly +from the streaming workflow events. + +Verifies GitHub issue #2718: files generated by code interpreter in +HandoffBuilder workflows can be properly retrieved. + +Prerequisites: + - AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. + - `az login` (Azure CLI authentication) + - AZURE_AI_MODEL_DEPLOYMENT_NAME +""" + +import asyncio +import os +from collections.abc import AsyncIterable +from typing import cast + +from agent_framework import ( + AgentResponseUpdate, + Message, + WorkflowEvent, + WorkflowRunState, +) +from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder +from azure.identity import AzureCliCredential + + +async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]: + """Collect all events from an async stream.""" + return [event async for event in stream] + + +def _handle_events(events: list[WorkflowEvent]) -> tuple[list[WorkflowEvent[HandoffAgentUserRequest]], list[str]]: + """Process workflow events and extract file IDs and pending requests. + + Returns: + Tuple of (pending_requests, file_ids_found) + """ + + requests: list[WorkflowEvent[HandoffAgentUserRequest]] = [] + file_ids: list[str] = [] + + for event in events: + if event.type == "handoff_sent": + print(f"\n[Handoff from {event.data.source} to {event.data.target} initiated.]") + elif event.type == "status" and event.state in { + WorkflowRunState.IDLE, + WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, + }: + print(f"[status] {event.state}") + elif event.type == "request_info" and isinstance(event.data, HandoffAgentUserRequest): + requests.append(cast(WorkflowEvent[HandoffAgentUserRequest], event)) + elif event.type == "output": + data = event.data + if isinstance(data, AgentResponseUpdate): + for content in data.contents: + if content.type == "hosted_file": + file_ids.append(content.file_id) # type: ignore + print(f"[Found HostedFileContent: file_id={content.file_id}]") + elif content.type == "text" and content.annotations: + for annotation in content.annotations: + file_id = annotation["file_id"] # type: ignore + file_ids.append(file_id) + print(f"[Found file annotation: file_id={file_id}]") + elif isinstance(data, list): + conversation = cast(list[Message], data) + if isinstance(conversation, list): + print("\n=== Final Conversation Snapshot ===") + for message in conversation: + speaker = message.author_name or message.role + print(f"- {speaker}: {message.text or [content.type for content in message.contents]}") + print("===================================") + + return requests, file_ids + + +async def main() -> None: + """Run a simple handoff workflow with code interpreter file generation.""" + print("=== Handoff Workflow with Code Interpreter File Generation ===\n") + + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + + triage = client.as_agent( + name="triage_agent", + instructions=( + "You are a triage agent. Route code-related requests to the code_specialist. " + "When the user asks to create or generate files, hand off to code_specialist " + "by calling handoff_to_code_specialist." + ), + ) + + code_interpreter_tool = client.get_code_interpreter_tool() + + code_specialist = client.as_agent( + name="code_specialist", + instructions=( + "You are a Python code specialist. Use the code interpreter to execute Python code " + "and create files when requested. Always save files to /mnt/data/ directory." + ), + tools=[code_interpreter_tool], + ) + + workflow = ( + HandoffBuilder( + termination_condition=lambda conv: sum(1 for msg in conv if msg.role == "user") >= 2, + ) + .participants([triage, code_specialist]) + .with_start_agent(triage) + .build() + ) + + user_inputs = [ + "Please create a text file called hello.txt with 'Hello from handoff workflow!' inside it.", + "exit", + ] + input_index = 0 + all_file_ids: list[str] = [] + + print(f"User: {user_inputs[0]}") + events = await _drain(workflow.run(user_inputs[0], stream=True)) + requests, file_ids = _handle_events(events) + all_file_ids.extend(file_ids) + input_index += 1 + + while requests: + request = requests[0] + if input_index >= len(user_inputs): + break + user_input = user_inputs[input_index] + print(f"\nUser: {user_input}") + + responses = {request.request_id: HandoffAgentUserRequest.create_response(user_input)} + events = await _drain(workflow.run(stream=True, responses=responses)) + requests, file_ids = _handle_events(events) + all_file_ids.extend(file_ids) + input_index += 1 + + print("\n" + "=" * 50) + if all_file_ids: + print(f"SUCCESS: Found {len(all_file_ids)} file ID(s) in handoff workflow:") + for fid in all_file_ids: + print(f" - {fid}") + else: + print("WARNING: No file IDs captured from the handoff workflow.") + print("=" * 50) + + """ + Sample Output: + + User: Please create a text file called hello.txt with 'Hello from handoff workflow!' inside it. + [Found HostedFileContent: file_id=assistant-JT1sA...] + + === Conversation So Far === + - user: Please create a text file called hello.txt with 'Hello from handoff workflow!' inside it. + - triage_agent: I am handing off your request to create the text file "hello.txt" with the specified content to the code specialist. They will assist you shortly. + - code_specialist: The file "hello.txt" has been created with the content "Hello from handoff workflow!". You can download it using the link below: + + [hello.txt](sandbox:/mnt/data/hello.txt) + =========================== + + [status] IDLE_WITH_PENDING_REQUESTS + + User: exit + [status] IDLE + + ================================================== + SUCCESS: Found 1 file ID(s) in handoff workflow: + - assistant-JT1sA... + ================================================== + """ # noqa: E501 + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/orchestrations/handoff_with_tool_approval_checkpoint_resume.py b/python/samples/03-workflows/orchestrations/handoff_with_tool_approval_checkpoint_resume.py new file mode 100644 index 0000000000..7fb9daef13 --- /dev/null +++ b/python/samples/03-workflows/orchestrations/handoff_with_tool_approval_checkpoint_resume.py @@ -0,0 +1,255 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import json +import os +from pathlib import Path +from typing import Any + +from agent_framework import ( + Agent, + AgentResponseUpdate, + Content, + FileCheckpointStorage, + Workflow, + WorkflowEvent, + tool, +) +from agent_framework.azure import AzureOpenAIResponsesClient +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_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure CLI authentication (az login). +- Environment variables configured for AzureOpenAIResponsesClient. +""" + +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: AzureOpenAIResponsesClient) -> 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 = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + 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 + print("Running initial workflow...") + results = await workflow.run(message=initial_request, stream=True) + + # Iterate through streamed events and collect request_info events + request_events: list[WorkflowEvent] = [] + async for event in results: + event: WorkflowEvent + if event.type == "request_info": + request_events.append(event) + + 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 + + print("Resuming workflow from checkpoint...") + results = await workflow.run(responses=responses, checkpoint_id=checkpoint_id, stream=True) + + # Iterate through streamed events and collect request_info events + request_events: list[WorkflowEvent] = [] + async for event in results: + event: WorkflowEvent + if event.type == "request_info": + request_events.append(event) + elif event.type == "output" and isinstance(event.data, AgentResponseUpdate): + print(event.data.text, end="", flush=True) + + print("\n" + "=" * 60) + print("DEMO COMPLETE") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/orchestrations/magentic.py b/python/samples/03-workflows/orchestrations/magentic.py similarity index 88% rename from python/samples/getting_started/orchestrations/magentic.py rename to python/samples/03-workflows/orchestrations/magentic.py index d0e4f13703..7ff0a08b1b 100644 --- a/python/samples/getting_started/orchestrations/magentic.py +++ b/python/samples/03-workflows/orchestrations/magentic.py @@ -6,10 +6,9 @@ import logging from typing import cast from agent_framework import ( + Agent, AgentResponseUpdate, - ChatAgent, - ChatMessage, - HostedCodeInterpreterTool, + Message, WorkflowEvent, ) from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient @@ -24,9 +23,9 @@ Sample: Magentic Orchestration (multi-agent) What it does: - Orchestrates multiple agents using `MagenticBuilder` with streaming callbacks. -- ResearcherAgent (ChatAgent backed by an OpenAI chat client) for +- ResearcherAgent (Agent backed by an OpenAI chat client) for finding information. -- CoderAgent (ChatAgent backed by OpenAI Assistants with the hosted +- CoderAgent (Agent backed by OpenAI Assistants with the hosted code interpreter tool) for analysis and computation. The workflow is configured with: @@ -44,30 +43,34 @@ Prerequisites: async def main() -> None: - researcher_agent = ChatAgent( + researcher_agent = Agent( name="ResearcherAgent", description="Specialist in research and information gathering", instructions=( "You are a Researcher. You find information without additional computation or quantitative analysis." ), # This agent requires the gpt-4o-search-preview model to perform web searches. - chat_client=OpenAIChatClient(model_id="gpt-4o-search-preview"), + client=OpenAIChatClient(model_id="gpt-4o-search-preview"), ) - coder_agent = ChatAgent( + # Create code interpreter tool using instance method + coder_client = OpenAIResponsesClient() + code_interpreter_tool = coder_client.get_code_interpreter_tool() + + coder_agent = Agent( name="CoderAgent", description="A helpful assistant that writes and executes code to process and analyze data.", instructions="You solve questions using code. Please provide detailed analysis and computation process.", - chat_client=OpenAIResponsesClient(), - tools=HostedCodeInterpreterTool(), + client=coder_client, + tools=code_interpreter_tool, ) # Create a manager agent for orchestration - manager_agent = ChatAgent( + manager_agent = Agent( name="MagenticManager", description="Orchestrator that coordinates the research and coding workflow", instructions="You coordinate a team to complete complex tasks efficiently.", - chat_client=OpenAIChatClient(), + client=OpenAIChatClient(), ) print("\nBuilding Magentic Workflow...") @@ -110,7 +113,7 @@ async def main() -> None: elif event.type == "magentic_orchestrator": print(f"\n[Magentic Orchestrator Event] Type: {event.data.event_type.name}") - if isinstance(event.data.content, ChatMessage): + if isinstance(event.data.content, Message): print(f"Please review the plan:\n{event.data.content.text}") elif isinstance(event.data.content, MagenticProgressLedger): print(f"Please review progress ledger:\n{json.dumps(event.data.content.to_dict(), indent=2)}") @@ -130,7 +133,7 @@ async def main() -> None: if output_event: # The output of the magentic workflow is a collection of chat messages from all participants - outputs = cast(list[ChatMessage], output_event.data) + outputs = cast(list[Message], output_event.data) print("\n" + "=" * 80) print("\nFinal Conversation Transcript:\n") for message in outputs: diff --git a/python/samples/getting_started/orchestrations/magentic_checkpoint.py b/python/samples/03-workflows/orchestrations/magentic_checkpoint.py similarity index 92% rename from python/samples/getting_started/orchestrations/magentic_checkpoint.py rename to python/samples/03-workflows/orchestrations/magentic_checkpoint.py index 08e26909e0..adce878f0d 100644 --- a/python/samples/getting_started/orchestrations/magentic_checkpoint.py +++ b/python/samples/03-workflows/orchestrations/magentic_checkpoint.py @@ -2,13 +2,14 @@ import asyncio import json +from datetime import datetime from pathlib import Path from typing import cast from agent_framework import ( - ChatAgent, - ChatMessage, + Agent, FileCheckpointStorage, + Message, WorkflowCheckpoint, WorkflowEvent, WorkflowRunState, @@ -52,26 +53,26 @@ def build_workflow(checkpoint_storage: FileCheckpointStorage): # Two vanilla ChatAgents act as participants in the orchestration. They do not need # extra state handling because their inputs/outputs are fully described by chat messages. - researcher = ChatAgent( + researcher = Agent( name="ResearcherAgent", description="Collects background facts and references for the project.", instructions=("You are the research lead. Gather crisp bullet points the team should know."), - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), + client=AzureOpenAIChatClient(credential=AzureCliCredential()), ) - writer = ChatAgent( + writer = Agent( name="WriterAgent", description="Synthesizes the final brief for stakeholders.", instructions=("You convert the research notes into a structured brief with milestones and risks."), - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), + client=AzureOpenAIChatClient(credential=AzureCliCredential()), ) # Create a manager agent for orchestration - manager_agent = ChatAgent( + manager_agent = Agent( name="MagenticManager", description="Orchestrator that coordinates the research and writing workflow", instructions="You coordinate a team to complete complex tasks efficiently.", - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), + client=AzureOpenAIChatClient(credential=AzureCliCredential()), ) # The builder wires in the Magentic orchestrator, sets the plan review path, and @@ -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. @@ -167,7 +164,7 @@ async def main() -> None: if not result: print("No result data from workflow.") return - output_messages = cast(list[ChatMessage], result) + output_messages = cast(list[Message], result) print("\n=== Final Answer ===") # The output of the Magentic workflow is a list of ChatMessages with only one final message # generated by the orchestrator. @@ -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( @@ -234,7 +225,7 @@ async def main() -> None: print("No result data from post-plan resume.") return - output_messages = cast(list[ChatMessage], post_result) + output_messages = cast(list[Message], post_result) print("\n=== Final Answer (post-plan resume) ===") # The output of the Magentic workflow is a list of ChatMessages with only one final message # generated by the orchestrator. diff --git a/python/samples/getting_started/orchestrations/magentic_human_plan_review.py b/python/samples/03-workflows/orchestrations/magentic_human_plan_review.py similarity index 93% rename from python/samples/getting_started/orchestrations/magentic_human_plan_review.py rename to python/samples/03-workflows/orchestrations/magentic_human_plan_review.py index 24757a1692..95f8de5f46 100644 --- a/python/samples/getting_started/orchestrations/magentic_human_plan_review.py +++ b/python/samples/03-workflows/orchestrations/magentic_human_plan_review.py @@ -6,9 +6,9 @@ from collections.abc import AsyncIterable from typing import cast from agent_framework import ( + Agent, AgentResponseUpdate, - ChatAgent, - ChatMessage, + Message, WorkflowEvent, ) from agent_framework.openai import OpenAIChatClient @@ -64,7 +64,7 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str print("=" * 60) print("Final discussion summary:") # To make the type checker happy, we cast event.data to the expected type - outputs = cast(list[ChatMessage], event.data) + outputs = cast(list[Message], event.data) for msg in outputs: speaker = msg.author_name or msg.role print(f"[{speaker}]: {msg.text}") @@ -92,25 +92,25 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: - researcher_agent = ChatAgent( + researcher_agent = Agent( name="ResearcherAgent", description="Specialist in research and information gathering", instructions="You are a Researcher. You find information and gather facts.", - chat_client=OpenAIChatClient(model_id="gpt-4o"), + client=OpenAIChatClient(model_id="gpt-4o"), ) - analyst_agent = ChatAgent( + analyst_agent = Agent( name="AnalystAgent", description="Data analyst who processes and summarizes research findings", instructions="You are an Analyst. You analyze findings and create summaries.", - chat_client=OpenAIChatClient(model_id="gpt-4o"), + client=OpenAIChatClient(model_id="gpt-4o"), ) - manager_agent = ChatAgent( + manager_agent = Agent( name="MagenticManager", description="Orchestrator that coordinates the workflow", instructions="You coordinate a team to complete tasks efficiently.", - chat_client=OpenAIChatClient(model_id="gpt-4o"), + client=OpenAIChatClient(model_id="gpt-4o"), ) print("\nBuilding Magentic Workflow with Human Plan Review...") diff --git a/python/samples/getting_started/orchestrations/sequential_agents.py b/python/samples/03-workflows/orchestrations/sequential_agents.py similarity index 87% rename from python/samples/getting_started/orchestrations/sequential_agents.py rename to python/samples/03-workflows/orchestrations/sequential_agents.py index 37c9afe975..7d77ef35c6 100644 --- a/python/samples/getting_started/orchestrations/sequential_agents.py +++ b/python/samples/03-workflows/orchestrations/sequential_agents.py @@ -3,7 +3,7 @@ import asyncio from typing import cast -from agent_framework import ChatMessage +from agent_framework import Message from agent_framework.azure import AzureOpenAIChatClient from agent_framework.orchestrations import SequentialBuilder from azure.identity import AzureCliCredential @@ -12,7 +12,7 @@ from azure.identity import AzureCliCredential Sample: Sequential workflow (agent-focused API) with shared conversation context Build a high-level sequential workflow using SequentialBuilder and two domain agents. -The shared conversation (list[ChatMessage]) flows through each participant. Each agent +The shared conversation (list[Message]) flows through each participant. Each agent appends its assistant message to the context. The workflow outputs the final conversation list when complete. @@ -30,14 +30,14 @@ Prerequisites: async def main() -> None: # 1) Create agents - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIChatClient(credential=AzureCliCredential()) - writer = chat_client.as_agent( + writer = client.as_agent( instructions=("You are a concise copywriter. Provide a single, punchy marketing sentence based on the prompt."), name="writer", ) - reviewer = chat_client.as_agent( + reviewer = client.as_agent( instructions=("You are a thoughtful reviewer. Give brief feedback on the previous assistant message."), name="reviewer", ) @@ -46,10 +46,10 @@ async def main() -> None: workflow = SequentialBuilder(participants=[writer, reviewer]).build() # 3) Run and collect outputs - outputs: list[list[ChatMessage]] = [] + outputs: list[list[Message]] = [] async for event in workflow.run("Write a tagline for a budget-friendly eBike.", stream=True): if event.type == "output": - outputs.append(cast(list[ChatMessage], event.data)) + outputs.append(cast(list[Message], event.data)) if outputs: print("===== Final Conversation =====") diff --git a/python/samples/getting_started/orchestrations/sequential_custom_executors.py b/python/samples/03-workflows/orchestrations/sequential_custom_executors.py similarity index 86% rename from python/samples/getting_started/orchestrations/sequential_custom_executors.py rename to python/samples/03-workflows/orchestrations/sequential_custom_executors.py index d421e85f1c..7f3e61fe2e 100644 --- a/python/samples/getting_started/orchestrations/sequential_custom_executors.py +++ b/python/samples/03-workflows/orchestrations/sequential_custom_executors.py @@ -5,8 +5,8 @@ from typing import Any from agent_framework import ( AgentExecutorResponse, - ChatMessage, Executor, + Message, WorkflowContext, handler, ) @@ -18,13 +18,13 @@ from azure.identity import AzureCliCredential Sample: Sequential workflow mixing agents and a custom summarizer executor This demonstrates how SequentialBuilder chains participants with a shared -conversation context (list[ChatMessage]). An agent produces content; a custom +conversation context (list[Message]). An agent produces content; a custom executor appends a compact summary to the conversation. The workflow completes after all participants have executed in sequence, and the final output contains the complete conversation. Custom executor contract: -- Provide at least one @handler accepting AgentExecutorResponse and a WorkflowContext[list[ChatMessage]] +- Provide at least one @handler accepting AgentExecutorResponse and a WorkflowContext[list[Message]] - Emit the updated conversation via ctx.send_message([...]) Prerequisites: @@ -36,30 +36,30 @@ class Summarizer(Executor): """Simple summarizer: consumes full conversation and appends an assistant summary.""" @handler - async def summarize(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[list[ChatMessage]]) -> None: + async def summarize(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[list[Message]]) -> None: """Append a summary message to a copy of the full conversation. Note: A custom executor must be able to handle the message type from the prior participant, and produce the message type expected by the next participant. In this case, the prior participant is an agent thus the input is AgentExecutorResponse (an agent will be wrapped in an AgentExecutor, which produces `AgentExecutorResponse`). If the next participant is also an agent or this is the final participant, - the output must be `list[ChatMessage]`. + the output must be `list[Message]`. """ if not agent_response.full_conversation: - await ctx.send_message([ChatMessage("assistant", ["No conversation to summarize."])]) + await ctx.send_message([Message("assistant", ["No conversation to summarize."])]) return users = sum(1 for m in agent_response.full_conversation if m.role == "user") assistants = sum(1 for m in agent_response.full_conversation if m.role == "assistant") - summary = ChatMessage("assistant", [f"Summary -> users:{users} assistants:{assistants}"]) + summary = Message("assistant", [f"Summary -> users:{users} assistants:{assistants}"]) final_conversation = list(agent_response.full_conversation) + [summary] await ctx.send_message(final_conversation) async def main() -> None: # 1) Create a content agent - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - content = chat_client.as_agent( + client = AzureOpenAIChatClient(credential=AzureCliCredential()) + content = client.as_agent( instructions="Produce a concise paragraph answering the user's request.", name="content", ) @@ -74,7 +74,7 @@ async def main() -> None: if outputs: print("===== Final Conversation =====") - messages: list[ChatMessage] | Any = outputs[0] + messages: list[Message] | Any = outputs[0] for i, msg in enumerate(messages, start=1): name = msg.author_name or ("assistant" if msg.role == "assistant" else "user") print(f"{'-' * 60}\n{i:02d} [{name}]\n{msg.text}") diff --git a/python/samples/getting_started/workflows/parallelism/aggregate_results_of_different_types.py b/python/samples/03-workflows/parallelism/aggregate_results_of_different_types.py similarity index 84% rename from python/samples/getting_started/workflows/parallelism/aggregate_results_of_different_types.py rename to python/samples/03-workflows/parallelism/aggregate_results_of_different_types.py index 6338b35c04..c84213b007 100644 --- a/python/samples/getting_started/workflows/parallelism/aggregate_results_of_different_types.py +++ b/python/samples/03-workflows/parallelism/aggregate_results_of_different_types.py @@ -72,14 +72,15 @@ class Aggregator(Executor): async def main() -> None: # 1) Build a simple fan out and fan in workflow + dispatcher = Dispatcher(id="dispatcher") + average = Average(id="average") + summation = Sum(id="summation") + aggregator = Aggregator(id="aggregator") + workflow = ( - WorkflowBuilder(start_executor="dispatcher") - .register_executor(lambda: Dispatcher(id="dispatcher"), name="dispatcher") - .register_executor(lambda: Average(id="average"), name="average") - .register_executor(lambda: Sum(id="summation"), name="summation") - .register_executor(lambda: Aggregator(id="aggregator"), name="aggregator") - .add_fan_out_edges("dispatcher", ["average", "summation"]) - .add_fan_in_edges(["average", "summation"], "aggregator") + WorkflowBuilder(start_executor=dispatcher) + .add_fan_out_edges(dispatcher, [average, summation]) + .add_fan_in_edges([average, summation], aggregator) .build() ) diff --git a/python/samples/03-workflows/parallelism/fan_out_fan_in_edges.py b/python/samples/03-workflows/parallelism/fan_out_fan_in_edges.py new file mode 100644 index 0000000000..e1722cd9ef --- /dev/null +++ b/python/samples/03-workflows/parallelism/fan_out_fan_in_edges.py @@ -0,0 +1,189 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from dataclasses import dataclass + +from agent_framework import ( + AgentExecutor, # Wraps a ChatAgent as an Executor for use in workflows + AgentExecutorRequest, # The message bundle sent to an AgentExecutor + AgentExecutorResponse, # The structured result returned by an AgentExecutor + AgentResponseUpdate, + Executor, # Base class for custom Python executors + Message, # Chat message structure + WorkflowBuilder, # Fluent builder for wiring the workflow graph + WorkflowContext, # Per run context and event bus + handler, # Decorator to mark an Executor method as invokable +) +from agent_framework.azure import AzureOpenAIResponsesClient +from azure.identity import AzureCliCredential # Uses your az CLI login for credentials +from typing_extensions import Never + +""" +Sample: Concurrent fan out and fan in with three domain agents + +A dispatcher fans out the same user prompt to research, marketing, and legal AgentExecutor nodes. +An aggregator then fans in their responses and produces a single consolidated report. + +Purpose: +Show how to construct a parallel branch pattern in workflows. Demonstrate: +- Fan out by targeting multiple AgentExecutor nodes from one dispatcher. +- Fan in by collecting a list of AgentExecutorResponse objects and reducing them to a single result. + +Prerequisites: +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Familiarity with WorkflowBuilder, executors, edges, events, and streaming runs. +- Azure OpenAI access configured for AzureOpenAIResponsesClient. Log in with Azure CLI and set any required environment variables. +- Comfort reading AgentExecutorResponse.agent_response.text for assistant output aggregation. +""" + + +class DispatchToExperts(Executor): + """Dispatches the incoming prompt to all expert agent executors for parallel processing (fan out).""" + + @handler + async def dispatch(self, prompt: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None: + # Wrap the incoming prompt as a user message for each expert and request a response. + initial_message = Message("user", text=prompt) + await ctx.send_message(AgentExecutorRequest(messages=[initial_message], should_respond=True)) + + +@dataclass +class AggregatedInsights: + """Typed container for the aggregator to hold per domain strings before formatting.""" + + research: str + marketing: str + legal: str + + +class AggregateInsights(Executor): + """Aggregates expert agent responses into a single consolidated result (fan in).""" + + @handler + async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None: + # Map responses to text by executor id for a simple, predictable demo. + by_id: dict[str, str] = {} + for r in results: + # AgentExecutorResponse.agent_response.text is the assistant text produced by the agent. + by_id[r.executor_id] = r.agent_response.text + + research_text = by_id.get("researcher", "") + marketing_text = by_id.get("marketer", "") + legal_text = by_id.get("legal", "") + + aggregated = AggregatedInsights( + research=research_text, + marketing=marketing_text, + legal=legal_text, + ) + + # Provide a readable, consolidated string as the final workflow result. + consolidated = ( + "Consolidated Insights\n" + "====================\n\n" + f"Research Findings:\n{aggregated.research}\n\n" + f"Marketing Angle:\n{aggregated.marketing}\n\n" + f"Legal/Compliance Notes:\n{aggregated.legal}\n" + ) + + await ctx.yield_output(consolidated) + + +def render_live_streams(buffers: dict[str, str], order: list[str], completed: set[str]) -> None: + """Render concurrent agent streams in separate sections.""" + # Clear terminal and move cursor to top-left for a live dashboard effect. + print("\033[2J\033[H", end="") + print("=== Expert Streams (Live) ===") + print("Concurrent agent updates are shown below as they stream.\n") + for agent_id in order: + state = "completed" if agent_id in completed else "streaming" + print(f"[{agent_id}] ({state})") + print(buffers.get(agent_id, "")) + print("-" * 80) + print("", end="", flush=True) + + +async def main() -> None: + # 1) Create executor and agent instances + dispatcher = DispatchToExperts(id="dispatcher") + aggregator = AggregateInsights(id="aggregator") + + researcher = AgentExecutor( + AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( + instructions=( + "You're an expert market and product researcher. Given a prompt, provide concise, factual insights," + " opportunities, and risks." + ), + name="researcher", + ) + ) + marketer = AgentExecutor( + AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( + instructions=( + "You're a creative marketing strategist. Craft compelling value propositions and target messaging" + " aligned to the prompt." + ), + name="marketer", + ) + ) + legal = AgentExecutor( + AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( + instructions=( + "You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns" + " based on the prompt." + ), + name="legal", + ) + ) + + # 2) Build a simple fan out and fan in workflow + workflow = ( + WorkflowBuilder(start_executor=dispatcher) + .add_fan_out_edges(dispatcher, [researcher, marketer, legal]) # Parallel branches + .add_fan_in_edges([researcher, marketer, legal], aggregator) # Join at the aggregator + .build() + ) + + # 3) Run with a single prompt and render live expert streams plus final consolidated output. + expert_order = ["researcher", "marketer", "legal"] + expert_buffers: dict[str, str] = {expert_id: "" for expert_id in expert_order} + completed_experts: set[str] = set() + final_output: str | None = None + + async for event in workflow.run( + "We are launching a new budget-friendly electric bike for urban commuters.", stream=True + ): + if event.type == "executor_completed" and event.executor_id in expert_buffers: + completed_experts.add(event.executor_id) + render_live_streams(expert_buffers, expert_order, completed_experts) + elif event.type == "output": + if isinstance(event.data, AgentResponseUpdate): + executor_id = event.executor_id or "" + if executor_id in expert_buffers: + expert_buffers[executor_id] += event.data.text + render_live_streams(expert_buffers, expert_order, completed_experts) + continue + + if event.executor_id == "aggregator": + final_output = str(event.data) + + if final_output: + print("\n=== Final Consolidated Output ===\n") + print(final_output) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/parallelism/map_reduce_and_visualization.py b/python/samples/03-workflows/parallelism/map_reduce_and_visualization.py similarity index 84% rename from python/samples/getting_started/workflows/parallelism/map_reduce_and_visualization.py rename to python/samples/03-workflows/parallelism/map_reduce_and_visualization.py index d29fe14bfb..eeeb42d9aa 100644 --- a/python/samples/getting_started/workflows/parallelism/map_reduce_and_visualization.py +++ b/python/samples/03-workflows/parallelism/map_reduce_and_visualization.py @@ -257,49 +257,31 @@ class CompletionExecutor(Executor): async def main(): """Construct the map reduce workflow, visualize it, then run it over a sample file.""" - # Step 1: Create the workflow builder and register executors. - workflow_builder = ( - WorkflowBuilder(start_executor="split_data_executor") - .register_executor(lambda: Map(id="map_executor_0"), name="map_executor_0") - .register_executor(lambda: Map(id="map_executor_1"), name="map_executor_1") - .register_executor(lambda: Map(id="map_executor_2"), name="map_executor_2") - .register_executor( - lambda: Split(["map_executor_0", "map_executor_1", "map_executor_2"], id="split_data_executor"), - name="split_data_executor", - ) - .register_executor(lambda: Reduce(id="reduce_executor_0"), name="reduce_executor_0") - .register_executor(lambda: Reduce(id="reduce_executor_1"), name="reduce_executor_1") - .register_executor(lambda: Reduce(id="reduce_executor_2"), name="reduce_executor_2") - .register_executor(lambda: Reduce(id="reduce_executor_3"), name="reduce_executor_3") - .register_executor( - lambda: Shuffle( - ["reduce_executor_0", "reduce_executor_1", "reduce_executor_2", "reduce_executor_3"], - id="shuffle_executor", - ), - name="shuffle_executor", - ) - .register_executor(lambda: CompletionExecutor(id="completion_executor"), name="completion_executor") + # Step 1: Create executor instances. + map_executor_0 = Map(id="map_executor_0") + map_executor_1 = Map(id="map_executor_1") + map_executor_2 = Map(id="map_executor_2") + split_data_executor = Split(["map_executor_0", "map_executor_1", "map_executor_2"], id="split_data_executor") + reduce_executor_0 = Reduce(id="reduce_executor_0") + reduce_executor_1 = Reduce(id="reduce_executor_1") + reduce_executor_2 = Reduce(id="reduce_executor_2") + reduce_executor_3 = Reduce(id="reduce_executor_3") + shuffle_executor = Shuffle( + ["reduce_executor_0", "reduce_executor_1", "reduce_executor_2", "reduce_executor_3"], + id="shuffle_executor", ) + completion_executor = CompletionExecutor(id="completion_executor") + + mappers = [map_executor_0, map_executor_1, map_executor_2] + reducers = [reduce_executor_0, reduce_executor_1, reduce_executor_2, reduce_executor_3] # Step 2: Build the workflow graph using fan out and fan in edges. workflow = ( - workflow_builder - .add_fan_out_edges( - "split_data_executor", - ["map_executor_0", "map_executor_1", "map_executor_2"], - ) # Split -> many mappers - .add_fan_in_edges( - ["map_executor_0", "map_executor_1", "map_executor_2"], - "shuffle_executor", - ) # All mappers -> shuffle - .add_fan_out_edges( - "shuffle_executor", - ["reduce_executor_0", "reduce_executor_1", "reduce_executor_2", "reduce_executor_3"], - ) # Shuffle -> many reducers - .add_fan_in_edges( - ["reduce_executor_0", "reduce_executor_1", "reduce_executor_2", "reduce_executor_3"], - "completion_executor", - ) # All reducers -> completion + WorkflowBuilder(start_executor=split_data_executor) + .add_fan_out_edges(split_data_executor, mappers) # Split -> many mappers + .add_fan_in_edges(mappers, shuffle_executor) # All mappers -> shuffle + .add_fan_out_edges(shuffle_executor, reducers) # Shuffle -> many reducers + .add_fan_in_edges(reducers, completion_executor) # All reducers -> completion .build() ) diff --git a/python/samples/getting_started/workflows/resources/ambiguous_email.txt b/python/samples/03-workflows/resources/ambiguous_email.txt similarity index 100% rename from python/samples/getting_started/workflows/resources/ambiguous_email.txt rename to python/samples/03-workflows/resources/ambiguous_email.txt diff --git a/python/samples/getting_started/workflows/resources/email.txt b/python/samples/03-workflows/resources/email.txt similarity index 100% rename from python/samples/getting_started/workflows/resources/email.txt rename to python/samples/03-workflows/resources/email.txt diff --git a/python/samples/getting_started/workflows/resources/long_text.txt b/python/samples/03-workflows/resources/long_text.txt similarity index 100% rename from python/samples/getting_started/workflows/resources/long_text.txt rename to python/samples/03-workflows/resources/long_text.txt diff --git a/python/samples/getting_started/workflows/resources/spam.txt b/python/samples/03-workflows/resources/spam.txt similarity index 100% rename from python/samples/getting_started/workflows/resources/spam.txt rename to python/samples/03-workflows/resources/spam.txt diff --git a/python/samples/getting_started/workflows/state-management/state_with_agents.py b/python/samples/03-workflows/state-management/state_with_agents.py similarity index 82% rename from python/samples/getting_started/workflows/state-management/state_with_agents.py rename to python/samples/03-workflows/state-management/state_with_agents.py index 929dc40362..e09ab23eda 100644 --- a/python/samples/getting_started/workflows/state-management/state_with_agents.py +++ b/python/samples/03-workflows/state-management/state_with_agents.py @@ -1,21 +1,22 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from dataclasses import dataclass from pathlib import Path from typing import Any from uuid import uuid4 from agent_framework import ( + Agent, AgentExecutorRequest, AgentExecutorResponse, - ChatAgent, - ChatMessage, + Message, WorkflowBuilder, WorkflowContext, executor, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential from pydantic import BaseModel from typing_extensions import Never @@ -34,7 +35,8 @@ Show how to: - Compose agent backed executors with function style executors and yield the final output when the workflow completes. Prerequisites: -- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables. +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for AzureOpenAIResponsesClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. - Familiarity with WorkflowBuilder, executors, conditional edges, and streaming runs. """ @@ -103,7 +105,7 @@ async def store_email(email_text: str, ctx: WorkflowContext[AgentExecutorRequest ctx.set_state(CURRENT_EMAIL_ID_KEY, new_email.email_id) await ctx.send_message( - AgentExecutorRequest(messages=[ChatMessage("user", text=new_email.email_content)], should_respond=True) + AgentExecutorRequest(messages=[Message("user", text=new_email.email_content)], should_respond=True) ) @@ -134,7 +136,7 @@ async def submit_to_email_assistant(detection: DetectionResult, ctx: WorkflowCon # Load the original content by id from workflow state and forward it to the assistant. email: Email = ctx.get_state(f"{EMAIL_STATE_PREFIX}{detection.email_id}") await ctx.send_message( - AgentExecutorRequest(messages=[ChatMessage("user", text=email.email_content)], should_respond=True) + AgentExecutorRequest(messages=[Message("user", text=email.email_content)], should_respond=True) ) @@ -154,9 +156,13 @@ async def handle_spam(detection: DetectionResult, ctx: WorkflowContext[Never, st raise RuntimeError("This executor should only handle spam messages.") -def create_spam_detection_agent() -> ChatAgent: +def create_spam_detection_agent() -> Agent: """Creates a spam detection agent.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( instructions=( "You are a spam detection assistant that identifies spam emails. " "Always return JSON with fields is_spam (bool) and reason (string)." @@ -167,9 +173,13 @@ def create_spam_detection_agent() -> ChatAgent: ) -def create_email_assistant_agent() -> ChatAgent: +def create_email_assistant_agent() -> Agent: """Creates an email assistant agent.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( instructions=( "You are an email assistant that helps users draft responses to emails with professionalism. " "Return JSON with a single field 'response' containing the drafted reply." @@ -188,21 +198,17 @@ async def main() -> None: # store_email -> spam_detection_agent -> to_detection_result -> branch: # False -> submit_to_email_assistant -> email_assistant_agent -> finalize_and_send # True -> handle_spam + spam_detection_agent = create_spam_detection_agent() + email_assistant_agent = create_email_assistant_agent() + workflow = ( - WorkflowBuilder(start_executor="store_email") - .register_agent(create_spam_detection_agent, name="spam_detection_agent") - .register_agent(create_email_assistant_agent, name="email_assistant_agent") - .register_executor(lambda: store_email, name="store_email") - .register_executor(lambda: to_detection_result, name="to_detection_result") - .register_executor(lambda: submit_to_email_assistant, name="submit_to_email_assistant") - .register_executor(lambda: finalize_and_send, name="finalize_and_send") - .register_executor(lambda: handle_spam, name="handle_spam") - .add_edge("store_email", "spam_detection_agent") - .add_edge("spam_detection_agent", "to_detection_result") - .add_edge("to_detection_result", "submit_to_email_assistant", condition=get_condition(False)) - .add_edge("to_detection_result", "handle_spam", condition=get_condition(True)) - .add_edge("submit_to_email_assistant", "email_assistant_agent") - .add_edge("email_assistant_agent", "finalize_and_send") + WorkflowBuilder(start_executor=store_email) + .add_edge(store_email, spam_detection_agent) + .add_edge(spam_detection_agent, to_detection_result) + .add_edge(to_detection_result, submit_to_email_assistant, condition=get_condition(False)) + .add_edge(to_detection_result, handle_spam, condition=get_condition(True)) + .add_edge(submit_to_email_assistant, email_assistant_agent) + .add_edge(email_assistant_agent, finalize_and_send) .build() ) diff --git a/python/samples/getting_started/workflows/state-management/workflow_kwargs.py b/python/samples/03-workflows/state-management/workflow_kwargs.py similarity index 84% rename from python/samples/getting_started/workflows/state-management/workflow_kwargs.py rename to python/samples/03-workflows/state-management/workflow_kwargs.py index d89115463f..c7f3562fc8 100644 --- a/python/samples/getting_started/workflows/state-management/workflow_kwargs.py +++ b/python/samples/03-workflows/state-management/workflow_kwargs.py @@ -2,11 +2,13 @@ import asyncio import json +import os from typing import Annotated, Any, cast -from agent_framework import ChatMessage, tool -from agent_framework.openai import OpenAIChatClient +from agent_framework import Message, tool +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.orchestrations import SequentialBuilder +from azure.identity import AzureCliCredential from pydantic import Field """ @@ -22,14 +24,15 @@ Key Concepts: - Works with Sequential, Concurrent, GroupChat, Handoff, and Magentic patterns Prerequisites: -- OpenAI environment variables configured +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Environment variables configured """ # Define tools that accept custom context via **kwargs # NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/getting_started/tools/function_tool_with_approval.py -# and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_user_data( query: Annotated[str, Field(description="What user data to retrieve")], @@ -74,10 +77,14 @@ async def main() -> None: print("=" * 70) # Create chat client - chat_client = OpenAIChatClient() + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) # Create agent with tools that use kwargs - agent = chat_client.as_agent( + agent = client.as_agent( name="assistant", instructions=( "You are a helpful assistant. Use the available tools to help users. " @@ -121,10 +128,10 @@ async def main() -> None: stream=True, ): if event.type == "output": - output_data = cast(list[ChatMessage], event.data) + output_data = cast(list[Message], event.data) if isinstance(output_data, list): for item in output_data: - if isinstance(item, ChatMessage) and item.text: + if isinstance(item, Message) and item.text: print(f"\n[Final Answer]: {item.text}") print("\n" + "=" * 70) diff --git a/python/samples/getting_started/workflows/tool-approval/concurrent_builder_tool_approval.py b/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py similarity index 90% rename from python/samples/getting_started/workflows/tool-approval/concurrent_builder_tool_approval.py rename to python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py index 6eb6e2bc6a..94ca3aab88 100644 --- a/python/samples/getting_started/workflows/tool-approval/concurrent_builder_tool_approval.py +++ b/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py @@ -1,17 +1,19 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from collections.abc import AsyncIterable from typing import Annotated from agent_framework import ( - ChatMessage, Content, + Message, WorkflowEvent, tool, ) -from agent_framework.openai import OpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.orchestrations import ConcurrentBuilder +from azure.identity import AzureCliCredential """ Sample: Concurrent Workflow with Tool Approval Requests @@ -38,6 +40,7 @@ Demonstrate: - Understanding that approval pauses only the agent that triggered it, not all agents. Prerequisites: +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - OpenAI or Azure OpenAI configured with the required environment variables. - Basic familiarity with ConcurrentBuilder and streaming workflow events. """ @@ -46,8 +49,8 @@ Prerequisites: # 1. Define market data tools (no approval required) # NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; # See: -# samples/getting_started/tools/function_tool_with_approval.py -# samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# samples/02-agents/tools/function_tool_with_approval.py +# samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_stock_price(symbol: Annotated[str, "The stock ticker symbol"]) -> str: """Get the current stock price for a given symbol.""" @@ -91,10 +94,10 @@ def _print_output(event: WorkflowEvent) -> None: if not event.data: raise ValueError("WorkflowEvent has no data") - if not isinstance(event.data, list) and not all(isinstance(msg, ChatMessage) for msg in event.data): - raise ValueError("WorkflowEvent data is not a list of ChatMessage") + if not isinstance(event.data, list) and not all(isinstance(msg, Message) for msg in event.data): + raise ValueError("WorkflowEvent data is not a list of Message") - messages: list[ChatMessage] = event.data # type: ignore + messages: list[Message] = event.data # type: ignore print("\n" + "-" * 60) print("Workflow completed. Aggregated results from both agents:") @@ -126,9 +129,13 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: # 3. Create two agents focused on different stocks but with the same tool sets - chat_client = OpenAIChatClient() + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) - microsoft_agent = chat_client.as_agent( + microsoft_agent = client.as_agent( name="MicrosoftAgent", instructions=( "You are a personal trading assistant focused on Microsoft (MSFT). " @@ -137,7 +144,7 @@ async def main() -> None: tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade], ) - google_agent = chat_client.as_agent( + google_agent = client.as_agent( name="GoogleAgent", instructions=( "You are a personal trading assistant focused on Google (GOOGL). " diff --git a/python/samples/getting_started/workflows/tool-approval/group_chat_builder_tool_approval.py b/python/samples/03-workflows/tool-approval/group_chat_builder_tool_approval.py similarity index 91% rename from python/samples/getting_started/workflows/tool-approval/group_chat_builder_tool_approval.py rename to python/samples/03-workflows/tool-approval/group_chat_builder_tool_approval.py index ebabfc508f..d7472d13ca 100644 --- a/python/samples/getting_started/workflows/tool-approval/group_chat_builder_tool_approval.py +++ b/python/samples/03-workflows/tool-approval/group_chat_builder_tool_approval.py @@ -1,17 +1,19 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from collections.abc import AsyncIterable from typing import Annotated, cast from agent_framework import ( - ChatMessage, Content, + Message, WorkflowEvent, tool, ) -from agent_framework.openai import OpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.orchestrations import GroupChatBuilder, GroupChatState +from azure.identity import AzureCliCredential """ Sample: Group Chat Workflow with Tool Approval Requests @@ -37,6 +39,7 @@ Demonstrate: - Multi-round group chat with tool approval interruption and resumption. Prerequisites: +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - OpenAI or Azure OpenAI configured with the required environment variables. - Basic familiarity with GroupChatBuilder and streaming workflow events. """ @@ -44,8 +47,8 @@ Prerequisites: # 1. Define tools for different agents # NOTE: approval_mode="never_require" is for sample brevity. -# Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py -# and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def run_tests(test_suite: Annotated[str, "Name of the test suite to run"]) -> str: """Run automated tests for the application.""" @@ -105,7 +108,7 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str # The output of the workflow comes from the orchestrator and it's a list of messages print("\n" + "=" * 60) print("Workflow summary:") - outputs = cast(list[ChatMessage], event.data) + outputs = cast(list[Message], event.data) for msg in outputs: speaker = msg.author_name or msg.role print(f"[{speaker}]: {msg.text}") @@ -126,9 +129,13 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: # 3. Create specialized agents - chat_client = OpenAIChatClient() + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) - qa_engineer = chat_client.as_agent( + qa_engineer = client.as_agent( name="QAEngineer", instructions=( "You are a QA engineer responsible for running tests before deployment. " @@ -137,7 +144,7 @@ async def main() -> None: tools=[run_tests], ) - devops_engineer = chat_client.as_agent( + devops_engineer = client.as_agent( name="DevOpsEngineer", instructions=( "You are a DevOps engineer responsible for deployments. First check staging " diff --git a/python/samples/getting_started/workflows/tool-approval/sequential_builder_tool_approval.py b/python/samples/03-workflows/tool-approval/sequential_builder_tool_approval.py similarity index 90% rename from python/samples/getting_started/workflows/tool-approval/sequential_builder_tool_approval.py rename to python/samples/03-workflows/tool-approval/sequential_builder_tool_approval.py index c203ecc084..d9916801f4 100644 --- a/python/samples/getting_started/workflows/tool-approval/sequential_builder_tool_approval.py +++ b/python/samples/03-workflows/tool-approval/sequential_builder_tool_approval.py @@ -1,17 +1,19 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from collections.abc import AsyncIterable from typing import Annotated, cast from agent_framework import ( - ChatMessage, Content, + Message, WorkflowEvent, tool, ) -from agent_framework.openai import OpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.orchestrations import SequentialBuilder +from azure.identity import AzureCliCredential """ Sample: Sequential Workflow with Tool Approval Requests @@ -38,6 +40,7 @@ Demonstrate: - Resuming workflow execution after approval via run(responses=..., stream=True). Prerequisites: +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - OpenAI or Azure OpenAI configured with the required environment variables. - Basic familiarity with SequentialBuilder and streaming workflow events. """ @@ -54,8 +57,8 @@ def execute_database_query( # NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/getting_started/tools/function_tool_with_approval.py and -# samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# see samples/02-agents/tools/function_tool_with_approval.py and +# samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_database_schema() -> str: """Get the current database schema. Does not require approval.""" @@ -78,7 +81,7 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str # The output of the workflow comes from the orchestrator and it's a list of messages print("\n" + "=" * 60) print("Workflow summary:") - outputs = cast(list[ChatMessage], event.data) + outputs = cast(list[Message], event.data) for msg in outputs: speaker = msg.author_name or msg.role print(f"[{speaker}]: {msg.text}") @@ -99,8 +102,12 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: # 2. Create the agent with tools (approval mode is set per-tool via decorator) - chat_client = OpenAIChatClient() - database_agent = chat_client.as_agent( + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + database_agent = client.as_agent( name="DatabaseAgent", instructions=( "You are a database assistant. You can view the database schema and execute " diff --git a/python/samples/getting_started/workflows/visualization/concurrent_with_visualization.py b/python/samples/03-workflows/visualization/concurrent_with_visualization.py similarity index 61% rename from python/samples/getting_started/workflows/visualization/concurrent_with_visualization.py rename to python/samples/03-workflows/visualization/concurrent_with_visualization.py index a1c1086eec..c7c2ce2d0c 100644 --- a/python/samples/getting_started/workflows/visualization/concurrent_with_visualization.py +++ b/python/samples/03-workflows/visualization/concurrent_with_visualization.py @@ -1,20 +1,21 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from dataclasses import dataclass from agent_framework import ( + AgentExecutor, AgentExecutorRequest, AgentExecutorResponse, - ChatAgent, - ChatMessage, Executor, + Message, WorkflowBuilder, WorkflowContext, WorkflowViz, handler, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azure import AzureOpenAIResponsesClient from azure.identity import AzureCliCredential from typing_extensions import Never @@ -27,7 +28,8 @@ What it does: - Visualization: generate Mermaid and GraphViz representations via `WorkflowViz` and optionally export SVG. Prerequisites: -- Azure AI/ Azure OpenAI for `AzureOpenAIChatClient` agents. +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure AI/ Azure OpenAI for `AzureOpenAIResponsesClient` agents. - Authentication via `azure-identity` — uses `AzureCliCredential()` (run `az login`). - For visualization export: `pip install graphviz>=0.20.0` and install GraphViz binaries. """ @@ -39,7 +41,7 @@ class DispatchToExperts(Executor): @handler async def dispatch(self, prompt: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None: # Wrap the incoming prompt as a user message for each expert and request a response. - initial_message = ChatMessage("user", text=prompt) + initial_message = Message("user", text=prompt) await ctx.send_message(AgentExecutorRequest(messages=[initial_message], should_respond=True)) @@ -85,52 +87,61 @@ class AggregateInsights(Executor): await ctx.yield_output(consolidated) -def create_researcher_agent() -> ChatAgent: - """Creates a research domain expert agent.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( - instructions=( - "You're an expert market and product researcher. Given a prompt, provide concise, factual insights," - " opportunities, and risks." - ), - name="researcher", - ) - - -def create_marketer_agent() -> ChatAgent: - """Creates a marketing domain expert agent.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( - instructions=( - "You're a creative marketing strategist. Craft compelling value propositions and target messaging" - " aligned to the prompt." - ), - name="marketer", - ) - - -def create_legal_agent() -> ChatAgent: - """Creates a legal domain expert agent.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( - instructions=( - "You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns" - " based on the prompt." - ), - name="legal", - ) - - async def main() -> None: """Build and run the concurrent workflow with visualization.""" + # Create agent instances + researcher = AgentExecutor( + AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( + instructions=( + "You're an expert market and product researcher. Given a prompt, provide concise, factual insights," + " opportunities, and risks." + ), + name="researcher", + ) + ) + + marketer = AgentExecutor( + AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( + instructions=( + "You're a creative marketing strategist. Craft compelling value propositions and target messaging" + " aligned to the prompt." + ), + name="marketer", + ) + ) + + legal = AgentExecutor( + AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ).as_agent( + instructions=( + "You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns" + " based on the prompt." + ), + name="legal", + ) + ) + + # Create executor instances + dispatcher = DispatchToExperts(id="dispatcher") + aggregator = AggregateInsights(id="aggregator") + # Build a simple fan-out/fan-in workflow workflow = ( - WorkflowBuilder(start_executor="dispatcher") - .register_agent(create_researcher_agent, name="researcher") - .register_agent(create_marketer_agent, name="marketer") - .register_agent(create_legal_agent, name="legal") - .register_executor(lambda: DispatchToExperts(id="dispatcher"), name="dispatcher") - .register_executor(lambda: AggregateInsights(id="aggregator"), name="aggregator") - .add_fan_out_edges("dispatcher", ["researcher", "marketer", "legal"]) - .add_fan_in_edges(["researcher", "marketer", "legal"], "aggregator") + WorkflowBuilder(start_executor=dispatcher) + .add_fan_out_edges(dispatcher, [researcher, marketer, legal]) + .add_fan_in_edges([researcher, marketer, legal], aggregator) .build() ) diff --git a/python/samples/getting_started/agents/a2a/README.md b/python/samples/04-hosting/a2a/README.md similarity index 66% rename from python/samples/getting_started/agents/a2a/README.md rename to python/samples/04-hosting/a2a/README.md index 6900100703..dd09aaaa1a 100644 --- a/python/samples/getting_started/agents/a2a/README.md +++ b/python/samples/04-hosting/a2a/README.md @@ -2,12 +2,15 @@ This folder contains examples demonstrating how to create and use agents with the A2A (Agent2Agent) protocol from the `agent_framework` package to communicate with remote A2A agents. +By default the A2AAgent waits for the remote agent to finish before returning (`background=False`), so long-running A2A tasks are handled transparently. For advanced scenarios where you need to poll or resubscribe to in-progress tasks using continuation tokens, see the [background responses sample](../../02-agents/background_responses.py). + For more information about the A2A protocol specification, visit: https://a2a-protocol.org/latest/ + ## Examples | File | Description | |------|-------------| -| [`agent_with_a2a.py`](agent_with_a2a.py) | The simplest way to connect to and use a single A2A agent. Demonstrates agent discovery via agent cards and basic message exchange using the A2A protocol. | +| [`agent_with_a2a.py`](agent_with_a2a.py) | Demonstrates agent discovery, non-streaming and streaming responses using the A2A protocol. | ## Environment Variables diff --git a/python/samples/04-hosting/a2a/agent_with_a2a.py b/python/samples/04-hosting/a2a/agent_with_a2a.py new file mode 100644 index 0000000000..4250104b9f --- /dev/null +++ b/python/samples/04-hosting/a2a/agent_with_a2a.py @@ -0,0 +1,108 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +import httpx +from a2a.client import A2ACardResolver +from agent_framework.a2a import A2AAgent + +""" +Agent2Agent (A2A) Protocol Integration Sample + +This sample demonstrates how to connect to and communicate with external agents using +the A2A protocol. A2A is a standardized communication protocol that enables interoperability +between different agent systems, allowing agents built with different frameworks and +technologies to communicate seamlessly. + +By default the A2AAgent waits for the remote agent to finish before returning (background=False). +This means long-running A2A tasks are handled transparently — the caller simply awaits the result. +For advanced scenarios where you need to poll or resubscribe to in-progress tasks, see the +background_responses sample: samples/concepts/background_responses.py + +For more information about the A2A protocol specification, visit: https://a2a-protocol.org/latest/ + +Key concepts demonstrated: +- Discovering A2A-compliant agents using AgentCard resolution +- Creating A2AAgent instances to wrap external A2A endpoints +- Non-streaming request/response +- Streaming responses to receive incremental updates via SSE + +To run this sample: +1. Set the A2A_AGENT_HOST environment variable to point to an A2A-compliant agent endpoint + Example: export A2A_AGENT_HOST="https://your-a2a-agent.example.com" +2. Ensure the target agent exposes its AgentCard at /.well-known/agent.json +3. Run: uv run python agent_with_a2a.py + +Visit the README.md for more details on setting up and running A2A agents. +""" + + +async def main(): + """Demonstrates connecting to and communicating with an A2A-compliant agent.""" + # 1. Get A2A agent host from environment. + a2a_agent_host = os.getenv("A2A_AGENT_HOST") + if not a2a_agent_host: + raise ValueError("A2A_AGENT_HOST environment variable is not set") + + print(f"Connecting to A2A agent at: {a2a_agent_host}") + + # 2. Resolve the agent card to discover capabilities. + async with httpx.AsyncClient(timeout=60.0) as http_client: + resolver = A2ACardResolver(httpx_client=http_client, base_url=a2a_agent_host) + agent_card = await resolver.get_agent_card() + print(f"Found agent: {agent_card.name} - {agent_card.description}") + + # 3. Create A2A agent instance. + async with A2AAgent( + name=agent_card.name, + description=agent_card.description, + agent_card=agent_card, + url=a2a_agent_host, + ) as agent: + # 4. Simple request/response — the agent waits for completion internally. + # Even if the remote agent takes a while, background=False (the default) + # means the call blocks until a terminal state is reached. + print("\n--- Non-streaming response ---") + response = await agent.run("What are your capabilities?") + + print("Agent Response:") + for message in response.messages: + print(f" {message.text}") + + # 5. Stream a response — the natural model for A2A. + # Updates arrive as Server-Sent Events, letting you observe + # progress in real time as the remote agent works. + print("\n--- Streaming response ---") + async with agent.run("Tell me about yourself", stream=True) as stream: + async for update in stream: + for content in update.contents: + if content.text: + print(f" {content.text}") + + response = await stream.get_final_response() + print(f"\nFinal response ({len(response.messages)} message(s)):") + for message in response.messages: + print(f" {message.text}") + + +if __name__ == "__main__": + asyncio.run(main()) + + +""" +Sample output: + +Connecting to A2A agent at: http://localhost:5001/ +Found agent: MyAgent - A helpful AI assistant + +--- Non-streaming response --- +Agent Response: + I can help with code generation, analysis, and general Q&A. + +--- Streaming response --- + I am an AI assistant built to help with various tasks. + +Final response (1 message(s)): + I am an AI assistant built to help with various tasks. +""" diff --git a/python/samples/getting_started/azure_functions/01_single_agent/README.md b/python/samples/04-hosting/azure_functions/01_single_agent/README.md similarity index 96% rename from python/samples/getting_started/azure_functions/01_single_agent/README.md rename to python/samples/04-hosting/azure_functions/01_single_agent/README.md index 38c6ce58f5..886f1156a0 100644 --- a/python/samples/getting_started/azure_functions/01_single_agent/README.md +++ b/python/samples/04-hosting/azure_functions/01_single_agent/README.md @@ -7,7 +7,7 @@ This sample demonstrates how to use the Durable Extension for Agent Framework to - Defining a simple agent with the Microsoft Agent Framework and wiring it into an Azure Functions app via the Durable Extension for Agent Framework. - Calling the agent through generated HTTP endpoints (`/api/agents/Joker/run`). -- Managing conversation state with thread identifiers, so multiple clients can +- Managing conversation state with session identifiers, so multiple clients can interact with the agent concurrently without sharing context. ## Prerequisites diff --git a/python/samples/getting_started/azure_functions/01_single_agent/demo.http b/python/samples/04-hosting/azure_functions/01_single_agent/demo.http similarity index 100% rename from python/samples/getting_started/azure_functions/01_single_agent/demo.http rename to python/samples/04-hosting/azure_functions/01_single_agent/demo.http diff --git a/python/samples/getting_started/azure_functions/01_single_agent/function_app.py b/python/samples/04-hosting/azure_functions/01_single_agent/function_app.py similarity index 100% rename from python/samples/getting_started/azure_functions/01_single_agent/function_app.py rename to python/samples/04-hosting/azure_functions/01_single_agent/function_app.py diff --git a/python/samples/getting_started/azure_functions/01_single_agent/host.json b/python/samples/04-hosting/azure_functions/01_single_agent/host.json similarity index 100% rename from python/samples/getting_started/azure_functions/01_single_agent/host.json rename to python/samples/04-hosting/azure_functions/01_single_agent/host.json diff --git a/python/samples/getting_started/azure_functions/01_single_agent/local.settings.json.template b/python/samples/04-hosting/azure_functions/01_single_agent/local.settings.json.template similarity index 100% rename from python/samples/getting_started/azure_functions/01_single_agent/local.settings.json.template rename to python/samples/04-hosting/azure_functions/01_single_agent/local.settings.json.template diff --git a/python/samples/getting_started/azure_functions/01_single_agent/requirements.txt b/python/samples/04-hosting/azure_functions/01_single_agent/requirements.txt similarity index 100% rename from python/samples/getting_started/azure_functions/01_single_agent/requirements.txt rename to python/samples/04-hosting/azure_functions/01_single_agent/requirements.txt diff --git a/python/samples/getting_started/azure_functions/02_multi_agent/README.md b/python/samples/04-hosting/azure_functions/02_multi_agent/README.md similarity index 94% rename from python/samples/getting_started/azure_functions/02_multi_agent/README.md rename to python/samples/04-hosting/azure_functions/02_multi_agent/README.md index 473d6bb236..e133ca369c 100644 --- a/python/samples/getting_started/azure_functions/02_multi_agent/README.md +++ b/python/samples/04-hosting/azure_functions/02_multi_agent/README.md @@ -6,7 +6,7 @@ This sample demonstrates how to use the Durable Extension for Agent Framework to - Using the Microsoft Agent Framework to define multiple AI agents with unique names and instructions. - Registering multiple agents with the Function app and running them using HTTP. -- Conversation management (via thread IDs) for isolated interactions per agent. +- Conversation management (via session IDs) for isolated interactions per agent. - Two different methods for registering agents: list-based initialization and incremental addition. ## Prerequisites @@ -76,8 +76,8 @@ Expected response: { "status": "healthy", "agents": [ - {"name": "WeatherAgent", "type": "ChatAgent"}, - {"name": "MathAgent", "type": "ChatAgent"} + {"name": "WeatherAgent", "type": "Agent"}, + {"name": "MathAgent", "type": "Agent"} ], "agent_count": 2 } diff --git a/python/samples/getting_started/azure_functions/02_multi_agent/demo.http b/python/samples/04-hosting/azure_functions/02_multi_agent/demo.http similarity index 100% rename from python/samples/getting_started/azure_functions/02_multi_agent/demo.http rename to python/samples/04-hosting/azure_functions/02_multi_agent/demo.http diff --git a/python/samples/getting_started/azure_functions/02_multi_agent/function_app.py b/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py similarity index 91% rename from python/samples/getting_started/azure_functions/02_multi_agent/function_app.py rename to python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py index 6a3f396bcb..419b91b779 100644 --- a/python/samples/getting_started/azure_functions/02_multi_agent/function_app.py +++ b/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py @@ -20,7 +20,7 @@ from azure.identity import AzureCliCredential logger = logging.getLogger(__name__) -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather(location: str) -> dict[str, Any]: """Get current weather for a location.""" @@ -56,15 +56,15 @@ def calculate_tip(bill_amount: float, tip_percentage: float = 15.0) -> dict[str, # 1. Create multiple agents, each with its own instruction set and tools. -chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) +client = AzureOpenAIChatClient(credential=AzureCliCredential()) -weather_agent = chat_client.as_agent( +weather_agent = client.as_agent( name="WeatherAgent", instructions="You are a helpful weather assistant. Provide current weather information.", tools=[get_weather], ) -math_agent = chat_client.as_agent( +math_agent = client.as_agent( name="MathAgent", instructions="You are a helpful math assistant. Help users with calculations like tip calculations.", tools=[calculate_tip], diff --git a/python/samples/getting_started/azure_functions/02_multi_agent/host.json b/python/samples/04-hosting/azure_functions/02_multi_agent/host.json similarity index 100% rename from python/samples/getting_started/azure_functions/02_multi_agent/host.json rename to python/samples/04-hosting/azure_functions/02_multi_agent/host.json diff --git a/python/samples/getting_started/azure_functions/02_multi_agent/local.settings.json.template b/python/samples/04-hosting/azure_functions/02_multi_agent/local.settings.json.template similarity index 100% rename from python/samples/getting_started/azure_functions/02_multi_agent/local.settings.json.template rename to python/samples/04-hosting/azure_functions/02_multi_agent/local.settings.json.template diff --git a/python/samples/getting_started/azure_functions/02_multi_agent/requirements.txt b/python/samples/04-hosting/azure_functions/02_multi_agent/requirements.txt similarity index 100% rename from python/samples/getting_started/azure_functions/02_multi_agent/requirements.txt rename to python/samples/04-hosting/azure_functions/02_multi_agent/requirements.txt diff --git a/python/samples/getting_started/azure_functions/03_reliable_streaming/README.md b/python/samples/04-hosting/azure_functions/03_reliable_streaming/README.md similarity index 100% rename from python/samples/getting_started/azure_functions/03_reliable_streaming/README.md rename to python/samples/04-hosting/azure_functions/03_reliable_streaming/README.md diff --git a/python/samples/getting_started/azure_functions/03_reliable_streaming/demo.http b/python/samples/04-hosting/azure_functions/03_reliable_streaming/demo.http similarity index 100% rename from python/samples/getting_started/azure_functions/03_reliable_streaming/demo.http rename to python/samples/04-hosting/azure_functions/03_reliable_streaming/demo.http diff --git a/python/samples/getting_started/azure_functions/03_reliable_streaming/function_app.py b/python/samples/04-hosting/azure_functions/03_reliable_streaming/function_app.py similarity index 100% rename from python/samples/getting_started/azure_functions/03_reliable_streaming/function_app.py rename to python/samples/04-hosting/azure_functions/03_reliable_streaming/function_app.py diff --git a/python/samples/getting_started/azure_functions/03_reliable_streaming/host.json b/python/samples/04-hosting/azure_functions/03_reliable_streaming/host.json similarity index 100% rename from python/samples/getting_started/azure_functions/03_reliable_streaming/host.json rename to python/samples/04-hosting/azure_functions/03_reliable_streaming/host.json diff --git a/python/samples/getting_started/azure_functions/03_reliable_streaming/local.settings.json.template b/python/samples/04-hosting/azure_functions/03_reliable_streaming/local.settings.json.template similarity index 100% rename from python/samples/getting_started/azure_functions/03_reliable_streaming/local.settings.json.template rename to python/samples/04-hosting/azure_functions/03_reliable_streaming/local.settings.json.template diff --git a/python/samples/getting_started/azure_functions/03_reliable_streaming/redis_stream_response_handler.py b/python/samples/04-hosting/azure_functions/03_reliable_streaming/redis_stream_response_handler.py similarity index 100% rename from python/samples/getting_started/azure_functions/03_reliable_streaming/redis_stream_response_handler.py rename to python/samples/04-hosting/azure_functions/03_reliable_streaming/redis_stream_response_handler.py diff --git a/python/samples/getting_started/azure_functions/03_reliable_streaming/requirements.txt b/python/samples/04-hosting/azure_functions/03_reliable_streaming/requirements.txt similarity index 100% rename from python/samples/getting_started/azure_functions/03_reliable_streaming/requirements.txt rename to python/samples/04-hosting/azure_functions/03_reliable_streaming/requirements.txt diff --git a/python/samples/getting_started/azure_functions/03_reliable_streaming/tools.py b/python/samples/04-hosting/azure_functions/03_reliable_streaming/tools.py similarity index 100% rename from python/samples/getting_started/azure_functions/03_reliable_streaming/tools.py rename to python/samples/04-hosting/azure_functions/03_reliable_streaming/tools.py diff --git a/python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/README.md b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/README.md similarity index 94% rename from python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/README.md rename to python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/README.md index 13e8c08429..332c03d378 100644 --- a/python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/README.md +++ b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/README.md @@ -4,8 +4,8 @@ This sample shows how to chain two invocations of the same agent inside a Durabl preserving the conversation state between runs. ## Key Concepts -- Deterministic orchestrations that make sequential agent calls on a shared thread -- Reusing an agent thread to carry conversation history across invocations +- Deterministic orchestrations that make sequential agent calls on a shared session +- Reusing an agent session to carry conversation history across invocations - HTTP endpoints for starting the orchestration and polling for status/output ## Prerequisites diff --git a/python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/demo.http b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/demo.http similarity index 100% rename from python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/demo.http rename to python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/demo.http diff --git a/python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/function_app.py b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/function_app.py similarity index 95% rename from python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/function_app.py rename to python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/function_app.py index 33ccc5319f..0b6f97f87a 100644 --- a/python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/function_app.py +++ b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/function_app.py @@ -5,7 +5,7 @@ Components used in this sample: - AzureOpenAIChatClient to construct the writer agent hosted by Agent Framework. - AgentFunctionApp to surface HTTP and orchestration triggers via the Azure Functions extension. -- Durable Functions orchestration to run sequential agent invocations on the same conversation thread. +- Durable Functions orchestration to run sequential agent invocations on the same conversation session. Prerequisites: configure `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, and either `AZURE_OPENAI_API_KEY` or authenticate with Azure CLI before starting the Functions host.""" @@ -45,17 +45,17 @@ def _create_writer_agent() -> Any: app = AgentFunctionApp(agents=[_create_writer_agent()], enable_health_check=True) -# 4. Orchestration that runs the agent sequentially on a shared thread for chaining behaviour. +# 4. Orchestration that runs the agent sequentially on a shared session for chaining behaviour. @app.orchestration_trigger(context_name="context") def single_agent_orchestration(context: DurableOrchestrationContext) -> Generator[Any, Any, str]: - """Run the writer agent twice on the same thread to mirror chaining behaviour.""" + """Run the writer agent twice on the same session to mirror chaining behaviour.""" writer = app.get_agent(context, WRITER_AGENT_NAME) - writer_thread = writer.get_new_thread() + writer_session = writer.create_session() initial = yield writer.run( messages="Write a concise inspirational sentence about learning.", - thread=writer_thread, + session=writer_session, ) improved_prompt = ( @@ -65,7 +65,7 @@ def single_agent_orchestration(context: DurableOrchestrationContext) -> Generato refined = yield writer.run( messages=improved_prompt, - thread=writer_thread, + session=writer_session, ) return refined.text diff --git a/python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/host.json b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/host.json similarity index 100% rename from python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/host.json rename to python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/host.json diff --git a/python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/local.settings.json.template b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/local.settings.json.template similarity index 100% rename from python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/local.settings.json.template rename to python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/local.settings.json.template diff --git a/python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/requirements.txt b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/requirements.txt similarity index 100% rename from python/samples/getting_started/azure_functions/04_single_agent_orchestration_chaining/requirements.txt rename to python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/requirements.txt diff --git a/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/README.md b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/README.md similarity index 100% rename from python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/README.md rename to python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/README.md diff --git a/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/demo.http b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/demo.http similarity index 100% rename from python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/demo.http rename to python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/demo.http diff --git a/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/function_app.py b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/function_app.py similarity index 94% rename from python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/function_app.py rename to python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/function_app.py index aad945288c..148835033f 100644 --- a/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/function_app.py +++ b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/function_app.py @@ -30,14 +30,14 @@ CHEMIST_AGENT_NAME = "ChemistAgent" # 2. Instantiate both agents that the orchestration will run concurrently. def _create_agents() -> list[Any]: - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIChatClient(credential=AzureCliCredential()) - physicist = chat_client.as_agent( + physicist = client.as_agent( name=PHYSICIST_AGENT_NAME, instructions="You are an expert in physics. You answer questions from a physics perspective.", ) - chemist = chat_client.as_agent( + chemist = client.as_agent( name=CHEMIST_AGENT_NAME, instructions="You are an expert in chemistry. You answer questions from a chemistry perspective.", ) @@ -64,12 +64,12 @@ def multi_agent_concurrent_orchestration(context: DurableOrchestrationContext) - physicist = app.get_agent(context, PHYSICIST_AGENT_NAME) chemist = app.get_agent(context, CHEMIST_AGENT_NAME) - physicist_thread = physicist.get_new_thread() - chemist_thread = chemist.get_new_thread() + physicist_session = physicist.create_session() + chemist_session = chemist.create_session() # Create tasks from agent.run() calls - physicist_task = physicist.run(messages=str(prompt), thread=physicist_thread) - chemist_task = chemist.run(messages=str(prompt), thread=chemist_thread) + physicist_task = physicist.run(messages=str(prompt), session=physicist_session) + chemist_task = chemist.run(messages=str(prompt), session=chemist_session) # Execute both tasks concurrently using task_all task_results = yield context.task_all([physicist_task, chemist_task]) diff --git a/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/host.json b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/host.json similarity index 100% rename from python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/host.json rename to python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/host.json diff --git a/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/local.settings.json.template b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/local.settings.json.template similarity index 100% rename from python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/local.settings.json.template rename to python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/local.settings.json.template diff --git a/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/requirements.txt b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/requirements.txt similarity index 100% rename from python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/requirements.txt rename to python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/requirements.txt diff --git a/python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/README.md b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/README.md similarity index 100% rename from python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/README.md rename to python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/README.md diff --git a/python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/demo.http b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/demo.http similarity index 100% rename from python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/demo.http rename to python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/demo.http diff --git a/python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/function_app.py b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/function_app.py similarity index 96% rename from python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/function_app.py rename to python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/function_app.py index 54728332f0..148c6eaad5 100644 --- a/python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/function_app.py +++ b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/function_app.py @@ -45,14 +45,14 @@ class EmailPayload(BaseModel): # 2. Instantiate both agents so they can be registered with AgentFunctionApp. def _create_agents() -> list[Any]: - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = AzureOpenAIChatClient(credential=AzureCliCredential()) - spam_agent = chat_client.as_agent( + spam_agent = client.as_agent( name=SPAM_AGENT_NAME, instructions="You are a spam detection assistant that identifies spam emails.", ) - email_agent = chat_client.as_agent( + email_agent = client.as_agent( name=EMAIL_AGENT_NAME, instructions="You are an email assistant that helps users draft responses to emails with professionalism.", ) @@ -89,7 +89,7 @@ def spam_detection_orchestration(context: DurableOrchestrationContext) -> Genera spam_agent = app.get_agent(context, SPAM_AGENT_NAME) email_agent = app.get_agent(context, EMAIL_AGENT_NAME) - spam_thread = spam_agent.get_new_thread() + spam_session = spam_agent.create_session() spam_prompt = ( "Analyze this email for spam content and return a JSON response with 'is_spam' (boolean) " @@ -100,7 +100,7 @@ def spam_detection_orchestration(context: DurableOrchestrationContext) -> Genera spam_result_raw = yield spam_agent.run( messages=spam_prompt, - thread=spam_thread, + session=spam_session, options={"response_format": SpamDetectionResult}, ) @@ -113,7 +113,7 @@ def spam_detection_orchestration(context: DurableOrchestrationContext) -> Genera result = yield context.call_activity("handle_spam_email", spam_result.reason) # type: ignore[misc] return result - email_thread = email_agent.get_new_thread() + email_session = email_agent.create_session() email_prompt = ( "Draft a professional response to this email. Return a JSON response with a 'response' field " @@ -124,7 +124,7 @@ def spam_detection_orchestration(context: DurableOrchestrationContext) -> Genera email_result_raw = yield email_agent.run( messages=email_prompt, - thread=email_thread, + session=email_session, options={"response_format": EmailResponse}, ) diff --git a/python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/host.json b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/host.json similarity index 100% rename from python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/host.json rename to python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/host.json diff --git a/python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/local.settings.json.template b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/local.settings.json.template similarity index 100% rename from python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/local.settings.json.template rename to python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/local.settings.json.template diff --git a/python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/requirements.txt b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/requirements.txt similarity index 100% rename from python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/requirements.txt rename to python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/requirements.txt diff --git a/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/README.md b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/README.md similarity index 100% rename from python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/README.md rename to python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/README.md diff --git a/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/demo.http b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/demo.http similarity index 100% rename from python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/demo.http rename to python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/demo.http diff --git a/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/function_app.py b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/function_app.py similarity index 99% rename from python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/function_app.py rename to python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/function_app.py index 931092c6cc..644ed9ed23 100644 --- a/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/function_app.py +++ b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/function_app.py @@ -93,13 +93,13 @@ def content_generation_hitl_orchestration(context: DurableOrchestrationContext) raise ValueError(f"Invalid content generation input: {exc}") from exc writer = app.get_agent(context, WRITER_AGENT_NAME) - writer_thread = writer.get_new_thread() + writer_session = writer.create_session() context.set_custom_status(f"Starting content generation for topic: {payload.topic}") initial_raw = yield writer.run( messages=f"Write a short article about '{payload.topic}'.", - thread=writer_thread, + session=writer_session, options={"response_format": GeneratedContent}, ) @@ -150,7 +150,7 @@ def content_generation_hitl_orchestration(context: DurableOrchestrationContext) ) rewritten_raw = yield writer.run( messages=rewrite_prompt, - thread=writer_thread, + session=writer_session, options={"response_format": GeneratedContent}, ) diff --git a/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/host.json b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/host.json similarity index 100% rename from python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/host.json rename to python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/host.json diff --git a/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/local.settings.json.template b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/local.settings.json.template similarity index 100% rename from python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/local.settings.json.template rename to python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/local.settings.json.template diff --git a/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/requirements.txt b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/requirements.txt similarity index 100% rename from python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/requirements.txt rename to python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/requirements.txt diff --git a/python/samples/getting_started/azure_functions/08_mcp_server/README.md b/python/samples/04-hosting/azure_functions/08_mcp_server/README.md similarity index 96% rename from python/samples/getting_started/azure_functions/08_mcp_server/README.md rename to python/samples/04-hosting/azure_functions/08_mcp_server/README.md index 02fcbbb957..80fb2370b8 100644 --- a/python/samples/getting_started/azure_functions/08_mcp_server/README.md +++ b/python/samples/04-hosting/azure_functions/08_mcp_server/README.md @@ -46,7 +46,7 @@ Update your `local.settings.json` with your Azure OpenAI credentials: 1. **Start the Function App**: ```bash - cd python/samples/getting_started/azure_functions/08_mcp_server + cd python/samples/04-hosting/azure_functions/08_mcp_server func start ``` @@ -142,20 +142,20 @@ The sample shows how to enable MCP tool triggers with flexible agent configurati from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient # Create Azure OpenAI Chat Client -chat_client = AzureOpenAIChatClient() +client = AzureOpenAIChatClient() # Define agents with different roles -joker_agent = chat_client.as_agent( +joker_agent = client.as_agent( name="Joker", instructions="You are good at telling jokes.", ) -stock_agent = chat_client.as_agent( +stock_agent = client.as_agent( name="StockAdvisor", instructions="Check stock prices.", ) -plant_agent = chat_client.as_agent( +plant_agent = client.as_agent( name="PlantAdvisor", instructions="Recommend plants.", description="Get plant recommendations.", diff --git a/python/samples/getting_started/azure_functions/08_mcp_server/function_app.py b/python/samples/04-hosting/azure_functions/08_mcp_server/function_app.py similarity index 94% rename from python/samples/getting_started/azure_functions/08_mcp_server/function_app.py rename to python/samples/04-hosting/azure_functions/08_mcp_server/function_app.py index 2d67ddec81..b34361d10e 100644 --- a/python/samples/getting_started/azure_functions/08_mcp_server/function_app.py +++ b/python/samples/04-hosting/azure_functions/08_mcp_server/function_app.py @@ -28,23 +28,23 @@ from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient # Create Azure OpenAI Chat Client # This uses AzureCliCredential for authentication (requires 'az login') -chat_client = AzureOpenAIChatClient() +client = AzureOpenAIChatClient() # Define three AI agents with different roles # Agent 1: Joker - HTTP trigger only (default) -agent1 = chat_client.as_agent( +agent1 = client.as_agent( name="Joker", instructions="You are good at telling jokes.", ) # Agent 2: StockAdvisor - MCP tool trigger only -agent2 = chat_client.as_agent( +agent2 = client.as_agent( name="StockAdvisor", instructions="Check stock prices.", ) # Agent 3: PlantAdvisor - Both HTTP and MCP tool triggers -agent3 = chat_client.as_agent( +agent3 = client.as_agent( name="PlantAdvisor", instructions="Recommend plants.", description="Get plant recommendations.", diff --git a/python/samples/getting_started/azure_functions/08_mcp_server/host.json b/python/samples/04-hosting/azure_functions/08_mcp_server/host.json similarity index 100% rename from python/samples/getting_started/azure_functions/08_mcp_server/host.json rename to python/samples/04-hosting/azure_functions/08_mcp_server/host.json diff --git a/python/samples/getting_started/azure_functions/08_mcp_server/local.settings.json.template b/python/samples/04-hosting/azure_functions/08_mcp_server/local.settings.json.template similarity index 100% rename from python/samples/getting_started/azure_functions/08_mcp_server/local.settings.json.template rename to python/samples/04-hosting/azure_functions/08_mcp_server/local.settings.json.template diff --git a/python/samples/getting_started/azure_functions/08_mcp_server/requirements.txt b/python/samples/04-hosting/azure_functions/08_mcp_server/requirements.txt similarity index 100% rename from python/samples/getting_started/azure_functions/08_mcp_server/requirements.txt rename to python/samples/04-hosting/azure_functions/08_mcp_server/requirements.txt diff --git a/python/samples/getting_started/azure_functions/README.md b/python/samples/04-hosting/azure_functions/README.md similarity index 100% rename from python/samples/getting_started/azure_functions/README.md rename to python/samples/04-hosting/azure_functions/README.md diff --git a/python/samples/getting_started/durabletask/01_single_agent/README.md b/python/samples/04-hosting/durabletask/01_single_agent/README.md similarity index 90% rename from python/samples/getting_started/durabletask/01_single_agent/README.md rename to python/samples/04-hosting/durabletask/01_single_agent/README.md index ffe3b1484a..2b8ce83c13 100644 --- a/python/samples/getting_started/durabletask/01_single_agent/README.md +++ b/python/samples/04-hosting/durabletask/01_single_agent/README.md @@ -6,7 +6,7 @@ This sample demonstrates how to create a worker-client setup that hosts a single - Using the Microsoft Agent Framework to define a simple AI agent with a name and instructions. - Registering durable agents with the worker and interacting with them via a client. -- Conversation management (via threads) for isolated interactions. +- Conversation management (via sessions) for isolated interactions. - Worker-client architecture for distributed agent execution. ## Environment Setup @@ -20,7 +20,7 @@ With the environment setup, you can run the sample using the combined approach o **Option 1: Combined (Recommended for Testing)** ```bash -cd samples/getting_started/durabletask/01_single_agent +cd samples/04-hosting/durabletask/01_single_agent python sample.py ``` @@ -46,7 +46,7 @@ Using taskhub: default Using endpoint: http://localhost:8080 Getting reference to Joker agent... -Created conversation thread: a1b2c3d4-e5f6-7890-abcd-ef1234567890 +Created conversation session: a1b2c3d4-e5f6-7890-abcd-ef1234567890 User: Tell me a short joke about cloud computing. diff --git a/python/samples/getting_started/durabletask/01_single_agent/client.py b/python/samples/04-hosting/durabletask/01_single_agent/client.py similarity index 94% rename from python/samples/getting_started/durabletask/01_single_agent/client.py rename to python/samples/04-hosting/durabletask/01_single_agent/client.py index d88c9e857f..7940d0421c 100644 --- a/python/samples/getting_started/durabletask/01_single_agent/client.py +++ b/python/samples/04-hosting/durabletask/01_single_agent/client.py @@ -69,9 +69,9 @@ def run_client(agent_client: DurableAIAgentClient) -> None: logger.debug("Getting reference to Joker agent...") joker = agent_client.get_agent("Joker") - # Create a new thread for the conversation - thread = joker.get_new_thread() - logger.debug(f"Thread ID: {thread.session_id}") + # Create a new session for the conversation + session = joker.create_session() + logger.debug(f"Session ID: {session.session_id}") logger.info("Start chatting with the Joker agent! (Type 'exit' to quit)") # Interactive conversation loop @@ -94,7 +94,7 @@ def run_client(agent_client: DurableAIAgentClient) -> None: # Send message to agent and get response try: - response = joker.run(user_message, thread=thread) + response = joker.run(user_message, session=session) logger.info(f"Joker: {response.text} \n") except Exception as e: logger.error(f"Error getting response: {e}") diff --git a/python/samples/getting_started/durabletask/01_single_agent/requirements.txt b/python/samples/04-hosting/durabletask/01_single_agent/requirements.txt similarity index 100% rename from python/samples/getting_started/durabletask/01_single_agent/requirements.txt rename to python/samples/04-hosting/durabletask/01_single_agent/requirements.txt diff --git a/python/samples/getting_started/durabletask/01_single_agent/sample.py b/python/samples/04-hosting/durabletask/01_single_agent/sample.py similarity index 100% rename from python/samples/getting_started/durabletask/01_single_agent/sample.py rename to python/samples/04-hosting/durabletask/01_single_agent/sample.py diff --git a/python/samples/getting_started/durabletask/01_single_agent/worker.py b/python/samples/04-hosting/durabletask/01_single_agent/worker.py similarity index 96% rename from python/samples/getting_started/durabletask/01_single_agent/worker.py rename to python/samples/04-hosting/durabletask/01_single_agent/worker.py index 8afbbb3a44..64023113b4 100644 --- a/python/samples/getting_started/durabletask/01_single_agent/worker.py +++ b/python/samples/04-hosting/durabletask/01_single_agent/worker.py @@ -15,7 +15,7 @@ import asyncio import logging import os -from agent_framework import ChatAgent +from agent_framework import Agent from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentWorker from azure.identity import AzureCliCredential, DefaultAzureCredential from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker @@ -25,11 +25,11 @@ logging.basicConfig(level=logging.WARNING) logger = logging.getLogger(__name__) -def create_joker_agent() -> ChatAgent: +def create_joker_agent() -> Agent: """Create the Joker agent using Azure OpenAI. Returns: - ChatAgent: The configured Joker agent + Agent: The configured Joker agent """ return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( name="Joker", diff --git a/python/samples/getting_started/durabletask/02_multi_agent/README.md b/python/samples/04-hosting/durabletask/02_multi_agent/README.md similarity index 94% rename from python/samples/getting_started/durabletask/02_multi_agent/README.md rename to python/samples/04-hosting/durabletask/02_multi_agent/README.md index e9b2a36e19..b2989579e8 100644 --- a/python/samples/getting_started/durabletask/02_multi_agent/README.md +++ b/python/samples/04-hosting/durabletask/02_multi_agent/README.md @@ -6,7 +6,7 @@ This sample demonstrates how to host multiple AI agents with different tools in - Hosting multiple agents (WeatherAgent and MathAgent) in a single worker process. - Each agent with its own specialized tools and instructions. -- Interacting with different agents using separate conversation threads. +- Interacting with different agents using separate conversation sessions. - Worker-client architecture for multi-agent systems. ## Environment Setup @@ -20,7 +20,7 @@ With the environment setup, you can run the sample using the combined approach o **Option 1: Combined (Recommended for Testing)** ```bash -cd samples/getting_started/durabletask/02_multi_agent +cd samples/04-hosting/durabletask/02_multi_agent python sample.py ``` @@ -49,7 +49,7 @@ Using endpoint: http://localhost:8080 Testing WeatherAgent ================================================================================ -Created weather conversation thread: +Created weather conversation session: User: What is the weather in Seattle? 🔧 [TOOL CALLED] get_weather(location=Seattle) @@ -61,7 +61,7 @@ WeatherAgent: The current weather in Seattle is sunny with a temperature of 72° Testing MathAgent ================================================================================ -Created math conversation thread: +Created math conversation session: User: Calculate a 20% tip on a $50 bill 🔧 [TOOL CALLED] calculate_tip(bill_amount=50.0, tip_percentage=20.0) diff --git a/python/samples/getting_started/durabletask/02_multi_agent/client.py b/python/samples/04-hosting/durabletask/02_multi_agent/client.py similarity index 88% rename from python/samples/getting_started/durabletask/02_multi_agent/client.py rename to python/samples/04-hosting/durabletask/02_multi_agent/client.py index 4586186408..ee9f0e7ab6 100644 --- a/python/samples/getting_started/durabletask/02_multi_agent/client.py +++ b/python/samples/04-hosting/durabletask/02_multi_agent/client.py @@ -70,30 +70,30 @@ def run_client(agent_client: DurableAIAgentClient) -> None: # Get reference to WeatherAgent weather_agent = agent_client.get_agent("WeatherAgent") - weather_thread = weather_agent.get_new_thread() + weather_session = weather_agent.create_session() - logger.debug(f"Created weather conversation thread: {weather_thread.session_id}") + logger.debug(f"Created weather conversation session: {weather_session.session_id}") # Test WeatherAgent weather_message = "What is the weather in Seattle?" logger.info(f"User: {weather_message}") - weather_response = weather_agent.run(weather_message, thread=weather_thread) + weather_response = weather_agent.run(weather_message, session=weather_session) logger.info(f"WeatherAgent: {weather_response.text} \n") logger.debug("Testing MathAgent") # Get reference to MathAgent math_agent = agent_client.get_agent("MathAgent") - math_thread = math_agent.get_new_thread() + math_session = math_agent.create_session() - logger.debug(f"Created math conversation thread: {math_thread.session_id}") + logger.debug(f"Created math conversation session: {math_session.session_id}") # Test MathAgent math_message = "Calculate a 20% tip on a $50 bill" logger.info(f"User: {math_message}") - math_response = math_agent.run(math_message, thread=math_thread) + math_response = math_agent.run(math_message, session=math_session) logger.info(f"MathAgent: {math_response.text} \n") logger.debug("Both agents completed successfully!") diff --git a/python/samples/getting_started/durabletask/02_multi_agent/requirements.txt b/python/samples/04-hosting/durabletask/02_multi_agent/requirements.txt similarity index 100% rename from python/samples/getting_started/durabletask/02_multi_agent/requirements.txt rename to python/samples/04-hosting/durabletask/02_multi_agent/requirements.txt diff --git a/python/samples/getting_started/durabletask/02_multi_agent/sample.py b/python/samples/04-hosting/durabletask/02_multi_agent/sample.py similarity index 100% rename from python/samples/getting_started/durabletask/02_multi_agent/sample.py rename to python/samples/04-hosting/durabletask/02_multi_agent/sample.py diff --git a/python/samples/getting_started/durabletask/02_multi_agent/worker.py b/python/samples/04-hosting/durabletask/02_multi_agent/worker.py similarity index 97% rename from python/samples/getting_started/durabletask/02_multi_agent/worker.py rename to python/samples/04-hosting/durabletask/02_multi_agent/worker.py index 88d9c2949d..3a6db39b7a 100644 --- a/python/samples/getting_started/durabletask/02_multi_agent/worker.py +++ b/python/samples/04-hosting/durabletask/02_multi_agent/worker.py @@ -65,7 +65,7 @@ def create_weather_agent(): """Create the Weather agent using Azure OpenAI. Returns: - ChatAgent: The configured Weather agent with weather tool + Agent: The configured Weather agent with weather tool """ return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( name=WEATHER_AGENT_NAME, @@ -78,7 +78,7 @@ def create_math_agent(): """Create the Math agent using Azure OpenAI. Returns: - ChatAgent: The configured Math agent with calculation tools + Agent: The configured Math agent with calculation tools """ return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( name=MATH_AGENT_NAME, diff --git a/python/samples/getting_started/durabletask/03_single_agent_streaming/README.md b/python/samples/04-hosting/durabletask/03_single_agent_streaming/README.md similarity index 98% rename from python/samples/getting_started/durabletask/03_single_agent_streaming/README.md rename to python/samples/04-hosting/durabletask/03_single_agent_streaming/README.md index 6e9f1428bf..8bd4191fe8 100644 --- a/python/samples/getting_started/durabletask/03_single_agent_streaming/README.md +++ b/python/samples/04-hosting/durabletask/03_single_agent_streaming/README.md @@ -37,7 +37,7 @@ With the environment setup, you can run the sample using the combined approach o **Option 1: Combined (Recommended for Testing)** ```bash -cd samples/getting_started/durabletask/03_single_agent_streaming +cd samples/04-hosting/durabletask/03_single_agent_streaming python sample.py ``` diff --git a/python/samples/getting_started/durabletask/03_single_agent_streaming/client.py b/python/samples/04-hosting/durabletask/03_single_agent_streaming/client.py similarity index 94% rename from python/samples/getting_started/durabletask/03_single_agent_streaming/client.py rename to python/samples/04-hosting/durabletask/03_single_agent_streaming/client.py index c65b27b2a9..9cb6f4cd88 100644 --- a/python/samples/getting_started/durabletask/03_single_agent_streaming/client.py +++ b/python/samples/04-hosting/durabletask/03_single_agent_streaming/client.py @@ -140,14 +140,14 @@ def run_client(agent_client: DurableAIAgentClient) -> None: logger.debug("Getting reference to TravelPlanner agent...") travel_planner = agent_client.get_agent("TravelPlanner") - # Create a new thread for the conversation - thread = travel_planner.get_new_thread() - if not thread.session_id: - logger.error("Failed to create a new thread with session ID!") + # Create a new session for the conversation + session = travel_planner.create_session() + if not session.session_id: + logger.error("Failed to create a new session with session ID!") return - key = thread.session_id.key - logger.info(f"Thread ID: {key}") + key = session.session_id + logger.info(f"Session ID: {key}") # Get user input print("\nEnter your travel planning request:") @@ -164,7 +164,7 @@ def run_client(agent_client: DurableAIAgentClient) -> None: # Start the agent run with wait_for_response=False for non-blocking execution # This signals the agent to start processing without waiting for completion # The agent will execute in the background and write chunks to Redis - travel_planner.run(user_message, thread=thread, options={"wait_for_response": False}) + travel_planner.run(user_message, session=session, options={"wait_for_response": False}) # Stream the response from Redis # This demonstrates that the client can stream from Redis while diff --git a/python/samples/getting_started/durabletask/03_single_agent_streaming/redis_stream_response_handler.py b/python/samples/04-hosting/durabletask/03_single_agent_streaming/redis_stream_response_handler.py similarity index 100% rename from python/samples/getting_started/durabletask/03_single_agent_streaming/redis_stream_response_handler.py rename to python/samples/04-hosting/durabletask/03_single_agent_streaming/redis_stream_response_handler.py diff --git a/python/samples/getting_started/durabletask/03_single_agent_streaming/requirements.txt b/python/samples/04-hosting/durabletask/03_single_agent_streaming/requirements.txt similarity index 100% rename from python/samples/getting_started/durabletask/03_single_agent_streaming/requirements.txt rename to python/samples/04-hosting/durabletask/03_single_agent_streaming/requirements.txt diff --git a/python/samples/getting_started/durabletask/03_single_agent_streaming/sample.py b/python/samples/04-hosting/durabletask/03_single_agent_streaming/sample.py similarity index 100% rename from python/samples/getting_started/durabletask/03_single_agent_streaming/sample.py rename to python/samples/04-hosting/durabletask/03_single_agent_streaming/sample.py diff --git a/python/samples/getting_started/durabletask/03_single_agent_streaming/tools.py b/python/samples/04-hosting/durabletask/03_single_agent_streaming/tools.py similarity index 100% rename from python/samples/getting_started/durabletask/03_single_agent_streaming/tools.py rename to python/samples/04-hosting/durabletask/03_single_agent_streaming/tools.py diff --git a/python/samples/getting_started/durabletask/03_single_agent_streaming/worker.py b/python/samples/04-hosting/durabletask/03_single_agent_streaming/worker.py similarity index 97% rename from python/samples/getting_started/durabletask/03_single_agent_streaming/worker.py rename to python/samples/04-hosting/durabletask/03_single_agent_streaming/worker.py index c2eb2e973b..320c008cde 100644 --- a/python/samples/getting_started/durabletask/03_single_agent_streaming/worker.py +++ b/python/samples/04-hosting/durabletask/03_single_agent_streaming/worker.py @@ -18,7 +18,7 @@ import os from datetime import timedelta import redis.asyncio as aioredis -from agent_framework import AgentResponseUpdate, ChatAgent +from agent_framework import Agent, AgentResponseUpdate from agent_framework.azure import ( AgentCallbackContext, AgentResponseCallbackProtocol, @@ -143,11 +143,11 @@ class RedisStreamCallback(AgentResponseCallbackProtocol): logger.error(f"Error writing end-of-stream marker: {ex}", exc_info=True) -def create_travel_agent() -> "ChatAgent": +def create_travel_agent() -> "Agent": """Create the TravelPlanner agent using Azure OpenAI. Returns: - ChatAgent: The configured TravelPlanner agent with travel planning tools. + Agent: The configured TravelPlanner agent with travel planning tools. """ return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( name="TravelPlanner", diff --git a/python/samples/getting_started/durabletask/04_single_agent_orchestration_chaining/README.md b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/README.md similarity index 92% rename from python/samples/getting_started/durabletask/04_single_agent_orchestration_chaining/README.md rename to python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/README.md index 3a5605b3dd..4d015c28dc 100644 --- a/python/samples/getting_started/durabletask/04_single_agent_orchestration_chaining/README.md +++ b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/README.md @@ -6,7 +6,7 @@ This sample demonstrates how to chain multiple invocations of the same agent usi - Using durable orchestrations to coordinate sequential agent invocations. - Chaining agent calls where the output of one run becomes input to the next. -- Maintaining conversation context across sequential runs using a shared thread. +- Maintaining conversation context across sequential runs using a shared session. - Using `DurableAIAgentOrchestrationContext` to access agents within orchestrations. ## Environment Setup @@ -20,7 +20,7 @@ With the environment setup, you can run the sample using the combined approach o **Option 1: Combined (Recommended for Testing)** ```bash -cd samples/getting_started/durabletask/04_single_agent_orchestration_chaining +cd samples/04-hosting/durabletask/04_single_agent_orchestration_chaining python sample.py ``` @@ -42,7 +42,7 @@ The orchestration will execute the writer agent twice sequentially: ``` [Orchestration] Starting single agent chaining... -[Orchestration] Created thread: abc-123 +[Orchestration] Created session: abc-123 [Orchestration] First agent run: Generating initial sentence... [Orchestration] Initial response: Every small step forward is progress toward mastery. [Orchestration] Second agent run: Refining the sentence... @@ -62,7 +62,7 @@ You can view the state of the orchestration in the Durable Task Scheduler dashbo 1. Open your browser and navigate to `http://localhost:8082` 2. In the dashboard, you can view: - The sequential execution of both agent runs - - The conversation thread shared between runs + - The conversation session shared between runs - Input and output at each step - Overall orchestration state and history diff --git a/python/samples/getting_started/durabletask/04_single_agent_orchestration_chaining/client.py b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/client.py similarity index 100% rename from python/samples/getting_started/durabletask/04_single_agent_orchestration_chaining/client.py rename to python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/client.py diff --git a/python/samples/getting_started/durabletask/04_single_agent_orchestration_chaining/requirements.txt b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/requirements.txt similarity index 100% rename from python/samples/getting_started/durabletask/04_single_agent_orchestration_chaining/requirements.txt rename to python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/requirements.txt diff --git a/python/samples/getting_started/durabletask/04_single_agent_orchestration_chaining/sample.py b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/sample.py similarity index 100% rename from python/samples/getting_started/durabletask/04_single_agent_orchestration_chaining/sample.py rename to python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/sample.py diff --git a/python/samples/getting_started/durabletask/04_single_agent_orchestration_chaining/worker.py b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/worker.py similarity index 94% rename from python/samples/getting_started/durabletask/04_single_agent_orchestration_chaining/worker.py rename to python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/worker.py index f10a35b61b..ecc44a8959 100644 --- a/python/samples/getting_started/durabletask/04_single_agent_orchestration_chaining/worker.py +++ b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/worker.py @@ -17,7 +17,7 @@ import logging import os from collections.abc import Generator -from agent_framework import AgentResponse, ChatAgent +from agent_framework import Agent, AgentResponse from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentOrchestrationContext, DurableAIAgentWorker from azure.identity import AzureCliCredential, DefaultAzureCredential from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker @@ -31,14 +31,14 @@ logger = logging.getLogger(__name__) WRITER_AGENT_NAME = "WriterAgent" -def create_writer_agent() -> "ChatAgent": +def create_writer_agent() -> "Agent": """Create the Writer agent using Azure OpenAI. This agent refines short pieces of text, enhancing initial sentences and polishing improved versions further. Returns: - ChatAgent: The configured Writer agent + Agent: The configured Writer agent """ instructions = ( "You refine short pieces of text. When given an initial sentence you enhance it;\n" @@ -87,17 +87,17 @@ def single_agent_chaining_orchestration( # Get the writer agent using the agent context writer = agent_context.get_agent(WRITER_AGENT_NAME) - # Create a new thread for the conversation - this will be shared across both runs - writer_thread = writer.get_new_thread() + # Create a new session for the conversation - this will be shared across both runs + writer_session = writer.create_session() - logger.debug(f"[Orchestration] Created thread: {writer_thread.session_id}") + logger.debug(f"[Orchestration] Created session: {writer_session.session_id}") prompt = "Write a concise inspirational sentence about learning." # First run: Generate an initial inspirational sentence logger.info("[Orchestration] First agent run: Generating initial sentence about: %s", prompt) initial_response = yield writer.run( messages=prompt, - thread=writer_thread, + session=writer_session, ) logger.info(f"[Orchestration] Initial response: {initial_response.text}") @@ -110,7 +110,7 @@ def single_agent_chaining_orchestration( logger.info("[Orchestration] Second agent run: Refining the sentence: %s", improved_prompt) refined_response = yield writer.run( messages=improved_prompt, - thread=writer_thread, + session=writer_session, ) logger.info(f"[Orchestration] Refined response: {refined_response.text}") diff --git a/python/samples/getting_started/durabletask/05_multi_agent_orchestration_concurrency/README.md b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/README.md similarity index 90% rename from python/samples/getting_started/durabletask/05_multi_agent_orchestration_concurrency/README.md rename to python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/README.md index 0edf244d78..e94602d822 100644 --- a/python/samples/getting_started/durabletask/05_multi_agent_orchestration_concurrency/README.md +++ b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/README.md @@ -7,7 +7,7 @@ This sample demonstrates how to host multiple agents and run them concurrently u - Running multiple specialized agents in parallel within an orchestration. - Using `OrchestrationAgentExecutor` to get `DurableAgentTask` objects for concurrent execution. - Aggregating results from multiple agents using `task.when_all()`. -- Creating separate conversation threads for independent agent contexts. +- Creating separate conversation sessions for independent agent contexts. ## Environment Setup @@ -20,7 +20,7 @@ With the environment setup, you can run the sample using the combined approach o **Option 1: Combined (Recommended for Testing)** ```bash -cd samples/getting_started/durabletask/05_multi_agent_orchestration_concurrency +cd samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency python sample.py ``` @@ -64,7 +64,7 @@ You can view the state of the orchestration in the Durable Task Scheduler dashbo 1. Open your browser and navigate to `http://localhost:8082` 2. In the dashboard, you can view: - The concurrent execution of both agents (PhysicistAgent and ChemistAgent) - - Separate conversation threads for each agent + - Separate conversation sessions for each agent - Parallel task execution and completion timing - Aggregated results from both agents diff --git a/python/samples/getting_started/durabletask/05_multi_agent_orchestration_concurrency/client.py b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/client.py similarity index 100% rename from python/samples/getting_started/durabletask/05_multi_agent_orchestration_concurrency/client.py rename to python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/client.py diff --git a/python/samples/getting_started/durabletask/05_multi_agent_orchestration_concurrency/requirements.txt b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/requirements.txt similarity index 100% rename from python/samples/getting_started/durabletask/05_multi_agent_orchestration_concurrency/requirements.txt rename to python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/requirements.txt diff --git a/python/samples/getting_started/durabletask/05_multi_agent_orchestration_concurrency/sample.py b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/sample.py similarity index 100% rename from python/samples/getting_started/durabletask/05_multi_agent_orchestration_concurrency/sample.py rename to python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/sample.py diff --git a/python/samples/getting_started/durabletask/05_multi_agent_orchestration_concurrency/worker.py b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/worker.py similarity index 90% rename from python/samples/getting_started/durabletask/05_multi_agent_orchestration_concurrency/worker.py rename to python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/worker.py index 8f045805f0..716355ec8b 100644 --- a/python/samples/getting_started/durabletask/05_multi_agent_orchestration_concurrency/worker.py +++ b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/worker.py @@ -18,7 +18,7 @@ import os from collections.abc import Generator from typing import Any -from agent_framework import AgentResponse, ChatAgent +from agent_framework import Agent, AgentResponse from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentOrchestrationContext, DurableAIAgentWorker from azure.identity import AzureCliCredential, DefaultAzureCredential from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker @@ -33,11 +33,11 @@ PHYSICIST_AGENT_NAME = "PhysicistAgent" CHEMIST_AGENT_NAME = "ChemistAgent" -def create_physicist_agent() -> "ChatAgent": +def create_physicist_agent() -> "Agent": """Create the Physicist agent using Azure OpenAI. Returns: - ChatAgent: The configured Physicist agent + Agent: The configured Physicist agent """ return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( name=PHYSICIST_AGENT_NAME, @@ -45,11 +45,11 @@ def create_physicist_agent() -> "ChatAgent": ) -def create_chemist_agent() -> "ChatAgent": +def create_chemist_agent() -> "Agent": """Create the Chemist agent using Azure OpenAI. Returns: - ChatAgent: The configured Chemist agent + Agent: The configured Chemist agent """ return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( name=CHEMIST_AGENT_NAME, @@ -80,15 +80,15 @@ def multi_agent_concurrent_orchestration(context: OrchestrationContext, prompt: physicist = agent_context.get_agent(PHYSICIST_AGENT_NAME) chemist = agent_context.get_agent(CHEMIST_AGENT_NAME) - # Create separate threads for each agent - physicist_thread = physicist.get_new_thread() - chemist_thread = chemist.get_new_thread() + # Create separate sessions for each agent + physicist_session = physicist.create_session() + chemist_session = chemist.create_session() - logger.debug(f"[Orchestration] Created threads - Physicist: {physicist_thread.session_id}, Chemist: {chemist_thread.session_id}") + logger.debug(f"[Orchestration] Created sessions - Physicist: {physicist_session.session_id}, Chemist: {chemist_session.session_id}") # Create tasks from agent.run() calls - these return DurableAgentTask instances - physicist_task = physicist.run(messages=str(prompt), thread=physicist_thread) - chemist_task = chemist.run(messages=str(prompt), thread=chemist_thread) + physicist_task = physicist.run(messages=str(prompt), session=physicist_session) + chemist_task = chemist.run(messages=str(prompt), session=chemist_session) logger.debug("[Orchestration] Created agent tasks, executing concurrently...") diff --git a/python/samples/getting_started/durabletask/06_multi_agent_orchestration_conditionals/README.md b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/README.md similarity index 97% rename from python/samples/getting_started/durabletask/06_multi_agent_orchestration_conditionals/README.md rename to python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/README.md index f6a40c087b..dcd4a617c3 100644 --- a/python/samples/getting_started/durabletask/06_multi_agent_orchestration_conditionals/README.md +++ b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/README.md @@ -21,7 +21,7 @@ With the environment setup, you can run the sample using the combined approach o **Option 1: Combined (Recommended for Testing)** ```bash -cd samples/getting_started/durabletask/06_multi_agent_orchestration_conditionals +cd samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals python sample.py ``` diff --git a/python/samples/getting_started/durabletask/06_multi_agent_orchestration_conditionals/client.py b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/client.py similarity index 100% rename from python/samples/getting_started/durabletask/06_multi_agent_orchestration_conditionals/client.py rename to python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/client.py diff --git a/python/samples/getting_started/durabletask/06_multi_agent_orchestration_conditionals/requirements.txt b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/requirements.txt similarity index 100% rename from python/samples/getting_started/durabletask/06_multi_agent_orchestration_conditionals/requirements.txt rename to python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/requirements.txt diff --git a/python/samples/getting_started/durabletask/06_multi_agent_orchestration_conditionals/sample.py b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/sample.py similarity index 100% rename from python/samples/getting_started/durabletask/06_multi_agent_orchestration_conditionals/sample.py rename to python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/sample.py diff --git a/python/samples/getting_started/durabletask/06_multi_agent_orchestration_conditionals/worker.py b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/worker.py similarity index 97% rename from python/samples/getting_started/durabletask/06_multi_agent_orchestration_conditionals/worker.py rename to python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/worker.py index 92b689d5cf..0016627cdc 100644 --- a/python/samples/getting_started/durabletask/06_multi_agent_orchestration_conditionals/worker.py +++ b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/worker.py @@ -18,7 +18,7 @@ import os from collections.abc import Generator from typing import Any, cast -from agent_framework import AgentResponse, ChatAgent +from agent_framework import Agent, AgentResponse from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentOrchestrationContext, DurableAIAgentWorker from azure.identity import AzureCliCredential, DefaultAzureCredential from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker @@ -51,11 +51,11 @@ class EmailPayload(BaseModel): email_content: str -def create_spam_agent() -> "ChatAgent": +def create_spam_agent() -> "Agent": """Create the Spam Detection agent using Azure OpenAI. Returns: - ChatAgent: The configured Spam Detection agent + Agent: The configured Spam Detection agent """ return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( name=SPAM_AGENT_NAME, @@ -63,11 +63,11 @@ def create_spam_agent() -> "ChatAgent": ) -def create_email_agent() -> "ChatAgent": +def create_email_agent() -> "Agent": """Create the Email Assistant agent using Azure OpenAI. Returns: - ChatAgent: The configured Email Assistant agent + Agent: The configured Email Assistant agent """ return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( name=EMAIL_AGENT_NAME, diff --git a/python/samples/getting_started/durabletask/07_single_agent_orchestration_hitl/README.md b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/README.md similarity index 95% rename from python/samples/getting_started/durabletask/07_single_agent_orchestration_hitl/README.md rename to python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/README.md index fbfe905d59..9f47d51009 100644 --- a/python/samples/getting_started/durabletask/07_single_agent_orchestration_hitl/README.md +++ b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/README.md @@ -23,7 +23,7 @@ With the environment setup, you can run the sample using the combined approach o **Option 1: Combined (Recommended for Testing)** ```bash -cd samples/getting_started/durabletask/07_single_agent_orchestration_hitl +cd samples/04-hosting/durabletask/07_single_agent_orchestration_hitl python sample.py ``` @@ -82,6 +82,6 @@ You can view the state of the WriterAgent and orchestration in the Durable Task 1. Open your browser and navigate to `http://localhost:8082` 2. In the dashboard, you can view: - Orchestration instance status and pending events - - WriterAgent entity state and conversation threads + - WriterAgent entity state and conversation sessions - Activity execution logs - External event history diff --git a/python/samples/getting_started/durabletask/07_single_agent_orchestration_hitl/client.py b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/client.py similarity index 100% rename from python/samples/getting_started/durabletask/07_single_agent_orchestration_hitl/client.py rename to python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/client.py diff --git a/python/samples/getting_started/durabletask/07_single_agent_orchestration_hitl/requirements.txt b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/requirements.txt similarity index 100% rename from python/samples/getting_started/durabletask/07_single_agent_orchestration_hitl/requirements.txt rename to python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/requirements.txt diff --git a/python/samples/getting_started/durabletask/07_single_agent_orchestration_hitl/sample.py b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/sample.py similarity index 100% rename from python/samples/getting_started/durabletask/07_single_agent_orchestration_hitl/sample.py rename to python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/sample.py diff --git a/python/samples/getting_started/durabletask/07_single_agent_orchestration_hitl/worker.py b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/worker.py similarity index 97% rename from python/samples/getting_started/durabletask/07_single_agent_orchestration_hitl/worker.py rename to python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/worker.py index db9a47002f..d90973ef1d 100644 --- a/python/samples/getting_started/durabletask/07_single_agent_orchestration_hitl/worker.py +++ b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/worker.py @@ -19,7 +19,7 @@ from collections.abc import Generator from datetime import timedelta from typing import Any, cast -from agent_framework import AgentResponse, ChatAgent +from agent_framework import Agent, AgentResponse from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentOrchestrationContext, DurableAIAgentWorker from azure.identity import AzureCliCredential, DefaultAzureCredential from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker @@ -54,11 +54,11 @@ class HumanApproval(BaseModel): feedback: str = "" -def create_writer_agent() -> "ChatAgent": +def create_writer_agent() -> "Agent": """Create the Writer agent using Azure OpenAI. Returns: - ChatAgent: The configured Writer agent + Agent: The configured Writer agent """ instructions = ( "You are a professional content writer who creates high-quality articles on various topics. " @@ -150,16 +150,16 @@ def content_generation_hitl_orchestration( # Get the writer agent writer = agent_context.get_agent(WRITER_AGENT_NAME) - writer_thread = writer.get_new_thread() + writer_session = writer.create_session() - logger.info(f"ThreadID: {writer_thread.session_id}") + logger.info(f"SessionID: {writer_session.session_id}") # Generate initial content logger.info("[Orchestration] Generating initial content...") initial_response: AgentResponse = yield writer.run( messages=f"Write a short article about '{payload.topic}'.", - thread=writer_thread, + session=writer_session, options={"response_format": GeneratedContent}, ) content = cast(GeneratedContent, initial_response.value) @@ -251,11 +251,11 @@ def content_generation_hitl_orchestration( logger.debug("[Orchestration] Regenerating content with feedback...") - logger.warning(f"Regenerating with ThreadID: {writer_thread.session_id}") + logger.warning(f"Regenerating with SessionID: {writer_session.session_id}") rewrite_response: AgentResponse = yield writer.run( messages=rewrite_prompt, - thread=writer_thread, + session=writer_session, options={"response_format": GeneratedContent}, ) rewritten_content = cast(GeneratedContent, rewrite_response.value) diff --git a/python/samples/getting_started/durabletask/README.md b/python/samples/04-hosting/durabletask/README.md similarity index 99% rename from python/samples/getting_started/durabletask/README.md rename to python/samples/04-hosting/durabletask/README.md index 8700380a14..fc38a1f19e 100644 --- a/python/samples/getting_started/durabletask/README.md +++ b/python/samples/04-hosting/durabletask/README.md @@ -106,7 +106,7 @@ $env:AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="your-deployment-name" Navigate to the sample directory and install dependencies. For example: ```bash -cd samples/getting_started/durabletask/01_single_agent +cd samples/04-hosting/durabletask/01_single_agent pip install -r requirements.txt ``` diff --git a/python/samples/demos/chatkit-integration/.gitignore b/python/samples/05-end-to-end/chatkit-integration/.gitignore similarity index 100% rename from python/samples/demos/chatkit-integration/.gitignore rename to python/samples/05-end-to-end/chatkit-integration/.gitignore diff --git a/python/samples/demos/chatkit-integration/README.md b/python/samples/05-end-to-end/chatkit-integration/README.md similarity index 98% rename from python/samples/demos/chatkit-integration/README.md rename to python/samples/05-end-to-end/chatkit-integration/README.md index 9636c4b190..692145196e 100644 --- a/python/samples/demos/chatkit-integration/README.md +++ b/python/samples/05-end-to-end/chatkit-integration/README.md @@ -38,7 +38,7 @@ graph TB subgraph Integration["Agent Framework Integration"] Converter[ThreadItemConverter] Streamer[stream_agent_response] - Agent[ChatAgent] + Agent[Agent] end Widgets[Widget Rendering
render_weather_widget
render_city_selector_widget] @@ -61,7 +61,7 @@ graph TB AttStore -.->|save files| Files AttStore -.->|save metadata| SQLite - Converter -->|ChatMessage array| Agent + Converter -->|Message array| Agent Agent -->|AgentResponseUpdate| Streamer Streamer -->|ThreadStreamEvent| ChatKit @@ -88,7 +88,7 @@ The sample implements a ChatKit server using the `ChatKitServer` base class from - **`WeatherChatKitServer`**: Custom ChatKit server implementation that: - Extends `ChatKitServer[dict[str, Any]]` - - Uses Agent Framework's `ChatAgent` with Azure OpenAI + - Uses Agent Framework's `Agent` with Azure OpenAI - Converts ChatKit messages to Agent Framework format using `ThreadItemConverter` - Streams responses back to ChatKit using `stream_agent_response` - Creates and streams interactive widgets after agent responses @@ -168,7 +168,7 @@ For **production deployment**: 1. **Install Python packages:** ```bash -cd python/samples/demos/chatkit-integration +cd python/samples/05-end-to-end/chatkit-integration pip install agent-framework-chatkit fastapi uvicorn azure-identity ``` diff --git a/python/samples/05-end-to-end/chatkit-integration/__init__.py b/python/samples/05-end-to-end/chatkit-integration/__init__.py new file mode 100644 index 0000000000..2a50eae894 --- /dev/null +++ b/python/samples/05-end-to-end/chatkit-integration/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/samples/demos/chatkit-integration/app.py b/python/samples/05-end-to-end/chatkit-integration/app.py similarity index 96% rename from python/samples/demos/chatkit-integration/app.py rename to python/samples/05-end-to-end/chatkit-integration/app.py index 44a2e125f6..a22698b085 100644 --- a/python/samples/demos/chatkit-integration/app.py +++ b/python/samples/05-end-to-end/chatkit-integration/app.py @@ -28,7 +28,7 @@ from typing import Annotated, Any import uvicorn # Agent Framework imports -from agent_framework import AgentResponseUpdate, ChatAgent, ChatMessage, tool +from agent_framework import Agent, AgentResponseUpdate, FunctionResultContent, Message, Role, tool from agent_framework.azure import AzureOpenAIChatClient # Agent Framework ChatKit integration @@ -141,7 +141,7 @@ async def stream_widget( yield ThreadItemDoneEvent(type="thread.item.done", item=widget_item) -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -217,8 +217,8 @@ class WeatherChatKitServer(ChatKitServer[dict[str, Any]]): # Create Agent Framework agent with Azure OpenAI # For authentication, run `az login` command in terminal try: - self.weather_agent = ChatAgent( - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), + self.weather_agent = Agent( + client=AzureOpenAIChatClient(credential=AzureCliCredential()), instructions=( "You are a helpful weather assistant with image analysis capabilities. " "You can provide weather information for any location, tell the current time, " @@ -290,8 +290,8 @@ class WeatherChatKitServer(ChatKitServer[dict[str, Any]]): conversation_context = "\n".join(user_messages[:3]) title_prompt = [ - ChatMessage( - role="user", + Message( + role=Role.USER, text=( f"Generate a very short, concise title (max 40 characters) for a conversation " f"that starts with:\n\n{conversation_context}\n\n" @@ -301,7 +301,7 @@ class WeatherChatKitServer(ChatKitServer[dict[str, Any]]): ] # Use the chat client directly for a quick, lightweight call - response = await self.weather_agent.chat_client.get_response( + response = await self.weather_agent.client.get_response( messages=title_prompt, options={ "temperature": 0.3, @@ -342,6 +342,7 @@ class WeatherChatKitServer(ChatKitServer[dict[str, Any]]): runs the agent, converts the response back to ChatKit events using stream_agent_response, and creates interactive weather widgets when weather data is queried. """ + from agent_framework import FunctionResultContent if input_user_message is None: logger.debug("Received None user message, skipping") @@ -384,7 +385,7 @@ class WeatherChatKitServer(ChatKitServer[dict[str, Any]]): # Check for function results in the update if update.contents: for content in update.contents: - if content.type == "function_result": + if isinstance(content, FunctionResultContent): result = content.result # Check if it's a WeatherResponse (string subclass with weather_data attribute) @@ -467,7 +468,7 @@ class WeatherChatKitServer(ChatKitServer[dict[str, Any]]): weather_data: WeatherData | None = None # Create an agent message asking about the weather - agent_messages = [ChatMessage(role="user", text=f"What's the weather in {city_label}?")] + agent_messages = [Message(role=Role.USER, text=f"What's the weather in {city_label}?")] logger.debug(f"Processing weather query: {agent_messages[0].text}") @@ -481,7 +482,7 @@ class WeatherChatKitServer(ChatKitServer[dict[str, Any]]): # Check for function results in the update if update.contents: for content in update.contents: - if content.type == "function_result": + if isinstance(content, FunctionResultContent): result = content.result # Check if it's a WeatherResponse (string subclass with weather_data attribute) @@ -572,7 +573,7 @@ async def chatkit_endpoint(request: Request): @app.post("/upload/{attachment_id}") -async def upload_file(attachment_id: str, file: Annotated[UploadFile, File()]): +async def upload_file(attachment_id: str, file: UploadFile = File(...)): # noqa: B008 """Handle file upload for two-phase upload. The client POSTs the file bytes here after creating the attachment @@ -594,7 +595,7 @@ async def upload_file(attachment_id: str, file: Annotated[UploadFile, File()]): attachment = await data_store.load_attachment(attachment_id, {"user_id": DEFAULT_USER_ID}) # Clear the upload_url since upload is complete - attachment.upload_url = None # type: ignore[union-attr] + attachment.upload_url = None # Save the updated attachment back to the store await data_store.save_attachment(attachment, {"user_id": DEFAULT_USER_ID}) diff --git a/python/samples/demos/chatkit-integration/attachment_store.py b/python/samples/05-end-to-end/chatkit-integration/attachment_store.py similarity index 100% rename from python/samples/demos/chatkit-integration/attachment_store.py rename to python/samples/05-end-to-end/chatkit-integration/attachment_store.py diff --git a/python/samples/demos/chatkit-integration/frontend/index.html b/python/samples/05-end-to-end/chatkit-integration/frontend/index.html similarity index 100% rename from python/samples/demos/chatkit-integration/frontend/index.html rename to python/samples/05-end-to-end/chatkit-integration/frontend/index.html diff --git a/python/samples/demos/chatkit-integration/frontend/package-lock.json b/python/samples/05-end-to-end/chatkit-integration/frontend/package-lock.json similarity index 100% rename from python/samples/demos/chatkit-integration/frontend/package-lock.json rename to python/samples/05-end-to-end/chatkit-integration/frontend/package-lock.json diff --git a/python/samples/demos/chatkit-integration/frontend/package.json b/python/samples/05-end-to-end/chatkit-integration/frontend/package.json similarity index 100% rename from python/samples/demos/chatkit-integration/frontend/package.json rename to python/samples/05-end-to-end/chatkit-integration/frontend/package.json diff --git a/python/samples/demos/chatkit-integration/frontend/src/App.tsx b/python/samples/05-end-to-end/chatkit-integration/frontend/src/App.tsx similarity index 100% rename from python/samples/demos/chatkit-integration/frontend/src/App.tsx rename to python/samples/05-end-to-end/chatkit-integration/frontend/src/App.tsx diff --git a/python/samples/demos/chatkit-integration/frontend/src/main.tsx b/python/samples/05-end-to-end/chatkit-integration/frontend/src/main.tsx similarity index 100% rename from python/samples/demos/chatkit-integration/frontend/src/main.tsx rename to python/samples/05-end-to-end/chatkit-integration/frontend/src/main.tsx diff --git a/python/samples/demos/chatkit-integration/frontend/src/vite-env.d.ts b/python/samples/05-end-to-end/chatkit-integration/frontend/src/vite-env.d.ts similarity index 100% rename from python/samples/demos/chatkit-integration/frontend/src/vite-env.d.ts rename to python/samples/05-end-to-end/chatkit-integration/frontend/src/vite-env.d.ts diff --git a/python/samples/demos/chatkit-integration/frontend/tsconfig.json b/python/samples/05-end-to-end/chatkit-integration/frontend/tsconfig.json similarity index 100% rename from python/samples/demos/chatkit-integration/frontend/tsconfig.json rename to python/samples/05-end-to-end/chatkit-integration/frontend/tsconfig.json diff --git a/python/samples/demos/chatkit-integration/frontend/tsconfig.node.json b/python/samples/05-end-to-end/chatkit-integration/frontend/tsconfig.node.json similarity index 100% rename from python/samples/demos/chatkit-integration/frontend/tsconfig.node.json rename to python/samples/05-end-to-end/chatkit-integration/frontend/tsconfig.node.json diff --git a/python/samples/demos/chatkit-integration/frontend/vite.config.ts b/python/samples/05-end-to-end/chatkit-integration/frontend/vite.config.ts similarity index 100% rename from python/samples/demos/chatkit-integration/frontend/vite.config.ts rename to python/samples/05-end-to-end/chatkit-integration/frontend/vite.config.ts diff --git a/python/samples/demos/chatkit-integration/store.py b/python/samples/05-end-to-end/chatkit-integration/store.py similarity index 100% rename from python/samples/demos/chatkit-integration/store.py rename to python/samples/05-end-to-end/chatkit-integration/store.py diff --git a/python/samples/demos/chatkit-integration/weather_widget.py b/python/samples/05-end-to-end/chatkit-integration/weather_widget.py similarity index 100% rename from python/samples/demos/chatkit-integration/weather_widget.py rename to python/samples/05-end-to-end/chatkit-integration/weather_widget.py diff --git a/python/samples/getting_started/evaluation/red_teaming/.env.example b/python/samples/05-end-to-end/evaluation/red_teaming/.env.example similarity index 100% rename from python/samples/getting_started/evaluation/red_teaming/.env.example rename to python/samples/05-end-to-end/evaluation/red_teaming/.env.example diff --git a/python/samples/getting_started/evaluation/red_teaming/README.md b/python/samples/05-end-to-end/evaluation/red_teaming/README.md similarity index 100% rename from python/samples/getting_started/evaluation/red_teaming/README.md rename to python/samples/05-end-to-end/evaluation/red_teaming/README.md diff --git a/python/samples/getting_started/evaluation/red_teaming/red_team_agent_sample.py b/python/samples/05-end-to-end/evaluation/red_teaming/red_team_agent_sample.py similarity index 100% rename from python/samples/getting_started/evaluation/red_teaming/red_team_agent_sample.py rename to python/samples/05-end-to-end/evaluation/red_teaming/red_team_agent_sample.py diff --git a/python/samples/getting_started/evaluation/self_reflection/.env.example b/python/samples/05-end-to-end/evaluation/self_reflection/.env.example similarity index 100% rename from python/samples/getting_started/evaluation/self_reflection/.env.example rename to python/samples/05-end-to-end/evaluation/self_reflection/.env.example diff --git a/python/samples/getting_started/evaluation/self_reflection/README.md b/python/samples/05-end-to-end/evaluation/self_reflection/README.md similarity index 100% rename from python/samples/getting_started/evaluation/self_reflection/README.md rename to python/samples/05-end-to-end/evaluation/self_reflection/README.md diff --git a/python/samples/getting_started/evaluation/self_reflection/resources/suboptimal_groundedness_prompts.jsonl b/python/samples/05-end-to-end/evaluation/self_reflection/resources/suboptimal_groundedness_prompts.jsonl similarity index 100% rename from python/samples/getting_started/evaluation/self_reflection/resources/suboptimal_groundedness_prompts.jsonl rename to python/samples/05-end-to-end/evaluation/self_reflection/resources/suboptimal_groundedness_prompts.jsonl diff --git a/python/samples/getting_started/evaluation/self_reflection/self_reflection.py b/python/samples/05-end-to-end/evaluation/self_reflection/self_reflection.py similarity index 83% rename from python/samples/getting_started/evaluation/self_reflection/self_reflection.py rename to python/samples/05-end-to-end/evaluation/self_reflection/self_reflection.py index 931d292dd1..6a21059a93 100644 --- a/python/samples/getting_started/evaluation/self_reflection/self_reflection.py +++ b/python/samples/05-end-to-end/evaluation/self_reflection/self_reflection.py @@ -5,7 +5,7 @@ # ] # /// # Run with any PEP 723 compatible runner, e.g.: -# uv run samples/getting_started/evaluation/self_reflection/self_reflection.py +# uv run samples/05-end-to-end/evaluation/self_reflection/self_reflection.py # Copyright (c) Microsoft. All rights reserved. # type: ignore @@ -17,7 +17,7 @@ from typing import Any import openai import pandas as pd -from agent_framework import ChatAgent, ChatMessage +from agent_framework import Agent, Message from agent_framework.azure import AzureOpenAIChatClient from azure.ai.projects import AIProjectClient from azure.identity import AzureCliCredential @@ -80,13 +80,15 @@ def create_eval(client: openai.OpenAI, judge_model: str) -> openai.types.EvalCre "include_sample_schema": True, }) - testing_criteria = [{ - "type": "azure_ai_evaluator", - "name": "groundedness", - "evaluator_name": "builtin.groundedness", - "data_mapping": {"query": "{{item.query}}", "response": "{{item.response}}", "context": "{{item.context}}"}, - "initialization_parameters": {"deployment_name": f"{judge_model}"}, - }] + testing_criteria = [ + { + "type": "azure_ai_evaluator", + "name": "groundedness", + "evaluator_name": "builtin.groundedness", + "data_mapping": {"query": "{{item.query}}", "response": "{{item.response}}", "context": "{{item.context}}"}, + "initialization_parameters": {"deployment_name": f"{judge_model}"}, + } + ] return client.evals.create( name="Eval", @@ -96,11 +98,11 @@ def create_eval(client: openai.OpenAI, judge_model: str) -> openai.types.EvalCre def run_eval( - client: openai.OpenAI, - eval_object: openai.types.EvalCreateResponse, - query: str, - response: str, - context: str, + client: openai.OpenAI, + eval_object: openai.types.EvalCreateResponse, + query: str, + response: str, + context: str, ): eval_run_object = client.evals.runs.create( eval_id=eval_object.id, @@ -129,7 +131,9 @@ def run_eval( for _ in range(0, MAX_RETRY): run = client.evals.runs.retrieve(run_id=eval_run_response.id, eval_id=eval_object.id) if run.status == "failed": - print(f"Eval run failed. Run ID: {run.id}, Status: {run.status}, Error: {getattr(run, 'error', 'Unknown error')}") + print( + f"Eval run failed. Run ID: {run.id}, Status: {run.status}, Error: {getattr(run, 'error', 'Unknown error')}" + ) continue if run.status == "completed": return list(client.evals.runs.output_items.list(run_id=run.id, eval_id=eval_object.id)) @@ -142,7 +146,7 @@ def run_eval( async def execute_query_with_self_reflection( *, client: openai.OpenAI, - agent: ChatAgent, + agent: Agent, eval_object: openai.types.EvalCreateResponse, full_user_query: str, context: str, @@ -152,7 +156,7 @@ async def execute_query_with_self_reflection( Execute a query with self-reflection loop. Args: - agent: ChatAgent instance to use for generating responses + agent: Agent instance to use for generating responses full_user_query: Complete prompt including system prompt, user request, and context context: Context document for groundedness evaluation evaluator: Groundedness evaluator function @@ -170,7 +174,7 @@ async def execute_query_with_self_reflection( - total_groundedness_eval_time: Time spent on evaluations (seconds) - total_end_to_end_time: Total execution time (seconds) """ - messages = [ChatMessage("user", [full_user_query])] + messages = [Message("user", [full_user_query])] best_score = 0 max_score = 5 @@ -201,7 +205,7 @@ async def execute_query_with_self_reflection( continue score = eval_run_output_items[0].results[0].score end_time_eval = time.time() - total_groundedness_eval_time += (end_time_eval - start_time_eval) + total_groundedness_eval_time += end_time_eval - start_time_eval # Store score in structured format iteration_scores.append(score) @@ -223,14 +227,14 @@ async def execute_query_with_self_reflection( print(f" → No improvement (score: {score}/{max_score}). Trying again...") # Add to conversation history - messages.append(ChatMessage("assistant", [agent_response])) + messages.append(Message("assistant", [agent_response])) # Request improvement reflection_prompt = ( f"The groundedness score of your response is {score}/{max_score}. " f"Reflect on your answer and improve it to get the maximum score of {max_score} " ) - messages.append(ChatMessage("user", [reflection_prompt])) + messages.append(Message("user", [reflection_prompt])) end_time = time.time() latency = end_time - start_time @@ -259,7 +263,7 @@ async def run_self_reflection_batch( judge_model: str = DEFAULT_JUDGE_MODEL, max_self_reflections: int = 3, env_file: str | None = None, - limit: int | None = None + limit: int | None = None, ): """ Run self-reflection on a batch of prompts. @@ -298,8 +302,15 @@ async def run_self_reflection_batch( print(f"Processing first {len(df)} prompts (limited by -n {limit})") # Validate required columns - required_columns = ["system_instruction", "user_request", "context_document", - "full_prompt", "domain", "type", "high_level_type"] + required_columns = [ + "system_instruction", + "user_request", + "context_document", + "full_prompt", + "domain", + "type", + "high_level_type", + ] missing_columns = [col for col in required_columns if col not in df.columns] if missing_columns: raise ValueError(f"Input file missing required columns: {missing_columns}") @@ -341,13 +352,15 @@ async def run_self_reflection_batch( "agent_response_model": agent_model, "agent_response": result, "error": None, - "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), } results.append(result_data) - print(f" ✓ Completed with score: {result['best_response_score']}/5 " - f"(best at iteration {result['best_iteration']}/{result['num_retries']}, " - f"time: {result['total_end_to_end_time']:.1f}s)\n") + print( + f" ✓ Completed with score: {result['best_response_score']}/5 " + f"(best at iteration {result['best_iteration']}/{result['num_retries']}, " + f"time: {result['total_end_to_end_time']:.1f}s)\n" + ) except Exception as e: print(f" ✗ Error: {str(e)}\n") @@ -365,7 +378,7 @@ async def run_self_reflection_batch( "agent_response_model": agent_model, "agent_response": None, "error": str(e), - "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), } results.append(error_data) continue @@ -391,14 +404,20 @@ async def run_self_reflection_batch( # Extract scores and iteration data from nested agent_response dict best_scores = [r["best_response_score"] for r in successful_runs["agent_response"] if r is not None] iterations = [r["best_iteration"] for r in successful_runs["agent_response"] if r is not None] - iteration_scores_list = [r["iteration_scores"] for r in successful_runs["agent_response"] if r is not None and "iteration_scores" in r] + iteration_scores_list = [ + r["iteration_scores"] + for r in successful_runs["agent_response"] + if r is not None and "iteration_scores" in r + ] if best_scores: avg_score = sum(best_scores) / len(best_scores) perfect_scores = sum(1 for s in best_scores if s == 5) print("\nGroundedness Scores:") print(f" Average best score: {avg_score:.2f}/5") - print(f" Perfect scores (5/5): {perfect_scores}/{len(best_scores)} ({100 * perfect_scores / len(best_scores):.1f}%)") + print( + f" Perfect scores (5/5): {perfect_scores}/{len(best_scores)} ({100 * perfect_scores / len(best_scores):.1f}%)" + ) # Calculate improvement metrics if iteration_scores_list: @@ -416,7 +435,9 @@ async def run_self_reflection_batch( print(f" Average first score: {avg_first_score:.2f}/5") print(f" Average final score: {avg_last_score:.2f}/5") print(f" Average improvement: +{avg_improvement:.2f}") - print(f" Responses that improved: {improved_count}/{len(improvements)} ({100 * improved_count / len(improvements):.1f}%)") + print( + f" Responses that improved: {improved_count}/{len(improvements)} ({100 * improved_count / len(improvements):.1f}%)" + ) # Show iteration statistics if iterations: @@ -432,13 +453,29 @@ async def run_self_reflection_batch( async def main(): """CLI entry point.""" parser = argparse.ArgumentParser(description="Run self-reflection loop on LLM prompts with groundedness evaluation") - parser.add_argument("--input", "-i", default="resources/suboptimal_groundedness_prompts.jsonl", help="Input JSONL file with prompts") + parser.add_argument( + "--input", "-i", default="resources/suboptimal_groundedness_prompts.jsonl", help="Input JSONL file with prompts" + ) parser.add_argument("--output", "-o", default="resources/results.jsonl", help="Output JSONL file for results") - parser.add_argument("--agent-model", "-m", default=DEFAULT_AGENT_MODEL, help=f"Agent model deployment name (default: {DEFAULT_AGENT_MODEL})") - parser.add_argument("--judge-model", "-e", default=DEFAULT_JUDGE_MODEL, help=f"Judge model deployment name (default: {DEFAULT_JUDGE_MODEL})") - parser.add_argument("--max-reflections", type=int, default=3, help="Maximum number of self-reflection iterations (default: 3)") + parser.add_argument( + "--agent-model", + "-m", + default=DEFAULT_AGENT_MODEL, + help=f"Agent model deployment name (default: {DEFAULT_AGENT_MODEL})", + ) + parser.add_argument( + "--judge-model", + "-e", + default=DEFAULT_JUDGE_MODEL, + help=f"Judge model deployment name (default: {DEFAULT_JUDGE_MODEL})", + ) + parser.add_argument( + "--max-reflections", type=int, default=3, help="Maximum number of self-reflection iterations (default: 3)" + ) parser.add_argument("--env-file", help="Path to .env file with Azure OpenAI credentials") - parser.add_argument("--limit", "-n", type=int, default=None, help="Process only the first N prompts from the input file") + parser.add_argument( + "--limit", "-n", type=int, default=None, help="Process only the first N prompts from the input file" + ) args = parser.parse_args() @@ -451,7 +488,7 @@ async def main(): judge_model=args.judge_model, max_self_reflections=args.max_reflections, env_file=args.env_file, - limit=args.limit + limit=args.limit, ) print("\n✓ Processing complete!") diff --git a/python/samples/demos/hosted_agents/agent_with_hosted_mcp/Dockerfile b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/Dockerfile similarity index 100% rename from python/samples/demos/hosted_agents/agent_with_hosted_mcp/Dockerfile rename to python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/Dockerfile diff --git a/python/samples/demos/hosted_agents/agent_with_hosted_mcp/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml similarity index 100% rename from python/samples/demos/hosted_agents/agent_with_hosted_mcp/agent.yaml rename to python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml diff --git a/python/samples/demos/hosted_agents/agent_with_hosted_mcp/main.py b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py similarity index 76% rename from python/samples/demos/hosted_agents/agent_with_hosted_mcp/main.py rename to python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py index 49f75a6df4..3118addc5b 100644 --- a/python/samples/demos/hosted_agents/agent_with_hosted_mcp/main.py +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py @@ -1,20 +1,23 @@ # Copyright (c) Microsoft. All rights reserved. -from agent_framework import HostedMCPTool from agent_framework.azure import AzureOpenAIChatClient from azure.ai.agentserver.agentframework import from_agent_framework # pyright: ignore[reportUnknownVariableType] from azure.identity import DefaultAzureCredential def main(): + # Create MCP tool configuration as dict + mcp_tool = { + "type": "mcp", + "server_label": "Microsoft_Learn_MCP", + "server_url": "https://learn.microsoft.com/api/mcp", + } + # Create an Agent using the Azure OpenAI Chat Client with a MCP Tool that connects to Microsoft Learn MCP agent = AzureOpenAIChatClient(credential=DefaultAzureCredential()).as_agent( name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=HostedMCPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - ), + tools=mcp_tool, ) # Run the agent as a hosted agent diff --git a/python/samples/demos/hosted_agents/agent_with_hosted_mcp/requirements.txt b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/requirements.txt similarity index 100% rename from python/samples/demos/hosted_agents/agent_with_hosted_mcp/requirements.txt rename to python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/requirements.txt diff --git a/python/samples/demos/hosted_agents/agent_with_text_search_rag/Dockerfile b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/Dockerfile similarity index 100% rename from python/samples/demos/hosted_agents/agent_with_text_search_rag/Dockerfile rename to python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/Dockerfile diff --git a/python/samples/demos/hosted_agents/agent_with_text_search_rag/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml similarity index 100% rename from python/samples/demos/hosted_agents/agent_with_text_search_rag/agent.yaml rename to python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml diff --git a/python/samples/demos/hosted_agents/agent_with_text_search_rag/main.py b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py similarity index 82% rename from python/samples/demos/hosted_agents/agent_with_text_search_rag/main.py rename to python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py index 0c0660ceb0..e53430ec16 100644 --- a/python/samples/demos/hosted_agents/agent_with_text_search_rag/main.py +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py @@ -2,11 +2,10 @@ import json import sys -from collections.abc import MutableSequence from dataclasses import dataclass from typing import Any -from agent_framework import ChatMessage, Context, ContextProvider +from agent_framework import AgentSession, BaseContextProvider, Message, SessionContext from agent_framework.azure import AzureOpenAIChatClient from azure.ai.agentserver.agentframework import from_agent_framework # pyright: ignore[reportUnknownVariableType] from azure.identity import DefaultAzureCredential @@ -24,19 +23,30 @@ class TextSearchResult: text: str -class TextSearchContextProvider(ContextProvider): +class TextSearchContextProvider(BaseContextProvider): """A simple context provider that simulates text search results based on keywords in the user's message.""" - def _get_most_recent_message(self, messages: ChatMessage | MutableSequence[ChatMessage]) -> ChatMessage: + def __init__(self): + super().__init__("text-search") + + def _get_most_recent_message(self, messages: list[Message]) -> Message: """Helper method to extract the most recent message from the input.""" - if isinstance(messages, ChatMessage): - return messages if messages: return messages[-1] raise ValueError("No messages provided") @override - async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context: + async def before_run( + self, + *, + agent: Any, + session: AgentSession | None, + context: SessionContext, + state: dict[str, Any], + ) -> None: + messages = context.get_messages() + if not messages: + return message = self._get_most_recent_message(messages) query = message.text.lower() @@ -80,14 +90,15 @@ class TextSearchContextProvider(ContextProvider): ) if not results: - return Context() + return - return Context( - messages=[ - ChatMessage( + context.extend_messages( + self.source_id, + [ + Message( role="user", text="\n\n".join(json.dumps(result.__dict__, indent=2) for result in results) ) - ] + ], ) @@ -99,7 +110,7 @@ def main(): "You are a helpful support specialist for Contoso Outdoors. " "Answer questions using the provided context and cite the source document when available." ), - context_provider=TextSearchContextProvider(), + context_providers=[TextSearchContextProvider()], ) # Run the agent as a hosted agent diff --git a/python/samples/demos/hosted_agents/agent_with_text_search_rag/requirements.txt b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/requirements.txt similarity index 100% rename from python/samples/demos/hosted_agents/agent_with_text_search_rag/requirements.txt rename to python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/requirements.txt diff --git a/python/samples/demos/hosted_agents/agents_in_workflow/Dockerfile b/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/Dockerfile similarity index 100% rename from python/samples/demos/hosted_agents/agents_in_workflow/Dockerfile rename to python/samples/05-end-to-end/hosted_agents/agents_in_workflow/Dockerfile diff --git a/python/samples/demos/hosted_agents/agents_in_workflow/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml similarity index 100% rename from python/samples/demos/hosted_agents/agents_in_workflow/agent.yaml rename to python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml diff --git a/python/samples/demos/hosted_agents/agents_in_workflow/main.py b/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/main.py similarity index 100% rename from python/samples/demos/hosted_agents/agents_in_workflow/main.py rename to python/samples/05-end-to-end/hosted_agents/agents_in_workflow/main.py diff --git a/python/samples/demos/hosted_agents/agents_in_workflow/requirements.txt b/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/requirements.txt similarity index 100% rename from python/samples/demos/hosted_agents/agents_in_workflow/requirements.txt rename to python/samples/05-end-to-end/hosted_agents/agents_in_workflow/requirements.txt diff --git a/python/samples/demos/m365-agent/.env.example b/python/samples/05-end-to-end/m365-agent/.env.example similarity index 100% rename from python/samples/demos/m365-agent/.env.example rename to python/samples/05-end-to-end/m365-agent/.env.example diff --git a/python/samples/demos/m365-agent/README.md b/python/samples/05-end-to-end/m365-agent/README.md similarity index 100% rename from python/samples/demos/m365-agent/README.md rename to python/samples/05-end-to-end/m365-agent/README.md diff --git a/python/samples/demos/m365-agent/m365_agent_demo/app.py b/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py similarity index 96% rename from python/samples/demos/m365-agent/m365_agent_demo/app.py rename to python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py index 212941efa7..a33a487b34 100644 --- a/python/samples/demos/m365-agent/m365_agent_demo/app.py +++ b/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py @@ -18,7 +18,7 @@ from dataclasses import dataclass from random import randint from typing import Annotated -from agent_framework import ChatAgent, tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIChatClient from aiohttp import web from aiohttp.web_middlewares import middleware @@ -79,7 +79,7 @@ def load_app_config() -> AppConfig: return AppConfig(use_anonymous_mode=use_anonymous_mode, port=port, agents_sdk_config=agents_sdk_config) -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -95,7 +95,7 @@ def get_weather( return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." -def build_agent() -> ChatAgent: +def build_agent() -> Agent: """Create and return the chat agent instance with weather tool registered.""" return OpenAIChatClient().as_agent( name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather diff --git a/python/samples/getting_started/purview_agent/README.md b/python/samples/05-end-to-end/purview_agent/README.md similarity index 96% rename from python/samples/getting_started/purview_agent/README.md rename to python/samples/05-end-to-end/purview_agent/README.md index 8982a68830..3d13478616 100644 --- a/python/samples/getting_started/purview_agent/README.md +++ b/python/samples/05-end-to-end/purview_agent/README.md @@ -1,6 +1,6 @@ ## Purview Policy Enforcement Sample (Python) -This getting-started sample shows how to attach Microsoft Purview policy evaluation to an Agent Framework `ChatAgent` using the **middleware** approach. +This getting-started sample shows how to attach Microsoft Purview policy evaluation to an Agent Framework `Agent` using the **middleware** approach. **What this sample demonstrates:** 1. Configure an Azure OpenAI chat client @@ -54,7 +54,7 @@ Certificate steps (summary): create / register entra app, generate certificate, From repo root: ```powershell -cd python/samples/getting_started/purview_agent +cd python/samples/05-end-to-end/purview_agent python sample_purview_agent.py ``` @@ -99,8 +99,8 @@ Prompt blocks set a system-level message: `Prompt blocked by policy` and termina ### Agent Middleware Injection ```python -agent = ChatAgent( - chat_client=chat_client, +agent = Agent( + client=client, instructions="You are good at telling jokes.", name="Joker", middleware=[ diff --git a/python/samples/getting_started/purview_agent/sample_purview_agent.py b/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py similarity index 89% rename from python/samples/getting_started/purview_agent/sample_purview_agent.py rename to python/samples/05-end-to-end/purview_agent/sample_purview_agent.py index b5231c2a5f..0a5e251ae4 100644 --- a/python/samples/getting_started/purview_agent/sample_purview_agent.py +++ b/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py @@ -25,7 +25,7 @@ import asyncio import os from typing import Any -from agent_framework import AgentResponse, ChatAgent, ChatMessage +from agent_framework import Agent, AgentResponse, Message from agent_framework.azure import AzureOpenAIChatClient from agent_framework.microsoft import ( PurviewChatPolicyMiddleware, @@ -141,7 +141,7 @@ async def run_with_agent_middleware() -> None: deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") - chat_client = AzureOpenAIChatClient(deployment_name=deployment, endpoint=endpoint, credential=AzureCliCredential()) + client = AzureOpenAIChatClient(deployment_name=deployment, endpoint=endpoint, credential=AzureCliCredential()) purview_agent_middleware = PurviewPolicyMiddleware( build_credential(), @@ -150,8 +150,8 @@ async def run_with_agent_middleware() -> None: ), ) - agent = ChatAgent( - chat_client=chat_client, + agent = Agent( + client=client, instructions=JOKER_INSTRUCTIONS, name=JOKER_NAME, middleware=[purview_agent_middleware], @@ -159,12 +159,12 @@ async def run_with_agent_middleware() -> None: print("-- Agent MiddlewareTypes Path --") first: AgentResponse = await agent.run( - ChatMessage("user", ["Tell me a joke about a pirate."], additional_properties={"user_id": user_id}) + Message("user", ["Tell me a joke about a pirate."], additional_properties={"user_id": user_id}) ) print("First response (agent middleware):\n", first) second: AgentResponse = await agent.run( - ChatMessage( + Message( role="user", text="That was funny. Tell me another one.", additional_properties={"user_id": user_id} ) ) @@ -180,7 +180,7 @@ async def run_with_chat_middleware() -> None: deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", default="gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") - chat_client = AzureOpenAIChatClient( + client = AzureOpenAIChatClient( deployment_name=deployment, endpoint=endpoint, credential=AzureCliCredential(), @@ -194,15 +194,15 @@ async def run_with_chat_middleware() -> None: ], ) - agent = ChatAgent( - chat_client=chat_client, + agent = Agent( + client=client, instructions=JOKER_INSTRUCTIONS, name=JOKER_NAME, ) print("-- Chat MiddlewareTypes Path --") first: AgentResponse = await agent.run( - ChatMessage( + Message( role="user", text="Give me a short clean joke.", additional_properties={"user_id": user_id}, @@ -211,7 +211,7 @@ async def run_with_chat_middleware() -> None: print("First response (chat middleware):\n", first) second: AgentResponse = await agent.run( - ChatMessage( + Message( role="user", text="One more please.", additional_properties={"user_id": user_id}, @@ -229,7 +229,7 @@ async def run_with_custom_cache_provider() -> None: deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") - chat_client = AzureOpenAIChatClient(deployment_name=deployment, endpoint=endpoint, credential=AzureCliCredential()) + client = AzureOpenAIChatClient(deployment_name=deployment, endpoint=endpoint, credential=AzureCliCredential()) custom_cache = SimpleDictCacheProvider() @@ -241,8 +241,8 @@ async def run_with_custom_cache_provider() -> None: cache_provider=custom_cache, ) - agent = ChatAgent( - chat_client=chat_client, + agent = Agent( + client=client, instructions=JOKER_INSTRUCTIONS, name=JOKER_NAME, middleware=[purview_agent_middleware], @@ -252,14 +252,14 @@ async def run_with_custom_cache_provider() -> None: print("Using SimpleDictCacheProvider") first: AgentResponse = await agent.run( - ChatMessage( + Message( role="user", text="Tell me a joke about a programmer.", additional_properties={"user_id": user_id} ) ) print("First response (custom provider):\n", first) second: AgentResponse = await agent.run( - ChatMessage("user", ["That's hilarious! One more?"], additional_properties={"user_id": user_id}) + Message("user", ["That's hilarious! One more?"], additional_properties={"user_id": user_id}) ) print("Second response (custom provider):\n", second) @@ -271,7 +271,7 @@ async def run_with_custom_cache_provider() -> None: deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") - chat_client = AzureOpenAIChatClient(deployment_name=deployment, endpoint=endpoint, credential=AzureCliCredential()) + client = AzureOpenAIChatClient(deployment_name=deployment, endpoint=endpoint, credential=AzureCliCredential()) # No cache_provider specified - uses default InMemoryCacheProvider purview_agent_middleware = PurviewPolicyMiddleware( @@ -283,8 +283,8 @@ async def run_with_custom_cache_provider() -> None: ), ) - agent = ChatAgent( - chat_client=chat_client, + agent = Agent( + client=client, instructions=JOKER_INSTRUCTIONS, name=JOKER_NAME, middleware=[purview_agent_middleware], @@ -294,12 +294,12 @@ async def run_with_custom_cache_provider() -> None: print("Using default InMemoryCacheProvider with settings-based configuration") first: AgentResponse = await agent.run( - ChatMessage("user", ["Tell me a joke about AI."], additional_properties={"user_id": user_id}) + Message("user", ["Tell me a joke about AI."], additional_properties={"user_id": user_id}) ) print("First response (default cache):\n", first) second: AgentResponse = await agent.run( - ChatMessage("user", ["Nice! Another AI joke please."], additional_properties={"user_id": user_id}) + Message("user", ["Nice! Another AI joke please."], additional_properties={"user_id": user_id}) ) print("Second response (default cache):\n", second) diff --git a/python/samples/demos/workflow_evaluation/.env.example b/python/samples/05-end-to-end/workflow_evaluation/.env.example similarity index 100% rename from python/samples/demos/workflow_evaluation/.env.example rename to python/samples/05-end-to-end/workflow_evaluation/.env.example diff --git a/python/samples/demos/workflow_evaluation/README.md b/python/samples/05-end-to-end/workflow_evaluation/README.md similarity index 100% rename from python/samples/demos/workflow_evaluation/README.md rename to python/samples/05-end-to-end/workflow_evaluation/README.md diff --git a/python/samples/demos/workflow_evaluation/_tools.py b/python/samples/05-end-to-end/workflow_evaluation/_tools.py similarity index 100% rename from python/samples/demos/workflow_evaluation/_tools.py rename to python/samples/05-end-to-end/workflow_evaluation/_tools.py diff --git a/python/samples/demos/workflow_evaluation/create_workflow.py b/python/samples/05-end-to-end/workflow_evaluation/create_workflow.py similarity index 95% rename from python/samples/demos/workflow_evaluation/create_workflow.py rename to python/samples/05-end-to-end/workflow_evaluation/create_workflow.py index 2eb31d3492..d1f679b778 100644 --- a/python/samples/demos/workflow_evaluation/create_workflow.py +++ b/python/samples/05-end-to-end/workflow_evaluation/create_workflow.py @@ -48,8 +48,8 @@ from _tools import ( from agent_framework import ( AgentExecutorResponse, AgentResponseUpdate, - ChatMessage, Executor, + Message, WorkflowBuilder, WorkflowContext, executor, @@ -65,17 +65,17 @@ load_dotenv() @executor(id="start_executor") -async def start_executor(input: str, ctx: WorkflowContext[list[ChatMessage]]) -> None: +async def start_executor(input: str, ctx: WorkflowContext[list[Message]]) -> None: """Initiates the workflow by sending the user query to all specialized agents.""" - await ctx.send_message([ChatMessage("user", [input])]) + await ctx.send_message([Message("user", [input])]) class ResearchLead(Executor): """Aggregates and summarizes travel planning findings from all specialized agents.""" - def __init__(self, chat_client: AzureAIClient, id: str = "travel-planning-coordinator"): + def __init__(self, client: AzureAIClient, id: str = "travel-planning-coordinator"): # store=True to preserve conversation history for evaluation - self.agent = chat_client.as_agent( + self.agent = client.as_agent( id="travel-planning-coordinator", instructions=( "You are the final coordinator. You will receive responses from multiple agents: " @@ -102,11 +102,11 @@ class ResearchLead(Executor): # Generate comprehensive travel plan summary messages = [ - ChatMessage( + Message( role="system", text="You are a travel planning coordinator. Summarize findings from multiple specialized travel agents and provide a clear, comprehensive travel plan based on the user's query.", ), - ChatMessage( + Message( role="user", text=f"Original query: {user_query}\n\nFindings from specialized travel agents:\n{summary_text}\n\nPlease provide a comprehensive travel plan based on these findings.", ), @@ -142,17 +142,17 @@ class ResearchLead(Executor): return agent_findings -async def run_workflow_with_response_tracking(query: str, chat_client: AzureAIClient | None = None) -> dict: +async def run_workflow_with_response_tracking(query: str, client: AzureAIClient | None = None) -> dict: """Run multi-agent workflow and track conversation IDs, response IDs, and interaction sequence. Args: query: The user query to process through the multi-agent workflow - chat_client: Optional AzureAIClient instance + client: Optional AzureAIClient instance Returns: Dictionary containing interaction sequence, conversation/response IDs, and conversation analysis """ - if chat_client is None: + if client is None: try: async with DefaultAzureCredential() as credential: # Create AIProjectClient with the correct API version for V2 prompt agents @@ -171,10 +171,10 @@ async def run_workflow_with_response_tracking(query: str, chat_client: AzureAICl print(f"Error during workflow execution: {e}") raise else: - return await _run_workflow_with_client(query, chat_client) + return await _run_workflow_with_client(query, client) -async def _run_workflow_with_client(query: str, chat_client: AzureAIClient) -> dict: +async def _run_workflow_with_client(query: str, client: AzureAIClient) -> dict: """Execute workflow with given client and track all interactions.""" # Initialize tracking variables - use lists to track multiple responses per agent @@ -184,7 +184,7 @@ async def _run_workflow_with_client(query: str, chat_client: AzureAIClient) -> d # Create workflow components and keep agent references # Pass project_client and credential to create separate client instances per agent - workflow, agent_map = await _create_workflow(chat_client.project_client, chat_client.credential) + workflow, agent_map = await _create_workflow(client.project_client, client.credential) # Process workflow events events = workflow.run(query, stream=True) @@ -210,7 +210,7 @@ async def _create_workflow(project_client, credential): final_coordinator_client = AzureAIClient( project_client=project_client, credential=credential, agent_name="final-coordinator" ) - final_coordinator = ResearchLead(chat_client=final_coordinator_client, id="final-coordinator") + final_coordinator = ResearchLead(client=final_coordinator_client, id="final-coordinator") # Agent 1: Travel Request Handler (initial coordinator) # Create separate client with unique agent_name diff --git a/python/samples/demos/workflow_evaluation/run_evaluation.py b/python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py similarity index 100% rename from python/samples/demos/workflow_evaluation/run_evaluation.py rename to python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py diff --git a/python/samples/AGENTS.md b/python/samples/AGENTS.md new file mode 100644 index 0000000000..09674da7d9 --- /dev/null +++ b/python/samples/AGENTS.md @@ -0,0 +1,116 @@ +# Samples Structure & Design Choices — Python + +> This file documents the structure and conventions of the Python samples so that +> agents (AI or human) can maintain them without rediscovering decisions. + +## Directory layout + +``` +python/samples/ +├── 01-get-started/ # Progressive tutorial (steps 01–06) +├── 02-agents/ # Deep-dive concept samples +│ ├── tools/ # Tool patterns (function, approval, schema, etc.) +│ ├── middleware/ # One file per middleware concept +│ ├── conversations/ # Thread, storage, suspend/resume +│ ├── providers/ # One sub-folder per provider (azure_ai/, openai/, etc.) +│ ├── context_providers/ # Memory & context injection +│ ├── orchestrations/ # Multi-agent orchestration patterns +│ ├── observability/ # Tracing, telemetry +│ ├── declarative/ # Declarative agent definitions +│ ├── chat_client/ # Raw chat client usage +│ ├── mcp/ # MCP server/client patterns +│ ├── multimodal_input/ # Image, audio inputs +│ └── devui/ # DevUI agent/workflow samples +├── 03-workflows/ # Workflow samples (preserved from upstream) +│ ├── _start-here/ # Introductory workflow samples +│ ├── agents/ # Agents in workflows +│ ├── checkpoint/ # Checkpointing & resume +│ ├── composition/ # Sub-workflows +│ ├── control-flow/ # Edges, conditions, loops +│ ├── declarative/ # YAML-based workflows +│ ├── human-in-the-loop/ # HITL patterns +│ ├── observability/ # Workflow telemetry +│ ├── parallelism/ # Fan-out, map-reduce +│ ├── state-management/ # State isolation, kwargs +│ ├── tool-approval/ # Tool approval in workflows +│ └── visualization/ # Workflow visualization +├── 04-hosting/ # Deployment & hosting +│ ├── a2a/ # Agent-to-Agent protocol +│ ├── azure-functions/ # Azure Functions samples +│ └── durabletask/ # Durable task framework +├── 05-end-to-end/ # Complete applications +│ ├── chatkit-integration/ +│ ├── evaluation/ +│ ├── hosted_agents/ +│ ├── m365-agent/ +│ ├── purview_agent/ +│ └── workflow_evaluation/ +├── autogen-migration/ # Migration guides (do not restructure) +├── semantic-kernel-migration/ +└── _to_delete/ # Old samples awaiting review +``` + +## Design principles + +1. **Progressive complexity**: Sections 01→05 build from "hello world" to + production. Within 01-get-started, files are numbered 01–06 and each step + adds exactly one concept. + +2. **One concept per file** in 01-get-started and flat files in 02-agents/. + +3. **Workflows preserved**: 03-workflows/ keeps the upstream folder names + and file names intact. Do not rename or restructure workflow samples. + +4. **Single-file for 01-03**: Only 04-hosting and 05-end-to-end use multi-file + projects with their own README. + +## Default provider + +All canonical samples (01-get-started) use **Azure OpenAI Responses** via `AzureOpenAIResponsesClient` +with an Azure AI Foundry project endpoint: + +```python +import os +from agent_framework.azure import AzureOpenAIResponsesClient +from azure.identity import AzureCliCredential + +credential = AzureCliCredential() +client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + credential=credential, +) +agent = client.as_agent(name="...", instructions="...") +``` + +Environment variables: +- `AZURE_AI_PROJECT_ENDPOINT` — Your Azure AI Foundry project endpoint +- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` — Model deployment name (e.g. gpt-4o) + +For authentication, run `az login` before running samples. + +## Snippet tags for docs integration + +Samples embed named snippet regions for future `:::code` integration: + +```python +# +code here +# +``` + +## Package install + +```bash +pip install agent-framework --pre +``` + +The `--pre` flag is needed during preview. `openai` is a core dependency. + +## Current API notes + +- `Agent` class renamed from `ChatAgent` (use `from agent_framework import Agent`) +- `Message` class renamed from `ChatMessage` (use `from agent_framework import Message`) +- `call_next` in middleware takes NO arguments: `await call_next()` (not `await call_next(context)`) +- Prefer `client.as_agent(...)` over `Agent(client=client, ...)` +- Tool methods on hosted tools are now functions, not classes (e.g. `hosted_mcp_tool(...)` not `HostedMCPTool(...)`) diff --git a/python/samples/README.md b/python/samples/README.md index fc64dced52..36df843e6a 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -2,321 +2,44 @@ This directory contains samples demonstrating the capabilities of Microsoft Agent Framework for Python. -## Agents +## Structure -### A2A (Agent-to-Agent) - -| File | Description | -|------|-------------| -| [`getting_started/agents/a2a/agent_with_a2a.py`](./getting_started/agents/a2a/agent_with_a2a.py) | Agent2Agent (A2A) Protocol Integration Sample | - -### Anthropic - -| File | Description | -|------|-------------| -| [`getting_started/agents/anthropic/anthropic_basic.py`](./getting_started/agents/anthropic/anthropic_basic.py) | Agent with Anthropic Client | -| [`getting_started/agents/anthropic/anthropic_advanced.py`](./getting_started/agents/anthropic/anthropic_advanced.py) | Advanced sample with `thinking` and hosted tools. | - -### Azure AI (based on `azure-ai-agents` V1 package) - -| File | Description | -|------|-------------| -| [`getting_started/agents/azure_ai_agent/azure_ai_basic.py`](./getting_started/agents/azure_ai_agent/azure_ai_basic.py) | Azure AI Agent Basic Example | -| [`getting_started/agents/azure_ai_agent/azure_ai_with_azure_ai_search.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_azure_ai_search.py) | Azure AI Agent with Azure AI Search Example | -| [`getting_started/agents/azure_ai_agent/azure_ai_with_bing_grounding.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_bing_grounding.py) | Azure AI agent with Bing Grounding search for real-time web information | -| [`getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter.py) | Azure AI Agent with Code Interpreter Example | -| [`getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter_file_generation.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter_file_generation.py) | Azure AI Agent with Code Interpreter File Generation Example | -| [`getting_started/agents/azure_ai_agent/azure_ai_with_existing_agent.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_existing_agent.py) | Azure AI Agent with Existing Agent Example | -| [`getting_started/agents/azure_ai_agent/azure_ai_with_existing_thread.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_existing_thread.py) | Azure AI Agent with Existing Thread Example | -| [`getting_started/agents/azure_ai_agent/azure_ai_with_explicit_settings.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_explicit_settings.py) | Azure AI Agent with Explicit Settings Example | -| [`getting_started/agents/azure_ai_agent/azure_ai_with_file_search.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_file_search.py) | Azure AI agent with File Search capabilities | -| [`getting_started/agents/azure_ai_agent/azure_ai_with_function_tools.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_function_tools.py) | Azure AI Agent with Function Tools Example | -| [`getting_started/agents/azure_ai_agent/azure_ai_with_hosted_mcp.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_hosted_mcp.py) | Azure AI Agent with Hosted MCP Example | -| [`getting_started/agents/azure_ai_agent/azure_ai_with_local_mcp.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_local_mcp.py) | Azure AI Agent with Local MCP Example | -| [`getting_started/agents/azure_ai_agent/azure_ai_with_multiple_tools.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_multiple_tools.py) | Azure AI Agent with Multiple Tools Example | -| [`getting_started/agents/azure_ai_agent/azure_ai_with_openapi_tools.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_openapi_tools.py) | Azure AI agent with OpenAPI tools | -| [`getting_started/agents/azure_ai_agent/azure_ai_with_thread.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_thread.py) | Azure AI Agent with Thread Management Example | - -### Azure AI (based on `azure-ai-projects` V2 package) - -| File | Description | -|------|-------------| -| [`getting_started/agents/azure_ai/azure_ai_basic.py`](./getting_started/agents/azure_ai/azure_ai_basic.py) | Azure AI Agent Basic Example | -| [`getting_started/agents/azure_ai/azure_ai_use_latest_version.py`](./getting_started/agents/azure_ai/azure_ai_use_latest_version.py) | Azure AI Agent latest version reuse example | -| [`getting_started/agents/azure_ai/azure_ai_with_azure_ai_search.py`](./getting_started/agents/azure_ai/azure_ai_with_azure_ai_search.py) | Azure AI Agent with Azure AI Search Example | -| [`getting_started/agents/azure_ai/azure_ai_with_bing_grounding.py`](./getting_started/agents/azure_ai/azure_ai_with_bing_grounding.py) | Azure AI Agent with Bing Grounding Example | -| [`getting_started/agents/azure_ai/azure_ai_with_bing_custom_search.py`](./getting_started/agents/azure_ai/azure_ai_with_bing_custom_search.py) | Azure AI Agent with Bing Custom Search Example | -| [`getting_started/agents/azure_ai/azure_ai_with_browser_automation.py`](./getting_started/agents/azure_ai/azure_ai_with_browser_automation.py) | Azure AI Agent with Browser Automation Example | -| [`getting_started/agents/azure_ai/azure_ai_with_code_interpreter.py`](./getting_started/agents/azure_ai/azure_ai_with_code_interpreter.py) | Azure AI Agent with Code Interpreter Example | -| [`getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_generation.py`](./getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_generation.py) | Azure AI Agent with Code Interpreter File Generation Example | -| [`getting_started/agents/azure_ai/azure_ai_with_existing_agent.py`](./getting_started/agents/azure_ai/azure_ai_with_existing_agent.py) | Azure AI Agent with Existing Agent Example | -| [`getting_started/agents/azure_ai/azure_ai_with_existing_conversation.py`](./getting_started/agents/azure_ai/azure_ai_with_existing_conversation.py) | Azure AI Agent with Existing Conversation Example | -| [`getting_started/agents/azure_ai/azure_ai_with_explicit_settings.py`](./getting_started/agents/azure_ai/azure_ai_with_explicit_settings.py) | Azure AI Agent with Explicit Settings Example | -| [`getting_started/agents/azure_ai/azure_ai_with_file_search.py`](./getting_started/agents/azure_ai/azure_ai_with_file_search.py) | Azure AI Agent with File Search Example | -| [`getting_started/agents/azure_ai/azure_ai_with_hosted_mcp.py`](./getting_started/agents/azure_ai/azure_ai_with_hosted_mcp.py) | Azure AI Agent with Hosted MCP Example | -| [`getting_started/agents/azure_ai/azure_ai_with_response_format.py`](./getting_started/agents/azure_ai/azure_ai_with_response_format.py) | Azure AI Agent with Structured Output Example | -| [`getting_started/agents/azure_ai/azure_ai_with_thread.py`](./getting_started/agents/azure_ai/azure_ai_with_thread.py) | Azure AI Agent with Thread Management Example | -| [`getting_started/agents/azure_ai/azure_ai_with_image_generation.py`](./getting_started/agents/azure_ai/azure_ai_with_image_generation.py) | Azure AI Agent with Image Generation Example | -| [`getting_started/agents/azure_ai/azure_ai_with_microsoft_fabric.py`](./getting_started/agents/azure_ai/azure_ai_with_microsoft_fabric.py) | Azure AI Agent with Microsoft Fabric Example | -| [`getting_started/agents/azure_ai/azure_ai_with_web_search.py`](./getting_started/agents/azure_ai/azure_ai_with_web_search.py) | Azure AI Agent with Web Search Example | - -### Azure OpenAI - -| File | Description | -|------|-------------| -| [`getting_started/agents/azure_openai/azure_assistants_basic.py`](./getting_started/agents/azure_openai/azure_assistants_basic.py) | Azure OpenAI Assistants Basic Example | -| [`getting_started/agents/azure_openai/azure_assistants_with_code_interpreter.py`](./getting_started/agents/azure_openai/azure_assistants_with_code_interpreter.py) | Azure OpenAI Assistants with Code Interpreter Example | -| [`getting_started/agents/azure_openai/azure_assistants_with_existing_assistant.py`](./getting_started/agents/azure_openai/azure_assistants_with_existing_assistant.py) | Azure OpenAI Assistants with Existing Assistant Example | -| [`getting_started/agents/azure_openai/azure_assistants_with_explicit_settings.py`](./getting_started/agents/azure_openai/azure_assistants_with_explicit_settings.py) | Azure OpenAI Assistants with Explicit Settings Example | -| [`getting_started/agents/azure_openai/azure_assistants_with_function_tools.py`](./getting_started/agents/azure_openai/azure_assistants_with_function_tools.py) | Azure OpenAI Assistants with Function Tools Example | -| [`getting_started/agents/azure_openai/azure_assistants_with_thread.py`](./getting_started/agents/azure_openai/azure_assistants_with_thread.py) | Azure OpenAI Assistants with Thread Management Example | -| [`getting_started/agents/azure_openai/azure_chat_client_basic.py`](./getting_started/agents/azure_openai/azure_chat_client_basic.py) | Azure OpenAI Chat Client Basic Example | -| [`getting_started/agents/azure_openai/azure_chat_client_with_explicit_settings.py`](./getting_started/agents/azure_openai/azure_chat_client_with_explicit_settings.py) | Azure OpenAI Chat Client with Explicit Settings Example | -| [`getting_started/agents/azure_openai/azure_chat_client_with_function_tools.py`](./getting_started/agents/azure_openai/azure_chat_client_with_function_tools.py) | Azure OpenAI Chat Client with Function Tools Example | -| [`getting_started/agents/azure_openai/azure_chat_client_with_thread.py`](./getting_started/agents/azure_openai/azure_chat_client_with_thread.py) | Azure OpenAI Chat Client with Thread Management Example | -| [`getting_started/agents/azure_openai/azure_responses_client_basic.py`](./getting_started/agents/azure_openai/azure_responses_client_basic.py) | Azure OpenAI Responses Client Basic Example | -| [`getting_started/agents/azure_openai/azure_responses_client_image_analysis.py`](./getting_started/agents/azure_openai/azure_responses_client_image_analysis.py) | Azure OpenAI Responses Client with Image Analysis Example | -| [`getting_started/agents/azure_openai/azure_responses_client_with_code_interpreter.py`](./getting_started/agents/azure_openai/azure_responses_client_with_code_interpreter.py) | Azure OpenAI Responses Client with Code Interpreter Example | -| [`getting_started/agents/azure_openai/azure_responses_client_with_explicit_settings.py`](./getting_started/agents/azure_openai/azure_responses_client_with_explicit_settings.py) | Azure OpenAI Responses Client with Explicit Settings Example | -| [`getting_started/agents/azure_openai/azure_responses_client_with_function_tools.py`](./getting_started/agents/azure_openai/azure_responses_client_with_function_tools.py) | Azure OpenAI Responses Client with Function Tools Example | -| [`getting_started/agents/azure_openai/azure_responses_client_with_hosted_mcp.py`](./getting_started/agents/azure_openai/azure_responses_client_with_hosted_mcp.py) | Azure OpenAI Responses Client with Hosted Model Context Protocol (MCP) Example | -| [`getting_started/agents/azure_openai/azure_responses_client_with_local_mcp.py`](./getting_started/agents/azure_openai/azure_responses_client_with_local_mcp.py) | Azure OpenAI Responses Client with local Model Context Protocol (MCP) Example | -| [`getting_started/agents/azure_openai/azure_responses_client_with_thread.py`](./getting_started/agents/azure_openai/azure_responses_client_with_thread.py) | Azure OpenAI Responses Client with Thread Management Example | - -### Copilot Studio - -| File | Description | -|------|-------------| -| [`getting_started/agents/copilotstudio/copilotstudio_basic.py`](./getting_started/agents/copilotstudio/copilotstudio_basic.py) | Copilot Studio Agent Basic Example | -| [`getting_started/agents/copilotstudio/copilotstudio_with_explicit_settings.py`](./getting_started/agents/copilotstudio/copilotstudio_with_explicit_settings.py) | Copilot Studio Agent with Explicit Settings Example | - -### Custom - -| File | Description | -|------|-------------| -| [`getting_started/agents/custom/custom_agent.py`](./getting_started/agents/custom/custom_agent.py) | Custom Agent Implementation Example | -| [`getting_started/chat_client/custom_chat_client.py`](./getting_started/chat_client/custom_chat_client.py) | Custom Chat Client Implementation Example | - -### Ollama - -The recommended way to use Ollama is via the native `OllamaChatClient` from the `agent-framework-ollama` package. - -| File | Description | -|------|-------------| -| [`getting_started/agents/ollama/ollama_agent_basic.py`](./getting_started/agents/ollama/ollama_agent_basic.py) | Basic Ollama Agent with native Ollama Chat Client | -| [`getting_started/agents/ollama/ollama_agent_reasoning.py`](./getting_started/agents/ollama/ollama_agent_reasoning.py) | Ollama Agent with reasoning capabilities | -| [`getting_started/agents/ollama/ollama_chat_client.py`](./getting_started/agents/ollama/ollama_chat_client.py) | Direct usage of Ollama Chat Client | -| [`getting_started/agents/ollama/ollama_chat_multimodal.py`](./getting_started/agents/ollama/ollama_chat_multimodal.py) | Ollama Chat Client with multimodal (image) input | -| [`getting_started/agents/ollama/ollama_with_openai_chat_client.py`](./getting_started/agents/ollama/ollama_with_openai_chat_client.py) | Alternative: Ollama via OpenAI Chat Client | - -### OpenAI - -| File | Description | -|------|-------------| -| [`getting_started/agents/openai/openai_assistants_basic.py`](./getting_started/agents/openai/openai_assistants_basic.py) | OpenAI Assistants Basic Example | -| [`getting_started/agents/openai/openai_assistants_with_code_interpreter.py`](./getting_started/agents/openai/openai_assistants_with_code_interpreter.py) | OpenAI Assistants with Code Interpreter Example | -| [`getting_started/agents/openai/openai_assistants_with_existing_assistant.py`](./getting_started/agents/openai/openai_assistants_with_existing_assistant.py) | OpenAI Assistants with Existing Assistant Example | -| [`getting_started/agents/openai/openai_assistants_with_explicit_settings.py`](./getting_started/agents/openai/openai_assistants_with_explicit_settings.py) | OpenAI Assistants with Explicit Settings Example | -| [`getting_started/agents/openai/openai_assistants_with_file_search.py`](./getting_started/agents/openai/openai_assistants_with_file_search.py) | OpenAI Assistants with File Search Example | -| [`getting_started/agents/openai/openai_assistants_with_function_tools.py`](./getting_started/agents/openai/openai_assistants_with_function_tools.py) | OpenAI Assistants with Function Tools Example | -| [`getting_started/agents/openai/openai_assistants_with_thread.py`](./getting_started/agents/openai/openai_assistants_with_thread.py) | OpenAI Assistants with Thread Management Example | -| [`getting_started/agents/openai/openai_chat_client_basic.py`](./getting_started/agents/openai/openai_chat_client_basic.py) | OpenAI Chat Client Basic Example | -| [`getting_started/agents/openai/openai_chat_client_with_explicit_settings.py`](./getting_started/agents/openai/openai_chat_client_with_explicit_settings.py) | OpenAI Chat Client with Explicit Settings Example | -| [`getting_started/agents/openai/openai_chat_client_with_function_tools.py`](./getting_started/agents/openai/openai_chat_client_with_function_tools.py) | OpenAI Chat Client with Function Tools Example | -| [`getting_started/agents/openai/openai_chat_client_with_local_mcp.py`](./getting_started/agents/openai/openai_chat_client_with_local_mcp.py) | OpenAI Chat Client with Local MCP Example | -| [`getting_started/agents/openai/openai_chat_client_with_thread.py`](./getting_started/agents/openai/openai_chat_client_with_thread.py) | OpenAI Chat Client with Thread Management Example | -| [`getting_started/agents/openai/openai_chat_client_with_web_search.py`](./getting_started/agents/openai/openai_chat_client_with_web_search.py) | OpenAI Chat Client with Web Search Example | -| [`getting_started/agents/openai/openai_chat_client_with_runtime_json_schema.py`](./getting_started/agents/openai/openai_chat_client_with_runtime_json_schema.py) | OpenAI Chat Client with runtime JSON Schema for structured output without a Pydantic model | -| [`getting_started/agents/openai/openai_responses_client_basic.py`](./getting_started/agents/openai/openai_responses_client_basic.py) | OpenAI Responses Client Basic Example | -| [`getting_started/agents/openai/openai_responses_client_image_analysis.py`](./getting_started/agents/openai/openai_responses_client_image_analysis.py) | OpenAI Responses Client Image Analysis Example | -| [`getting_started/agents/openai/openai_responses_client_image_generation.py`](./getting_started/agents/openai/openai_responses_client_image_generation.py) | OpenAI Responses Client Image Generation Example | -| [`getting_started/agents/openai/openai_responses_client_reasoning.py`](./getting_started/agents/openai/openai_responses_client_reasoning.py) | OpenAI Responses Client Reasoning Example | -| [`getting_started/agents/openai/openai_responses_client_with_code_interpreter.py`](./getting_started/agents/openai/openai_responses_client_with_code_interpreter.py) | OpenAI Responses Client with Code Interpreter Example | -| [`getting_started/agents/openai/openai_responses_client_with_explicit_settings.py`](./getting_started/agents/openai/openai_responses_client_with_explicit_settings.py) | OpenAI Responses Client with Explicit Settings Example | -| [`getting_started/agents/openai/openai_responses_client_with_file_search.py`](./getting_started/agents/openai/openai_responses_client_with_file_search.py) | OpenAI Responses Client with File Search Example | -| [`getting_started/agents/openai/openai_responses_client_with_function_tools.py`](./getting_started/agents/openai/openai_responses_client_with_function_tools.py) | OpenAI Responses Client with Function Tools Example | -| [`getting_started/agents/openai/openai_responses_client_with_hosted_mcp.py`](./getting_started/agents/openai/openai_responses_client_with_hosted_mcp.py) | OpenAI Responses Client with Hosted MCP Example | -| [`getting_started/agents/openai/openai_responses_client_with_local_mcp.py`](./getting_started/agents/openai/openai_responses_client_with_local_mcp.py) | OpenAI Responses Client with Local MCP Example | -| [`getting_started/agents/openai/openai_responses_client_with_structured_output.py`](./getting_started/agents/openai/openai_responses_client_with_structured_output.py) | OpenAI Responses Client with Structured Output Example | -| [`getting_started/agents/openai/openai_responses_client_with_thread.py`](./getting_started/agents/openai/openai_responses_client_with_thread.py) | OpenAI Responses Client with Thread Management Example | -| [`getting_started/agents/openai/openai_responses_client_with_web_search.py`](./getting_started/agents/openai/openai_responses_client_with_web_search.py) | OpenAI Responses Client with Web Search Example | - -## Chat Client - -| File | Description | -|------|-------------| -| [`getting_started/chat_client/azure_ai_chat_client.py`](./getting_started/chat_client/azure_ai_chat_client.py) | Azure AI Chat Client Direct Usage Example | -| [`getting_started/chat_client/azure_assistants_client.py`](./getting_started/chat_client/azure_assistants_client.py) | Azure OpenAI Assistants Client Direct Usage Example | -| [`getting_started/chat_client/azure_chat_client.py`](./getting_started/chat_client/azure_chat_client.py) | Azure Chat Client Direct Usage Example | -| [`getting_started/chat_client/azure_responses_client.py`](./getting_started/chat_client/azure_responses_client.py) | Azure OpenAI Responses Client Direct Usage Example | -| [`getting_started/chat_client/chat_response_cancellation.py`](./getting_started/chat_client/chat_response_cancellation.py) | Chat Response Cancellation Example | -| [`getting_started/chat_client/openai_assistants_client.py`](./getting_started/chat_client/openai_assistants_client.py) | OpenAI Assistants Client Direct Usage Example | -| [`getting_started/chat_client/openai_chat_client.py`](./getting_started/chat_client/openai_chat_client.py) | OpenAI Chat Client Direct Usage Example | -| [`getting_started/chat_client/openai_responses_client.py`](./getting_started/chat_client/openai_responses_client.py) | OpenAI Responses Client Direct Usage Example | - - -## Context Providers - -### Mem0 - -| File | Description | -|------|-------------| -| [`getting_started/context_providers/mem0/mem0_basic.py`](./getting_started/context_providers/mem0/mem0_basic.py) | Basic Mem0 integration example | -| [`getting_started/context_providers/mem0/mem0_oss.py`](./getting_started/context_providers/mem0/mem0_oss.py) | Mem0 OSS (Open Source) integration example | -| [`getting_started/context_providers/mem0/mem0_threads.py`](./getting_started/context_providers/mem0/mem0_threads.py) | Mem0 with thread management example | - -### Redis - -| File | Description | -|------|-------------| -| [`getting_started/context_providers/redis/redis_basics.py`](./getting_started/context_providers/redis/redis_basics.py) | Basic Redis provider example | -| [`getting_started/context_providers/redis/redis_conversation.py`](./getting_started/context_providers/redis/redis_conversation.py) | Redis conversation context management example | -| [`getting_started/context_providers/redis/redis_threads.py`](./getting_started/context_providers/redis/redis_threads.py) | Redis with thread management example | - -### Other - -| File | Description | -|------|-------------| -| [`getting_started/context_providers/simple_context_provider.py`](./getting_started/context_providers/simple_context_provider.py) | Simple context provider implementation example | -| [`getting_started/context_providers/aggregate_context_provider.py`](./getting_started/context_providers/aggregate_context_provider.py) | Shows how to combine multiple context providers using an AggregateContextProvider | - -## Declarative - -| File | Description | -|------|-------------| -| [`getting_started/declarative/azure_openai_responses_agent.py`](./getting_started/declarative/azure_openai_responses_agent.py) | Basic agent using Azure OpenAI with structured responses | -| [`getting_started/declarative/get_weather_agent.py`](./getting_started/declarative/get_weather_agent.py) | Agent with custom function tools using declarative bindings | -| [`getting_started/declarative/inline_yaml.py`](./getting_started/declarative/inline_yaml.py) | Agent created from inline YAML string | -| [`getting_started/declarative/mcp_tool_yaml.py`](./getting_started/declarative/mcp_tool_yaml.py) | MCP tool configuration with API key and Azure Foundry connection auth | -| [`getting_started/declarative/microsoft_learn_agent.py`](./getting_started/declarative/microsoft_learn_agent.py) | Agent with MCP server integration for Microsoft Learn documentation | -| [`getting_started/declarative/openai_responses_agent.py`](./getting_started/declarative/openai_responses_agent.py) | Basic agent using OpenAI directly | - -## DevUI - -| File | Description | -|------|-------------| -| [`getting_started/devui/fanout_workflow/workflow.py`](./getting_started/devui/fanout_workflow/workflow.py) | Complex fan-out/fan-in workflow example | -| [`getting_started/devui/foundry_agent/agent.py`](./getting_started/devui/foundry_agent/agent.py) | Azure AI Foundry agent example | -| [`getting_started/devui/in_memory_mode.py`](./getting_started/devui/in_memory_mode.py) | In-memory mode example for DevUI | -| [`getting_started/devui/spam_workflow/workflow.py`](./getting_started/devui/spam_workflow/workflow.py) | Spam detection workflow example | -| [`getting_started/devui/weather_agent_azure/agent.py`](./getting_started/devui/weather_agent_azure/agent.py) | Weather agent using Azure OpenAI example | -| [`getting_started/devui/workflow_agents/workflow.py`](./getting_started/devui/workflow_agents/workflow.py) | Workflow with multiple agents example | - -## Evaluation - -| File | Description | -|------|-------------| -| [`getting_started/evaluation/red_teaming/red_team_agent_sample.py`](./getting_started/evaluation/red_teaming/red_team_agent_sample.py) | Red team agent evaluation sample for Azure AI Foundry | -| [`getting_started/evaluation/self_reflection/self_reflection.py`](./getting_started/evaluation/self_reflection/self_reflection.py) | LLM self-reflection with AI Foundry graders example | -| [`demos/workflow_evaluation/run_evaluation.py`](./demos/workflow_evaluation/run_evaluation.py) | Multi-agent workflow evaluation demo with travel planning agents evaluated using Azure AI Foundry evaluators | - -## MCP (Model Context Protocol) - -| File | Description | -|------|-------------| -| [`getting_started/mcp/agent_as_mcp_server.py`](./getting_started/mcp/agent_as_mcp_server.py) | Agent as MCP Server Example | -| [`getting_started/mcp/mcp_api_key_auth.py`](./getting_started/mcp/mcp_api_key_auth.py) | MCP Authentication Example | - -## Middleware - -| File | Description | -|------|-------------| -| [`getting_started/middleware/agent_and_run_level_middleware.py`](./getting_started/middleware/agent_and_run_level_middleware.py) | Agent and run-level middleware example | -| [`getting_started/middleware/chat_middleware.py`](./getting_started/middleware/chat_middleware.py) | Chat middleware example | -| [`getting_started/middleware/class_based_middleware.py`](./getting_started/middleware/class_based_middleware.py) | Class-based middleware implementation example | -| [`getting_started/middleware/decorator_middleware.py`](./getting_started/middleware/decorator_middleware.py) | Decorator-based middleware example | -| [`getting_started/middleware/exception_handling_with_middleware.py`](./getting_started/middleware/exception_handling_with_middleware.py) | Exception handling with middleware example | -| [`getting_started/middleware/function_based_middleware.py`](./getting_started/middleware/function_based_middleware.py) | Function-based middleware example | -| [`getting_started/middleware/middleware_termination.py`](./getting_started/middleware/middleware_termination.py) | Middleware termination example | -| [`getting_started/middleware/override_result_with_middleware.py`](./getting_started/middleware/override_result_with_middleware.py) | Override result with middleware example | -| [`getting_started/middleware/runtime_context_delegation.py`](./getting_started/middleware/runtime_context_delegation.py) | Runtime context delegation example demonstrating how to pass API tokens, session data, and other context through hierarchical agent delegation | -| [`getting_started/middleware/shared_state_middleware.py`](./getting_started/middleware/shared_state_middleware.py) | Shared state middleware example | -| [`getting_started/middleware/thread_behavior_middleware.py`](./getting_started/middleware/thread_behavior_middleware.py) | Thread behavior middleware example demonstrating how to track conversation state across multiple agent runs | - -## Multimodal Input - -| File | Description | -|------|-------------| -| [`getting_started/multimodal_input/azure_chat_multimodal.py`](./getting_started/multimodal_input/azure_chat_multimodal.py) | Azure OpenAI Chat with multimodal (image) input example | -| [`getting_started/multimodal_input/azure_responses_multimodal.py`](./getting_started/multimodal_input/azure_responses_multimodal.py) | Azure OpenAI Responses with multimodal (image) input example | -| [`getting_started/multimodal_input/openai_chat_multimodal.py`](./getting_started/multimodal_input/openai_chat_multimodal.py) | OpenAI Chat with multimodal (image) input example | - - -## Azure Functions - -| Sample | Description | +| Folder | Description | |--------|-------------| -| [`getting_started/azure_functions/01_single_agent/`](./getting_started/azure_functions/01_single_agent/) | Host a single agent in Azure Functions with Durable Extension HTTP endpoints and per-session state. | -| [`getting_started/azure_functions/02_multi_agent/`](./getting_started/azure_functions/02_multi_agent/) | Register multiple agents in one function app with dedicated run routes and a health check endpoint. | -| [`getting_started/azure_functions/03_reliable_streaming/`](./getting_started/azure_functions/03_reliable_streaming/) | Implement reliable streaming for durable agents using Redis Streams with cursor-based resumption. | -| [`getting_started/azure_functions/04_single_agent_orchestration_chaining/`](./getting_started/azure_functions/04_single_agent_orchestration_chaining/) | Chain sequential agent executions inside a durable orchestration while preserving the shared thread context. | -| [`getting_started/azure_functions/05_multi_agent_orchestration_concurrency/`](./getting_started/azure_functions/05_multi_agent_orchestration_concurrency/) | Run two agents concurrently within a durable orchestration and combine their domain-specific outputs. | -| [`getting_started/azure_functions/06_multi_agent_orchestration_conditionals/`](./getting_started/azure_functions/06_multi_agent_orchestration_conditionals/) | Route orchestration logic based on structured agent responses for spam detection and reply drafting. | -| [`getting_started/azure_functions/07_single_agent_orchestration_hitl/`](./getting_started/azure_functions/07_single_agent_orchestration_hitl/) | Implement a human-in-the-loop approval loop that iterates on agent output inside a durable orchestration. | -| [`getting_started/azure_functions/08_mcp_server/`](./getting_started/azure_functions/08_mcp_server/) | Configure agents as both HTTP endpoints and MCP tools for flexible integration patterns. | +| [`01-get-started/`](./01-get-started/) | Progressive tutorial: hello agent → hosting | +| [`02-agents/`](./02-agents/) | Deep-dive by concept: tools, middleware, providers, orchestrations | +| [`03-workflows/`](./03-workflows/) | Workflow patterns: sequential, concurrent, state, declarative | +| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks, A2A | +| [`05-end-to-end/`](./05-end-to-end/) | Full applications, evaluation, demos | -## Durable Task +## Getting Started -These samples demonstrate durable agent hosting using the Durable Task Scheduler with a worker-client architecture pattern, enabling distributed agent execution with persistent conversation state. +Start with `01-get-started/` and work through the numbered files: -| Sample | Description | -|--------|-------------| -| [`getting_started/durabletask/01_single_agent/`](./getting_started/durabletask/01_single_agent/) | Host a single conversational agent with worker-client architecture and agent state management. | -| [`getting_started/durabletask/02_multi_agent/`](./getting_started/durabletask/02_multi_agent/) | Host multiple domain-specific agents and route requests based on question topic. | -| [`getting_started/durabletask/03_single_agent_streaming/`](./getting_started/durabletask/03_single_agent_streaming/) | Implement reliable streaming using Redis Streams with cursor-based resumption for durable agents. | -| [`getting_started/durabletask/04_single_agent_orchestration_chaining/`](./getting_started/durabletask/04_single_agent_orchestration_chaining/) | Chain multiple agent invocations using durable orchestration while preserving conversation context. | -| [`getting_started/durabletask/05_multi_agent_orchestration_concurrency/`](./getting_started/durabletask/05_multi_agent_orchestration_concurrency/) | Run multiple agents concurrently within an orchestration and aggregate their responses. | -| [`getting_started/durabletask/06_multi_agent_orchestration_conditionals/`](./getting_started/durabletask/06_multi_agent_orchestration_conditionals/) | Implement conditional branching with spam detection using structured outputs and activity functions. | -| [`getting_started/durabletask/07_single_agent_orchestration_hitl/`](./getting_started/durabletask/07_single_agent_orchestration_hitl/) | Human-in-the-loop pattern with external event handling, timeouts, and iterative refinement. | +1. **[01_hello_agent.py](./01-get-started/01_hello_agent.py)** — Create and run your first agent +2. **[02_add_tools.py](./01-get-started/02_add_tools.py)** — Add function tools with `@tool` +3. **[03_multi_turn.py](./01-get-started/03_multi_turn.py)** — Multi-turn conversations with `AgentThread` +4. **[04_memory.py](./01-get-started/04_memory.py)** — Agent memory with `ContextProvider` +5. **[05_first_workflow.py](./01-get-started/05_first_workflow.py)** — Build a workflow with executors and edges +6. **[06_host_your_agent.py](./01-get-started/06_host_your_agent.py)** — Host your agent via A2A -## Observability +## Prerequisites -| File | Description | -|------|-------------| -| [`getting_started/observability/advanced_manual_setup_console_output.py`](./getting_started/observability/advanced_manual_setup_console_output.py) | Advanced manual observability setup with console output | -| [`getting_started/observability/advanced_zero_code.py`](./getting_started/observability/advanced_zero_code.py) | Zero-code observability setup example | -| [`getting_started/observability/agent_observability.py`](./getting_started/observability/agent_observability.py) | Agent observability example | -| [`getting_started/observability/agent_with_foundry_tracing.py`](./getting_started/observability/agent_with_foundry_tracing.py) | Any chat client setup with Azure Foundry Observability | -| [`getting_started/observability/azure_ai_agent_observability.py`](./getting_started/observability/azure_ai_agent_observability.py) | Azure AI agent observability example | -| [`getting_started/observability/configure_otel_providers_with_env_var.py`](./getting_started/observability/configure_otel_providers_with_env_var.py) | Setup observability using environment variables | -| [`getting_started/observability/configure_otel_providers_with_parameters.py`](./getting_started/observability/configure_otel_providers_with_parameters.py) | Setup observability using parameters | -| [`getting_started/observability/workflow_observability.py`](./getting_started/observability/workflow_observability.py) | Workflow observability example | +```bash +pip install agent-framework --pre +``` -## Threads +Set the following environment variables for the getting-started samples: -| File | Description | -|------|-------------| -| [`getting_started/threads/custom_chat_message_store_thread.py`](./getting_started/threads/custom_chat_message_store_thread.py) | Implementation of custom chat message store state | -| [`getting_started/threads/redis_chat_message_store_thread.py`](./getting_started/threads/redis_chat_message_store_thread.py) | Basic example of using Redis chat message store | -| [`getting_started/threads/suspend_resume_thread.py`](./getting_started/threads/suspend_resume_thread.py) | Demonstrates how to suspend and resume a service-managed thread | +```bash +export AZURE_AI_PROJECT_ENDPOINT="your-foundry-project-endpoint" +export AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME="gpt-4o" +``` -## Tools +For Azure authentication, run `az login` before running samples. -Note: Many tool samples set `approval_mode="never_require"` to keep the examples concise. For production scenarios, -keep `approval_mode="always_require"` unless you are confident in the tool behavior and approval flow. See -`getting_started/tools/function_tool_with_approval.py` and -`getting_started/tools/function_tool_with_approval_and_threads.py`, plus the workflow approval samples in -`getting_started/workflows/tool-approval/`, for end-to-end approval handling. +## Additional Resources -| File | Description | -|------|-------------| -| [`getting_started/tools/function_tool_declaration_only.py`](./getting_started/tools/function_tool_declaration_only.py) | Function declarations without implementations for testing agent reasoning | -| [`getting_started/tools/function_tool_from_dict_with_dependency_injection.py`](./getting_started/tools/function_tool_from_dict_with_dependency_injection.py) | Creating local tools from dictionary definitions using dependency injection | -| [`getting_started/tools/function_tool_recover_from_failures.py`](./getting_started/tools/function_tool_recover_from_failures.py) | Graceful error handling when tools raise exceptions | -| [`getting_started/tools/function_tool_with_approval.py`](./getting_started/tools/function_tool_with_approval.py) | User approval workflows for function calls without threads | -| [`getting_started/tools/function_tool_with_approval_and_threads.py`](./getting_started/tools/function_tool_with_approval_and_threads.py) | Tool approval workflows using threads for conversation history management | -| [`getting_started/tools/function_tool_with_max_exceptions.py`](./getting_started/tools/function_tool_with_max_exceptions.py) | Limiting tool failure exceptions using max_invocation_exceptions | -| [`getting_started/tools/function_tool_with_max_invocations.py`](./getting_started/tools/function_tool_with_max_invocations.py) | Limiting total tool invocations using max_invocations | -| [`getting_started/tools/tool_in_class.py`](./getting_started/tools/tool_in_class.py) | Using the tool decorator with class methods for stateful tools | - -## Workflows - -View the list of Workflows samples [here](./getting_started/workflows/README.md). - -## Sample Guidelines - -For information on creating new samples, see [SAMPLE_GUIDELINES.md](./SAMPLE_GUIDELINES.md). - -## More Information - -- [Python Package Documentation](../README.md) +- [Agent Framework Documentation](https://learn.microsoft.com/agent-framework/) +- [AGENTS.md](./AGENTS.md) — Structure documentation for maintainers +- [SAMPLE_GUIDELINES.md](./SAMPLE_GUIDELINES.md) — Coding conventions for samples diff --git a/python/samples/autogen-migration/README.md b/python/samples/autogen-migration/README.md index 509b518f8a..2bfa229183 100644 --- a/python/samples/autogen-migration/README.md +++ b/python/samples/autogen-migration/README.md @@ -6,9 +6,9 @@ This gallery helps AutoGen developers move to the Microsoft Agent Framework (AF) ### Single-Agent Parity -- [01_basic_assistant_agent.py](single_agent/01_basic_assistant_agent.py) — Minimal AutoGen `AssistantAgent` and AF `ChatAgent` comparison. +- [01_basic_assistant_agent.py](single_agent/01_basic_assistant_agent.py) — Minimal AutoGen `AssistantAgent` and AF `Agent` comparison. - [02_assistant_agent_with_tool.py](single_agent/02_assistant_agent_with_tool.py) — Function tool integration in both SDKs. -- [03_assistant_agent_thread_and_stream.py](single_agent/03_assistant_agent_thread_and_stream.py) — Thread management and streaming responses. +- [03_assistant_agent_thread_and_stream.py](single_agent/03_assistant_agent_thread_and_stream.py) — Session management and streaming responses. - [04_agent_as_tool.py](single_agent/04_agent_as_tool.py) — Using agents as tools (hierarchical agent pattern) and streaming with tools. ### Multi-Agent Orchestration @@ -51,8 +51,8 @@ python samples/autogen-migration/orchestrations/04_magentic_one.py ## Tips for Migration -- **Default behavior differences**: AutoGen's `AssistantAgent` is single-turn by default (`max_tool_iterations=1`), while AF's `ChatAgent` is multi-turn and continues tool execution automatically. -- **Thread management**: AF agents are stateless by default. Use `agent.get_new_thread()` and pass it to `run()` to maintain conversation state, similar to AutoGen's conversation context. +- **Default behavior differences**: AutoGen's `AssistantAgent` is single-turn by default (`max_tool_iterations=1`), while AF's `Agent` is multi-turn and continues tool execution automatically. +- **Thread management**: AF agents are stateless by default. Use `agent.create_session()` and pass it to `run()` to maintain conversation state, similar to AutoGen's conversation context. - **Tools**: AutoGen uses `FunctionTool` wrappers; AF uses `@tool` decorators with automatic schema inference. - **Orchestration patterns**: - `RoundRobinGroupChat` → `SequentialBuilder` or `WorkflowBuilder` diff --git a/python/samples/autogen-migration/orchestrations/04_magentic_one.py b/python/samples/autogen-migration/orchestrations/04_magentic_one.py index f14cee5a26..a83f7cc33e 100644 --- a/python/samples/autogen-migration/orchestrations/04_magentic_one.py +++ b/python/samples/autogen-migration/orchestrations/04_magentic_one.py @@ -21,7 +21,7 @@ from typing import cast from agent_framework import ( AgentResponseUpdate, - ChatMessage, + Message, WorkflowEvent, ) from agent_framework.orchestrations import MagenticProgressLedger @@ -129,7 +129,7 @@ async def run_agent_framework() -> None: elif event.type == "magentic_orchestrator": print(f"\n[Magentic Orchestrator Event] Type: {event.data.event_type.name}") - if isinstance(event.data.content, ChatMessage): + if isinstance(event.data.content, Message): print(f"Please review the plan:\n{event.data.content.text}") elif isinstance(event.data.content, MagenticProgressLedger): print(f"Please review progress ledger:\n{json.dumps(event.data.content.to_dict(), indent=2)}") @@ -150,7 +150,7 @@ async def run_agent_framework() -> None: print("Final Output:") # The output of the Magentic workflow is a list of ChatMessages with only one final message # generated by the orchestrator. - output_messages = cast(list[ChatMessage], output_event.data) + output_messages = cast(list[Message], output_event.data) if output_messages: output = output_messages[-1].text print(output) diff --git a/python/samples/autogen-migration/single_agent/01_basic_assistant_agent.py b/python/samples/autogen-migration/single_agent/01_basic_assistant_agent.py index 711bd648c8..9f7ae98d6e 100644 --- a/python/samples/autogen-migration/single_agent/01_basic_assistant_agent.py +++ b/python/samples/autogen-migration/single_agent/01_basic_assistant_agent.py @@ -9,7 +9,7 @@ # uv run samples/autogen-migration/single_agent/01_basic_assistant_agent.py # Copyright (c) Microsoft. All rights reserved. -"""Basic AutoGen AssistantAgent vs Agent Framework ChatAgent. +"""Basic AutoGen AssistantAgent vs Agent Framework Agent. Both samples expect OpenAI-compatible environment variables (OPENAI_API_KEY or Azure OpenAI configuration). Update the prompts or client wiring to match your @@ -38,10 +38,10 @@ async def run_autogen() -> None: async def run_agent_framework() -> None: - """Call Agent Framework's ChatAgent created from OpenAIChatClient.""" + """Call Agent Framework's Agent created from OpenAIChatClient.""" from agent_framework.openai import OpenAIChatClient - # AF constructs a lightweight ChatAgent backed by OpenAIChatClient + # AF constructs a lightweight Agent backed by OpenAIChatClient client = OpenAIChatClient(model_id="gpt-4.1-mini") agent = client.as_agent( name="assistant", diff --git a/python/samples/autogen-migration/single_agent/02_assistant_agent_with_tool.py b/python/samples/autogen-migration/single_agent/02_assistant_agent_with_tool.py index ff56e694a0..0f2f254e07 100644 --- a/python/samples/autogen-migration/single_agent/02_assistant_agent_with_tool.py +++ b/python/samples/autogen-migration/single_agent/02_assistant_agent_with_tool.py @@ -10,7 +10,7 @@ # uv run samples/autogen-migration/single_agent/02_assistant_agent_with_tool.py # Copyright (c) Microsoft. All rights reserved. -"""AutoGen AssistantAgent vs Agent Framework ChatAgent with function tools. +"""AutoGen AssistantAgent vs Agent Framework Agent with function tools. Demonstrates how to create and attach tools to agents in both frameworks. """ @@ -62,7 +62,7 @@ async def run_agent_framework() -> None: from agent_framework.openai import OpenAIChatClient # Define tool with @tool decorator (automatic schema inference) - # NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. + # NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") def get_weather(location: str) -> str: """Get the weather for a location. diff --git a/python/samples/autogen-migration/single_agent/03_assistant_agent_thread_and_stream.py b/python/samples/autogen-migration/single_agent/03_assistant_agent_thread_and_stream.py index 73fb0f3c62..5e1de9d873 100644 --- a/python/samples/autogen-migration/single_agent/03_assistant_agent_thread_and_stream.py +++ b/python/samples/autogen-migration/single_agent/03_assistant_agent_thread_and_stream.py @@ -46,7 +46,7 @@ async def run_autogen() -> None: async def run_agent_framework() -> None: - """Agent Framework agent with explicit thread and streaming.""" + """Agent Framework agent with explicit session and streaming.""" from agent_framework.openai import OpenAIChatClient client = OpenAIChatClient(model_id="gpt-4.1-mini") @@ -55,22 +55,22 @@ async def run_agent_framework() -> None: instructions="You are a helpful math tutor.", ) - print("[Agent Framework] Conversation with thread:") - # Create a thread to maintain state - thread = agent.get_new_thread() + print("[Agent Framework] Conversation with session:") + # Create a session to maintain state + session = agent.create_session() - # First turn - pass thread to maintain history - result1 = await agent.run("What is 15 + 27?", thread=thread) + # First turn - pass session to maintain history + result1 = await agent.run("What is 15 + 27?", session=session) print(f" Q1: {result1.text}") - # Second turn - agent remembers context via thread - result2 = await agent.run("What about that number times 2?", thread=thread) + # Second turn - agent remembers context via session + result2 = await agent.run("What about that number times 2?", session=session) print(f" Q2: {result2.text}") print("\n[Agent Framework] Streaming response:") # Stream response print(" ", end="") - async for chunk in agent.run("Count from 1 to 5", thread=thread, stream=True): + async for chunk in agent.run("Count from 1 to 5", session=session, stream=True): if chunk.text: print(chunk.text, end="", flush=True) print() diff --git a/python/samples/concepts/README.md b/python/samples/concepts/README.md deleted file mode 100644 index 8e3c0282fa..0000000000 --- a/python/samples/concepts/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Concept Samples - -This folder contains samples that dive deep into specific Agent Framework concepts. - -## Samples - -| Sample | Description | -|--------|-------------| -| [response_stream.py](response_stream.py) | Deep dive into `ResponseStream` - the streaming abstraction for AI responses. Covers the four hook types (transform hooks, cleanup hooks, finalizer, result hooks), two consumption patterns (iteration vs direct finalization), and the `wrap()` API for layering streams without double-consumption. | -| [typed_options.py](typed_options.py) | Demonstrates TypedDict-based chat options for type-safe configuration with IDE autocomplete support. | diff --git a/python/samples/concepts/tools/README.md b/python/samples/concepts/tools/README.md deleted file mode 100644 index 6643a42126..0000000000 --- a/python/samples/concepts/tools/README.md +++ /dev/null @@ -1,499 +0,0 @@ -# Tools and Middleware: Request Flow Architecture - -This document describes the complete request flow when using an Agent with middleware and tools, from the initial `Agent.run()` call through middleware layers, function invocation, and back to the caller. - -## Overview - -The Agent Framework uses a layered architecture with three distinct middleware/processing layers: - -1. **Agent Middleware Layer** - Wraps the entire agent execution -2. **Chat Middleware Layer** - Wraps calls to the chat client -3. **Function Middleware Layer** - Wraps individual tool/function invocations - -Each layer provides interception points where you can modify inputs, inspect outputs, or alter behavior. - -## Flow Diagram - -```mermaid -sequenceDiagram - participant User - participant Agent as Agent.run() - participant AML as AgentMiddlewareLayer - participant AMP as AgentMiddlewarePipeline - participant RawAgent as RawChatAgent.run() - participant CML as ChatMiddlewareLayer - participant CMP as ChatMiddlewarePipeline - participant FIL as FunctionInvocationLayer - participant Client as BaseChatClient._inner_get_response() - participant LLM as LLM Service - participant FMP as FunctionMiddlewarePipeline - participant Tool as FunctionTool.invoke() - - User->>Agent: run(messages, thread, options, middleware) - - Note over Agent,AML: Agent Middleware Layer - Agent->>AML: run() with middleware param - AML->>AML: categorize_middleware() → split by type - AML->>AMP: execute(AgentContext) - - loop Agent Middleware Chain - AMP->>AMP: middleware[i].process(context, call_next) - Note right of AMP: Can modify: messages, options, thread - end - - AMP->>RawAgent: run() via final_handler - - alt Non-Streaming (stream=False) - RawAgent->>RawAgent: _prepare_run_context() [async] - Note right of RawAgent: Builds: thread_messages, chat_options, tools - RawAgent->>CML: chat_client.get_response(stream=False) - else Streaming (stream=True) - RawAgent->>RawAgent: ResponseStream.from_awaitable() - Note right of RawAgent: Defers async prep to stream consumption - RawAgent-->>User: Returns ResponseStream immediately - Note over RawAgent,CML: Async work happens on iteration - RawAgent->>RawAgent: _prepare_run_context() [deferred] - RawAgent->>CML: chat_client.get_response(stream=True) - end - - Note over CML,CMP: Chat Middleware Layer - CML->>CMP: execute(ChatContext) - - loop Chat Middleware Chain - CMP->>CMP: middleware[i].process(context, call_next) - Note right of CMP: Can modify: messages, options - end - - CMP->>FIL: get_response() via final_handler - - Note over FIL,Tool: Function Invocation Loop - loop Max Iterations (default: 40) - FIL->>Client: _inner_get_response(messages, options) - Client->>LLM: API Call - LLM-->>Client: Response (may include tool_calls) - Client-->>FIL: ChatResponse - - alt Response has function_calls - FIL->>FIL: _extract_function_calls() - FIL->>FIL: _try_execute_function_calls() - - Note over FIL,Tool: Function Middleware Layer - loop For each function_call - FIL->>FMP: execute(FunctionInvocationContext) - loop Function Middleware Chain - FMP->>FMP: middleware[i].process(context, call_next) - Note right of FMP: Can modify: arguments - end - FMP->>Tool: invoke(arguments) - Tool-->>FMP: result - FMP-->>FIL: Content.from_function_result() - end - - FIL->>FIL: Append tool results to messages - - alt tool_choice == "required" - Note right of FIL: Return immediately with function call + result - FIL-->>CMP: ChatResponse - else tool_choice == "auto" or other - Note right of FIL: Continue loop for text response - end - else No function_calls - FIL-->>CMP: ChatResponse - end - end - - CMP-->>CML: ChatResponse - Note right of CMP: Can observe/modify result - - CML-->>RawAgent: ChatResponse / ResponseStream - - alt Non-Streaming - RawAgent->>RawAgent: _finalize_response_and_update_thread() - else Streaming - Note right of RawAgent: .map() transforms updates - Note right of RawAgent: .with_result_hook() runs post-processing - end - - RawAgent-->>AMP: AgentResponse / ResponseStream - Note right of AMP: Can observe/modify result - AMP-->>AML: AgentResponse - AML-->>Agent: AgentResponse - Agent-->>User: AgentResponse / ResponseStream -``` - -## Layer Details - -### 1. Agent Middleware Layer (`AgentMiddlewareLayer`) - -**Entry Point:** `Agent.run(messages, thread, options, middleware)` - -**Context Object:** `AgentContext` - -| Field | Type | Description | -|-------|------|-------------| -| `agent` | `SupportsAgentRun` | The agent being invoked | -| `messages` | `list[ChatMessage]` | Input messages (mutable) | -| `thread` | `AgentThread \| None` | Conversation thread | -| `options` | `Mapping[str, Any]` | Chat options dict | -| `stream` | `bool` | Whether streaming is enabled | -| `metadata` | `dict` | Shared data between middleware | -| `result` | `AgentResponse \| None` | Set after `call_next()` is called | -| `kwargs` | `Mapping[str, Any]` | Additional run arguments | - -**Key Operations:** -1. `categorize_middleware()` separates middleware by type (agent, chat, function) -2. Chat and function middleware are forwarded to `chat_client` -3. `AgentMiddlewarePipeline.execute()` runs the agent middleware chain -4. Final handler calls `RawChatAgent.run()` - -**What Can Be Modified:** -- `context.messages` - Add, remove, or modify input messages -- `context.options` - Change model parameters, temperature, etc. -- `context.thread` - Replace or modify the thread -- `context.result` - Override the final response (after `call_next()`) - -### 2. Chat Middleware Layer (`ChatMiddlewareLayer`) - -**Entry Point:** `chat_client.get_response(messages, options)` - -**Context Object:** `ChatContext` - -| Field | Type | Description | -|-------|------|-------------| -| `chat_client` | `ChatClientProtocol` | The chat client | -| `messages` | `Sequence[ChatMessage]` | Messages to send | -| `options` | `Mapping[str, Any]` | Chat options | -| `stream` | `bool` | Whether streaming | -| `metadata` | `dict` | Shared data between middleware | -| `result` | `ChatResponse \| None` | Set after `call_next()` is called | -| `kwargs` | `Mapping[str, Any]` | Additional arguments | - -**Key Operations:** -1. `ChatMiddlewarePipeline.execute()` runs the chat middleware chain -2. Final handler calls `FunctionInvocationLayer.get_response()` -3. Stream hooks can be registered for streaming responses - -**What Can Be Modified:** -- `context.messages` - Inject system prompts, filter content -- `context.options` - Change model, temperature, tool_choice -- `context.result` - Override the response (after `call_next()`) - -### 3. Function Invocation Layer (`FunctionInvocationLayer`) - -**Entry Point:** `FunctionInvocationLayer.get_response()` - -This layer manages the tool execution loop: - -1. **Calls** `BaseChatClient._inner_get_response()` to get LLM response -2. **Extracts** function calls from the response -3. **Executes** functions through the Function Middleware Pipeline -4. **Appends** results to messages and loops back to step 1 - -**Configuration:** `FunctionInvocationConfiguration` - -| Setting | Default | Description | -|---------|---------|-------------| -| `enabled` | `True` | Enable auto-invocation | -| `max_iterations` | `40` | Maximum tool execution loops | -| `max_consecutive_errors_per_request` | `3` | Error threshold before stopping | -| `terminate_on_unknown_calls` | `False` | Raise error for unknown tools | -| `additional_tools` | `[]` | Extra tools to register | -| `include_detailed_errors` | `False` | Include exceptions in results | - -**`tool_choice` Behavior:** - -The `tool_choice` option controls how the model uses available tools: - -| Value | Behavior | -|-------|----------| -| `"auto"` | Model decides whether to call a tool or respond with text. After tool execution, the loop continues to get a text response. | -| `"none"` | Model is prevented from calling tools, will only respond with text. | -| `"required"` | Model **must** call a tool. After tool execution, returns immediately with the function call and result—**no additional model call** is made. | -| `{"mode": "required", "required_function_name": "fn"}` | Model must call the specified function. Same return behavior as `"required"`. | - -**Why `tool_choice="required"` returns immediately:** - -When you set `tool_choice="required"`, your intent is to force one or more tool calls (not all models supports multiple, either by name or when using `required` without a name). The framework respects this by: -1. Getting the model's function call(s) -2. Executing the tool(s) -3. Returning the response(s) with both the function call message(s) and the function result(s) - -This avoids an infinite loop (model forced to call tools → executes → model forced to call tools again) and gives you direct access to the tool result. - -```python -# With tool_choice="required", response contains function call + result only -response = await client.get_response( - "What's the weather?", - options={"tool_choice": "required", "tools": [get_weather]} -) - -# response.messages contains: -# [0] Assistant message with function_call content -# [1] Tool message with function_result content -# (No text response from model) - -# To get a text response after tool execution, use tool_choice="auto" -response = await client.get_response( - "What's the weather?", - options={"tool_choice": "auto", "tools": [get_weather]} -) -# response.text contains the model's interpretation of the weather data -``` - -### 4. Function Middleware Layer (`FunctionMiddlewarePipeline`) - -**Entry Point:** Called per function invocation within `_auto_invoke_function()` - -**Context Object:** `FunctionInvocationContext` - -| Field | Type | Description | -|-------|------|-------------| -| `function` | `FunctionTool` | The function being invoked | -| `arguments` | `BaseModel` | Validated Pydantic arguments | -| `metadata` | `dict` | Shared data between middleware | -| `result` | `Any` | Set after `call_next()` is called | -| `kwargs` | `Mapping[str, Any]` | Runtime kwargs | - -**What Can Be Modified:** -- `context.arguments` - Modify validated arguments before execution -- `context.result` - Override the function result (after `call_next()`) -- Raise `MiddlewareTermination` to skip execution and terminate the function invocation loop - -**Special Behavior:** When `MiddlewareTermination` is raised in function middleware, it signals that the function invocation loop should exit **without making another LLM call**. This is useful when middleware determines that no further processing is needed (e.g., a termination condition is met). - -```python -class TerminatingMiddleware(FunctionMiddleware): - async def process(self, context: FunctionInvocationContext, call_next): - if self.should_terminate(context): - context.result = "terminated by middleware" - raise MiddlewareTermination # Exit function invocation loop - await call_next(context) -``` - -## Arguments Added/Altered at Each Layer - -### Agent Layer → Chat Layer - -```python -# RawChatAgent._prepare_run_context() builds: -{ - "thread": AgentThread, # Validated/created thread - "input_messages": [...], # Normalized input messages - "thread_messages": [...], # Messages from thread + context + input - "agent_name": "...", # Agent name for attribution - "chat_options": { - "model_id": "...", - "conversation_id": "...", # From thread.service_thread_id - "tools": [...], # Normalized tools + MCP tools - "temperature": ..., - "max_tokens": ..., - # ... other options - }, - "filtered_kwargs": {...}, # kwargs minus 'chat_options' - "finalize_kwargs": {...}, # kwargs with 'thread' added -} -``` - -### Chat Layer → Function Layer - -```python -# Passed through to FunctionInvocationLayer: -{ - "messages": [...], # Prepared messages - "options": {...}, # Mutable copy of chat_options - "function_middleware": [...], # Function middleware from kwargs -} -``` - -### Function Layer → Tool Invocation - -```python -# FunctionInvocationContext receives: -{ - "function": FunctionTool, # The tool to invoke - "arguments": BaseModel, # Validated from function_call.arguments - "kwargs": { - # Runtime kwargs (filtered, no conversation_id) - }, -} -``` - -### Tool Result → Back Up - -```python -# Content.from_function_result() creates: -{ - "type": "function_result", - "call_id": "...", # From function_call.call_id - "result": ..., # Serialized tool output - "exception": "..." | None, # Error message if failed -} -``` - -## Middleware Control Flow - -There are three ways to exit a middleware's `process()` method: - -### 1. Return Normally (with or without calling `call_next`) - -Returns control to the upstream middleware, allowing its post-processing code to run. - -```python -class CachingMiddleware(FunctionMiddleware): - async def process(self, context: FunctionInvocationContext, call_next): - # Option A: Return early WITHOUT calling call_next (skip downstream) - if cached := self.cache.get(context.function.name): - context.result = cached - return # Upstream post-processing still runs - - # Option B: Call call_next, then return normally - await call_next(context) - self.cache[context.function.name] = context.result - return # Normal completion -``` - -### 2. Raise `MiddlewareTermination` - -Immediately exits the entire middleware chain. Upstream middleware's post-processing code is **skipped**. - -```python -class BlockedFunctionMiddleware(FunctionMiddleware): - async def process(self, context: FunctionInvocationContext, call_next): - if context.function.name in self.blocked_functions: - context.result = "Function blocked by policy" - raise MiddlewareTermination("Blocked") # Skips ALL post-processing - await call_next(context) -``` - -### 3. Raise Any Other Exception - -Bubbles up to the caller. The middleware chain is aborted and the exception propagates. - -```python -class ValidationMiddleware(FunctionMiddleware): - async def process(self, context: FunctionInvocationContext, call_next): - if not self.is_valid(context.arguments): - raise ValueError("Invalid arguments") # Bubbles up to user - await call_next(context) -``` - -## `return` vs `raise MiddlewareTermination` - -The key difference is what happens to **upstream middleware's post-processing**: - -```python -class MiddlewareA(AgentMiddleware): - async def process(self, context, call_next): - print("A: before") - await call_next(context) - print("A: after") # Does this run? - -class MiddlewareB(AgentMiddleware): - async def process(self, context, call_next): - print("B: before") - context.result = "early result" - # Choose one: - return # Option 1 - # raise MiddlewareTermination() # Option 2 -``` - -With middleware registered as `[MiddlewareA, MiddlewareB]`: - -| Exit Method | Output | -|-------------|--------| -| `return` | `A: before` → `B: before` → `A: after` | -| `raise MiddlewareTermination` | `A: before` → `B: before` (no `A: after`) | - -**Use `return`** when you want upstream middleware to still process the result (e.g., logging, metrics). - -**Use `raise MiddlewareTermination`** when you want to completely bypass all remaining processing (e.g., blocking a request, returning cached response without any modification). - -## Calling `call_next()` or Not - -The decision to call `call_next(context)` determines whether downstream middleware and the actual operation execute: - -### Without calling `call_next()` - Skip downstream - -```python -async def process(self, context, call_next): - context.result = "replacement result" - return # Downstream middleware and actual execution are SKIPPED -``` - -- Downstream middleware: ❌ NOT executed -- Actual operation (LLM call, function invocation): ❌ NOT executed -- Upstream middleware post-processing: ✅ Still runs (unless `MiddlewareTermination` raised) -- Result: Whatever you set in `context.result` - -### With calling `call_next()` - Full execution - -```python -async def process(self, context, call_next): - # Pre-processing - await call_next(context) # Execute downstream + actual operation - # Post-processing (context.result now contains real result) - return -``` - -- Downstream middleware: ✅ Executed -- Actual operation: ✅ Executed -- Upstream middleware post-processing: ✅ Runs -- Result: The actual result (possibly modified in post-processing) - -### Summary Table - -| Exit Method | Call `call_next()`? | Downstream Executes? | Actual Op Executes? | Upstream Post-Processing? | -|-------------|----------------|---------------------|---------------------|--------------------------| -| `return` (or implicit) | Yes | ✅ | ✅ | ✅ Yes | -| `return` | No | ❌ | ❌ | ✅ Yes | -| `raise MiddlewareTermination` | No | ❌ | ❌ | ❌ No | -| `raise MiddlewareTermination` | Yes | ✅ | ✅ | ❌ No | -| `raise OtherException` | Either | Depends | Depends | ❌ No (exception propagates) | - -> **Note:** The first row (`return` after calling `call_next()`) is the default behavior. Python functions implicitly return `None` at the end, so simply calling `await call_next(context)` without an explicit `return` statement achieves this pattern. - -## Streaming vs Non-Streaming - -The `run()` method handles streaming and non-streaming differently: - -### Non-Streaming (`stream=False`) - -Returns `Awaitable[AgentResponse]`: - -```python -async def _run_non_streaming(): - ctx = await self._prepare_run_context(...) # Async preparation - response = await self.chat_client.get_response(stream=False, ...) - await self._finalize_response_and_update_thread(...) - return AgentResponse(...) -``` - -### Streaming (`stream=True`) - -Returns `ResponseStream[AgentResponseUpdate, AgentResponse]` **synchronously**: - -```python -# Async preparation is deferred using ResponseStream.from_awaitable() -async def _get_stream(): - ctx = await self._prepare_run_context(...) # Deferred until iteration - return self.chat_client.get_response(stream=True, ...) - -return ( - ResponseStream.from_awaitable(_get_stream()) - .map( - transform=map_chat_to_agent_update, # Transform each update - finalizer=self._finalize_response_updates, # Build final response - ) - .with_result_hook(_post_hook) # Post-processing after finalization -) -``` - -Key points: -- `ResponseStream.from_awaitable()` wraps an async function, deferring execution until the stream is consumed -- `.map()` transforms `ChatResponseUpdate` → `AgentResponseUpdate` and provides the finalizer -- `.with_result_hook()` runs after finalization (e.g., notify thread of new messages) - -## See Also - -- [Middleware Samples](../../getting_started/middleware/) - Examples of custom middleware -- [Function Tool Samples](../../getting_started/tools/) - Creating and using tools diff --git a/python/samples/demos/chatkit-integration/uploads/atc_3df183a3 b/python/samples/demos/chatkit-integration/uploads/atc_3df183a3 new file mode 100644 index 0000000000..f760b6553f Binary files /dev/null and b/python/samples/demos/chatkit-integration/uploads/atc_3df183a3 differ diff --git a/python/samples/demos/chatkit-integration/uploads/atc_967c57ef b/python/samples/demos/chatkit-integration/uploads/atc_967c57ef new file mode 100644 index 0000000000..f760b6553f Binary files /dev/null and b/python/samples/demos/chatkit-integration/uploads/atc_967c57ef differ diff --git a/python/samples/demos/chatkit-integration/uploads/atc_f4a18d5e b/python/samples/demos/chatkit-integration/uploads/atc_f4a18d5e new file mode 100644 index 0000000000..f760b6553f Binary files /dev/null and b/python/samples/demos/chatkit-integration/uploads/atc_f4a18d5e differ diff --git a/python/samples/demos/chatkit-integration/uploads/atc_fa77f9c0 b/python/samples/demos/chatkit-integration/uploads/atc_fa77f9c0 new file mode 100644 index 0000000000..f760b6553f Binary files /dev/null and b/python/samples/demos/chatkit-integration/uploads/atc_fa77f9c0 differ diff --git a/python/samples/getting_started/agents/README.md b/python/samples/getting_started/agents/README.md deleted file mode 100644 index 6f1601cdc4..0000000000 --- a/python/samples/getting_started/agents/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Agent Examples - -This folder contains examples demonstrating how to create and use agents with different chat clients from the Agent Framework. Each sub-folder focuses on a specific provider and client type, showing various capabilities like function tools, code interpreter, thread management, structured outputs, image processing, web search, Model Context Protocol (MCP) integration, and more. - -## Examples by Provider - -### Azure AI Foundry Examples - -| Folder | Description | -|--------|-------------| -| **[`azure_ai_agent/`](azure_ai_agent/)** | Create agents using Azure AI Agent Service (based on `azure-ai-agents` V1 package) including function tools, code interpreter, MCP integration, thread management, and more. | -| **[`azure_ai/`](azure_ai/)** | Create agents using Azure AI Agent Service (based on `azure-ai-projects` [V2](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/CHANGELOG.md#200b1-2025-11-11) package) including function tools, code interpreter, MCP integration, thread management, and more. | - -### Microsoft Copilot Studio Examples - -| Folder | Description | -|--------|-------------| -| **[`copilotstudio/`](copilotstudio/)** | Create agents using Microsoft Copilot Studio with streaming and non-streaming responses, authentication handling, and explicit configuration options | - -### Azure OpenAI Examples - -| Folder | Description | -|--------|-------------| -| **[`azure_openai/`](azure_openai/)** | Create agents using Azure OpenAI APIs with multiple client types (Assistants, Chat, and Responses clients) supporting function tools, code interpreter, thread management, and more | - -### OpenAI Examples - -| Folder | Description | -|--------|-------------| -| **[`openai/`](openai/)** | Create agents using OpenAI APIs with comprehensive examples including Assistants, Chat, and Responses clients featuring function tools, code interpreter, file search, web search, MCP integration, image analysis/generation, structured outputs, reasoning, and thread management | - -### Anthropic Examples - -| Folder | Description | -|--------|-------------| -| **[`anthropic/`](anthropic/)** | Create agents using Anthropic models through OpenAI Chat Client configuration, demonstrating tool calling capabilities | - -### Custom Implementation Examples - -| Folder | Description | -|--------|-------------| -| **[`custom/`](custom/)** | Create custom agents and chat clients by extending the base framework classes, showing complete control over agent behavior and backend integration | diff --git a/python/samples/getting_started/agents/a2a/agent_with_a2a.py b/python/samples/getting_started/agents/a2a/agent_with_a2a.py deleted file mode 100644 index 2f0e6b33d2..0000000000 --- a/python/samples/getting_started/agents/a2a/agent_with_a2a.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os - -import httpx -from a2a.client import A2ACardResolver -from agent_framework.a2a import A2AAgent - -""" -Agent2Agent (A2A) Protocol Integration Sample - -This sample demonstrates how to connect to and communicate with external agents using -the A2A protocol. A2A is a standardized communication protocol that enables interoperability -between different agent systems, allowing agents built with different frameworks and -technologies to communicate seamlessly. - -For more information about the A2A protocol specification, visit: https://a2a-protocol.org/latest/ - -Key concepts demonstrated: -- Discovering A2A-compliant agents using AgentCard resolution -- Creating A2AAgent instances to wrap external A2A endpoints -- Converting Agent Framework messages to A2A protocol format -- Handling A2A responses (Messages and Tasks) back to framework types - -To run this sample: -1. Set the A2A_AGENT_HOST environment variable to point to an A2A-compliant agent endpoint - Example: export A2A_AGENT_HOST="https://your-a2a-agent.example.com" -2. Ensure the target agent exposes its AgentCard at /.well-known/agent.json -3. Run: uv run python agent_with_a2a.py - -The sample will: -- Connect to the specified A2A agent endpoint -- Retrieve and parse the agent's capabilities via its AgentCard -- Send a message using the A2A protocol -- Display the agent's response - -Visit the README.md for more details on setting up and running A2A agents. -""" - - -async def main(): - """Demonstrates connecting to and communicating with an A2A-compliant agent.""" - # Get A2A agent host from environment - a2a_agent_host = os.getenv("A2A_AGENT_HOST") - if not a2a_agent_host: - raise ValueError("A2A_AGENT_HOST environment variable is not set") - - print(f"Connecting to A2A agent at: {a2a_agent_host}") - - # Initialize A2ACardResolver - async with httpx.AsyncClient(timeout=60.0) as http_client: - resolver = A2ACardResolver(httpx_client=http_client, base_url=a2a_agent_host) - - # Get agent card - agent_card = await resolver.get_agent_card() - print(f"Found agent: {agent_card.name} - {agent_card.description}") - - # Create A2A agent instance - agent = A2AAgent( - name=agent_card.name, - description=agent_card.description, - agent_card=agent_card, - url=a2a_agent_host, - ) - - # Invoke the agent and output the result - print("\nSending message to A2A agent...") - response = await agent.run("What are your capabilities?") - - # Print the response - print("\nAgent Response:") - for message in response.messages: - print(message.text) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_thread.py b/python/samples/getting_started/agents/azure_ai/azure_ai_with_thread.py deleted file mode 100644 index 2330f9a19d..0000000000 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_thread.py +++ /dev/null @@ -1,162 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from pydantic import Field - -""" -Azure AI Agent with Thread Management Example - -This sample demonstrates thread management with Azure AI Agent, showing -persistent conversation capabilities using service-managed threads as well as storing messages in-memory. -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production -# See: -# samples/getting_started/tools/function_tool_with_approval.py -# samples/getting_started/tools/function_tool_with_approval_and_threads.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." - - -async def example_with_automatic_thread_creation() -> None: - """Example showing automatic thread creation.""" - print("=== Automatic Thread Creation Example ===") - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="BasicWeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # First conversation - no thread provided, will be created automatically - query1 = "What's the weather like in Seattle?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1.text}") - - # Second conversation - still no thread provided, will create another new thread - query2 = "What was the last city I asked about?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2) - print(f"Agent: {result2.text}") - print("Note: Each call creates a separate thread, so the agent doesn't remember previous context.\n") - - -async def example_with_thread_persistence_in_memory() -> None: - """ - Example showing thread persistence across multiple conversations. - In this example, messages are stored in-memory. - """ - print("=== Thread Persistence Example (In-Memory) ===") - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="BasicWeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Create a new thread that will be reused - thread = agent.get_new_thread() - - # First conversation - query1 = "What's the weather like in Tokyo?" - print(f"User: {query1}") - result1 = await agent.run(query1, thread=thread, options={"store": False}) - print(f"Agent: {result1.text}") - - # Second conversation using the same thread - maintains context - query2 = "How about London?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2, thread=thread, options={"store": False}) - print(f"Agent: {result2.text}") - - # Third conversation - agent should remember both previous cities - query3 = "Which of the cities I asked about has better weather?" - print(f"\nUser: {query3}") - result3 = await agent.run(query3, thread=thread, options={"store": False}) - print(f"Agent: {result3.text}") - print("Note: The agent remembers context from previous messages in the same thread.\n") - - -async def example_with_existing_thread_id() -> None: - """ - Example showing how to work with an existing thread ID from the service. - In this example, messages are stored on the server. - """ - print("=== Existing Thread ID Example ===") - - # First, create a conversation and capture the thread ID - existing_thread_id = None - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="BasicWeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Start a conversation and get the thread ID - thread = agent.get_new_thread() - - query1 = "What's the weather in Paris?" - print(f"User: {query1}") - result1 = await agent.run(query1, thread=thread) - print(f"Agent: {result1.text}") - - # The thread ID is set after the first response - existing_thread_id = thread.service_thread_id - print(f"Thread ID: {existing_thread_id}") - - if existing_thread_id: - print("\n--- Continuing with the same thread ID in a new agent instance ---") - - # Create a new agent instance from the same provider - agent2 = await provider.create_agent( - name="BasicWeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Create a thread with the existing ID - thread = agent2.get_new_thread(service_thread_id=existing_thread_id) - - query2 = "What was the last city I asked about?" - print(f"User: {query2}") - result2 = await agent2.run(query2, thread=thread) - print(f"Agent: {result2.text}") - print("Note: The agent continues the conversation from the previous thread by using thread ID.\n") - - -async def main() -> None: - print("=== Azure AI Agent Thread Management Examples ===\n") - - await example_with_automatic_thread_creation() - await example_with_thread_persistence_in_memory() - await example_with_existing_thread_id() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/agents/azure_openai/azure_assistants_with_thread.py b/python/samples/getting_started/agents/azure_openai/azure_assistants_with_thread.py deleted file mode 100644 index 793f8260c3..0000000000 --- a/python/samples/getting_started/agents/azure_openai/azure_assistants_with_thread.py +++ /dev/null @@ -1,144 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from random import randint -from typing import Annotated - -from agent_framework import AgentThread, ChatAgent, tool -from agent_framework.azure import AzureOpenAIAssistantsClient -from azure.identity import AzureCliCredential -from pydantic import Field - -""" -Azure OpenAI Assistants with Thread Management Example - -This sample demonstrates thread management with Azure OpenAI Assistants, comparing -automatic thread creation with explicit thread management for persistent context. -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." - - -async def example_with_automatic_thread_creation() -> None: - """Example showing automatic thread creation (service-managed thread).""" - print("=== Automatic Thread Creation Example ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ChatAgent( - chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) as agent: - # First conversation - no thread provided, will be created automatically - query1 = "What's the weather like in Seattle?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1.text}") - - # Second conversation - still no thread provided, will create another new thread - query2 = "What was the last city I asked about?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2) - print(f"Agent: {result2.text}") - print("Note: Each call creates a separate thread, so the agent doesn't remember previous context.\n") - - -async def example_with_thread_persistence() -> None: - """Example showing thread persistence across multiple conversations.""" - print("=== Thread Persistence Example ===") - print("Using the same thread across multiple conversations to maintain context.\n") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ChatAgent( - chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) as agent: - # Create a new thread that will be reused - thread = agent.get_new_thread() - - # First conversation - query1 = "What's the weather like in Tokyo?" - print(f"User: {query1}") - result1 = await agent.run(query1, thread=thread) - print(f"Agent: {result1.text}") - - # Second conversation using the same thread - maintains context - query2 = "How about London?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2, thread=thread) - print(f"Agent: {result2.text}") - - # Third conversation - agent should remember both previous cities - query3 = "Which of the cities I asked about has better weather?" - print(f"\nUser: {query3}") - result3 = await agent.run(query3, thread=thread) - print(f"Agent: {result3.text}") - print("Note: The agent remembers context from previous messages in the same thread.\n") - - -async def example_with_existing_thread_id() -> None: - """Example showing how to work with an existing thread ID from the service.""" - print("=== Existing Thread ID Example ===") - print("Using a specific thread ID to continue an existing conversation.\n") - - # First, create a conversation and capture the thread ID - existing_thread_id = None - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ChatAgent( - chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) as agent: - # Start a conversation and get the thread ID - thread = agent.get_new_thread() - query1 = "What's the weather in Paris?" - print(f"User: {query1}") - result1 = await agent.run(query1, thread=thread) - print(f"Agent: {result1.text}") - - # The thread ID is set after the first response - existing_thread_id = thread.service_thread_id - print(f"Thread ID: {existing_thread_id}") - - if existing_thread_id: - print("\n--- Continuing with the same thread ID in a new agent instance ---") - - # Create a new agent instance but use the existing thread ID - async with ChatAgent( - chat_client=AzureOpenAIAssistantsClient(thread_id=existing_thread_id, credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) as agent: - # Create a thread with the existing ID - thread = AgentThread(service_thread_id=existing_thread_id) - - query2 = "What was the last city I asked about?" - print(f"User: {query2}") - result2 = await agent.run(query2, thread=thread) - print(f"Agent: {result2.text}") - print("Note: The agent continues the conversation from the previous thread.\n") - - -async def main() -> None: - print("=== Azure OpenAI Assistants Chat Client Agent Thread Management Examples ===\n") - - await example_with_automatic_thread_creation() - await example_with_thread_persistence() - await example_with_existing_thread_id() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/agents/azure_openai/azure_chat_client_with_thread.py b/python/samples/getting_started/agents/azure_openai/azure_chat_client_with_thread.py deleted file mode 100644 index 08ada3ba97..0000000000 --- a/python/samples/getting_started/agents/azure_openai/azure_chat_client_with_thread.py +++ /dev/null @@ -1,155 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from random import randint -from typing import Annotated - -from agent_framework import AgentThread, ChatAgent, ChatMessageStore, tool -from agent_framework.azure import AzureOpenAIChatClient -from azure.identity import AzureCliCredential -from pydantic import Field - -""" -Azure OpenAI Chat Client with Thread Management Example - -This sample demonstrates thread management with Azure OpenAI Chat Client, comparing -automatic thread creation with explicit thread management for persistent context. -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." - - -async def example_with_automatic_thread_creation() -> None: - """Example showing automatic thread creation (service-managed thread).""" - print("=== Automatic Thread Creation Example ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - agent = ChatAgent( - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # First conversation - no thread provided, will be created automatically - query1 = "What's the weather like in Seattle?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1.text}") - - # Second conversation - still no thread provided, will create another new thread - query2 = "What was the last city I asked about?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2) - print(f"Agent: {result2.text}") - print("Note: Each call creates a separate thread, so the agent doesn't remember previous context.\n") - - -async def example_with_thread_persistence() -> None: - """Example showing thread persistence across multiple conversations.""" - print("=== Thread Persistence Example ===") - print("Using the same thread across multiple conversations to maintain context.\n") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - agent = ChatAgent( - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Create a new thread that will be reused - thread = agent.get_new_thread() - - # First conversation - query1 = "What's the weather like in Tokyo?" - print(f"User: {query1}") - result1 = await agent.run(query1, thread=thread) - print(f"Agent: {result1.text}") - - # Second conversation using the same thread - maintains context - query2 = "How about London?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2, thread=thread) - print(f"Agent: {result2.text}") - - # Third conversation - agent should remember both previous cities - query3 = "Which of the cities I asked about has better weather?" - print(f"\nUser: {query3}") - result3 = await agent.run(query3, thread=thread) - print(f"Agent: {result3.text}") - print("Note: The agent remembers context from previous messages in the same thread.\n") - - -async def example_with_existing_thread_messages() -> None: - """Example showing how to work with existing thread messages for Azure.""" - print("=== Existing Thread Messages Example ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - agent = ChatAgent( - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Start a conversation and build up message history - thread = agent.get_new_thread() - - query1 = "What's the weather in Paris?" - print(f"User: {query1}") - result1 = await agent.run(query1, thread=thread) - print(f"Agent: {result1.text}") - - # The thread now contains the conversation history in memory - if thread.message_store: - messages = await thread.message_store.list_messages() - print(f"Thread contains {len(messages or [])} messages") - - print("\n--- Continuing with the same thread in a new agent instance ---") - - # Create a new agent instance but use the existing thread with its message history - new_agent = ChatAgent( - chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Use the same thread object which contains the conversation history - query2 = "What was the last city I asked about?" - print(f"User: {query2}") - result2 = await new_agent.run(query2, thread=thread) - print(f"Agent: {result2.text}") - print("Note: The agent continues the conversation using the local message history.\n") - - print("\n--- Alternative: Creating a new thread from existing messages ---") - - # You can also create a new thread from existing messages - messages = await thread.message_store.list_messages() if thread.message_store else [] - new_thread = AgentThread(message_store=ChatMessageStore(messages)) - - query3 = "How does the Paris weather compare to London?" - print(f"User: {query3}") - result3 = await new_agent.run(query3, thread=new_thread) - print(f"Agent: {result3.text}") - print("Note: This creates a new thread with the same conversation history.\n") - - -async def main() -> None: - print("=== Azure Chat Client Agent Thread Management Examples ===\n") - - await example_with_automatic_thread_creation() - await example_with_thread_persistence() - await example_with_existing_thread_messages() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_thread.py b/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_thread.py deleted file mode 100644 index 01ade8da6f..0000000000 --- a/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_thread.py +++ /dev/null @@ -1,153 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from random import randint -from typing import Annotated - -from agent_framework import AgentThread, ChatAgent, tool -from agent_framework.azure import AzureOpenAIResponsesClient -from azure.identity import AzureCliCredential -from pydantic import Field - -""" -Azure OpenAI Responses Client with Thread Management Example - -This sample demonstrates thread management with Azure OpenAI Responses Client, comparing -automatic thread creation with explicit thread management for persistent context. -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." - - -async def example_with_automatic_thread_creation() -> None: - """Example showing automatic thread creation.""" - print("=== Automatic Thread Creation Example ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - agent = ChatAgent( - chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # First conversation - no thread provided, will be created automatically - query1 = "What's the weather like in Seattle?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1.text}") - - # Second conversation - still no thread provided, will create another new thread - query2 = "What was the last city I asked about?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2) - print(f"Agent: {result2.text}") - print("Note: Each call creates a separate thread, so the agent doesn't remember previous context.\n") - - -async def example_with_thread_persistence_in_memory() -> None: - """ - Example showing thread persistence across multiple conversations. - In this example, messages are stored in-memory. - """ - print("=== Thread Persistence Example (In-Memory) ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - agent = ChatAgent( - chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Create a new thread that will be reused - thread = agent.get_new_thread() - - # First conversation - query1 = "What's the weather like in Tokyo?" - print(f"User: {query1}") - result1 = await agent.run(query1, thread=thread) - print(f"Agent: {result1.text}") - - # Second conversation using the same thread - maintains context - query2 = "How about London?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2, thread=thread) - print(f"Agent: {result2.text}") - - # Third conversation - agent should remember both previous cities - query3 = "Which of the cities I asked about has better weather?" - print(f"\nUser: {query3}") - result3 = await agent.run(query3, thread=thread) - print(f"Agent: {result3.text}") - print("Note: The agent remembers context from previous messages in the same thread.\n") - - -async def example_with_existing_thread_id() -> None: - """ - Example showing how to work with an existing thread ID from the service. - In this example, messages are stored on the server using Azure OpenAI conversation state. - """ - print("=== Existing Thread ID Example ===") - - # First, create a conversation and capture the thread ID - existing_thread_id = None - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - agent = ChatAgent( - chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Start a conversation and get the thread ID - thread = agent.get_new_thread() - - query1 = "What's the weather in Paris?" - print(f"User: {query1}") - # Enable Azure OpenAI conversation state by setting `store` parameter to True - result1 = await agent.run(query1, thread=thread, store=True) - print(f"Agent: {result1.text}") - - # The thread ID is set after the first response - existing_thread_id = thread.service_thread_id - print(f"Thread ID: {existing_thread_id}") - - if existing_thread_id: - print("\n--- Continuing with the same thread ID in a new agent instance ---") - - agent = ChatAgent( - chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Create a thread with the existing ID - thread = AgentThread(service_thread_id=existing_thread_id) - - query2 = "What was the last city I asked about?" - print(f"User: {query2}") - result2 = await agent.run(query2, thread=thread, store=True) - print(f"Agent: {result2.text}") - print("Note: The agent continues the conversation from the previous thread by using thread ID.\n") - - -async def main() -> None: - print("=== Azure OpenAI Response Client Agent Thread Management Examples ===\n") - - await example_with_automatic_thread_creation() - await example_with_thread_persistence_in_memory() - await example_with_existing_thread_id() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/agents/openai/openai_chat_client_with_thread.py b/python/samples/getting_started/agents/openai/openai_chat_client_with_thread.py deleted file mode 100644 index f7a824c370..0000000000 --- a/python/samples/getting_started/agents/openai/openai_chat_client_with_thread.py +++ /dev/null @@ -1,149 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from random import randint -from typing import Annotated - -from agent_framework import AgentThread, ChatAgent, ChatMessageStore, tool -from agent_framework.openai import OpenAIChatClient -from pydantic import Field - -""" -OpenAI Chat Client with Thread Management Example - -This sample demonstrates thread management with OpenAI Chat Client, showing -conversation threads and message history preservation across interactions. -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." - - -async def example_with_automatic_thread_creation() -> None: - """Example showing automatic thread creation (service-managed thread).""" - print("=== Automatic Thread Creation Example ===") - - agent = ChatAgent( - chat_client=OpenAIChatClient(), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # First conversation - no thread provided, will be created automatically - query1 = "What's the weather like in Seattle?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1.text}") - - # Second conversation - still no thread provided, will create another new thread - query2 = "What was the last city I asked about?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2) - print(f"Agent: {result2.text}") - print("Note: Each call creates a separate thread, so the agent doesn't remember previous context.\n") - - -async def example_with_thread_persistence() -> None: - """Example showing thread persistence across multiple conversations.""" - print("=== Thread Persistence Example ===") - print("Using the same thread across multiple conversations to maintain context.\n") - - agent = ChatAgent( - chat_client=OpenAIChatClient(), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Create a new thread that will be reused - thread = agent.get_new_thread() - - # First conversation - query1 = "What's the weather like in Tokyo?" - print(f"User: {query1}") - result1 = await agent.run(query1, thread=thread) - print(f"Agent: {result1.text}") - - # Second conversation using the same thread - maintains context - query2 = "How about London?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2, thread=thread) - print(f"Agent: {result2.text}") - - # Third conversation - agent should remember both previous cities - query3 = "Which of the cities I asked about has better weather?" - print(f"\nUser: {query3}") - result3 = await agent.run(query3, thread=thread) - print(f"Agent: {result3.text}") - print("Note: The agent remembers context from previous messages in the same thread.\n") - - -async def example_with_existing_thread_messages() -> None: - """Example showing how to work with existing thread messages for OpenAI.""" - print("=== Existing Thread Messages Example ===") - - agent = ChatAgent( - chat_client=OpenAIChatClient(), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Start a conversation and build up message history - thread = agent.get_new_thread() - - query1 = "What's the weather in Paris?" - print(f"User: {query1}") - result1 = await agent.run(query1, thread=thread) - print(f"Agent: {result1.text}") - - # The thread now contains the conversation history in memory - if thread.message_store: - messages = await thread.message_store.list_messages() - print(f"Thread contains {len(messages or [])} messages") - - print("\n--- Continuing with the same thread in a new agent instance ---") - - # Create a new agent instance but use the existing thread with its message history - new_agent = ChatAgent( - chat_client=OpenAIChatClient(), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Use the same thread object which contains the conversation history - query2 = "What was the last city I asked about?" - print(f"User: {query2}") - result2 = await new_agent.run(query2, thread=thread) - print(f"Agent: {result2.text}") - print("Note: The agent continues the conversation using the local message history.\n") - - print("\n--- Alternative: Creating a new thread from existing messages ---") - - # You can also create a new thread from existing messages - messages = await thread.message_store.list_messages() if thread.message_store else [] - - new_thread = AgentThread(message_store=ChatMessageStore(messages)) - - query3 = "How does the Paris weather compare to London?" - print(f"User: {query3}") - result3 = await new_agent.run(query3, thread=new_thread) - print(f"Agent: {result3.text}") - print("Note: This creates a new thread with the same conversation history.\n") - - -async def main() -> None: - print("=== OpenAI Chat Client Agent Thread Management Examples ===\n") - - await example_with_automatic_thread_creation() - await example_with_thread_persistence() - await example_with_existing_thread_messages() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_image_generation.py b/python/samples/getting_started/agents/openai/openai_responses_client_image_generation.py deleted file mode 100644 index 7d3c724b08..0000000000 --- a/python/samples/getting_started/agents/openai/openai_responses_client_image_generation.py +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import base64 - -from agent_framework import HostedImageGenerationTool -from agent_framework.openai import OpenAIResponsesClient - -""" -OpenAI Responses Client Image Generation Example - -This sample demonstrates how to generate images using OpenAI's DALL-E models -through the Responses Client. Image generation capabilities enable AI to create visual content from text, -making it ideal for creative applications, content creation, design prototyping, -and automated visual asset generation. -""" - - -def show_image_info(data_uri: str) -> None: - """Display information about the generated image.""" - try: - # Extract format and size info from data URI - if data_uri.startswith("data:image/"): - format_info = data_uri.split(";")[0].split("/")[1] - base64_data = data_uri.split(",", 1)[1] - image_bytes = base64.b64decode(base64_data) - size_kb = len(image_bytes) / 1024 - - print(" Image successfully generated!") - print(f" Format: {format_info.upper()}") - print(f" Size: {size_kb:.1f} KB") - print(f" Data URI length: {len(data_uri)} characters") - print("") - print(" To save and view the image:") - print(' 1. Install Pillow: "pip install pillow" or "uv add pillow"') - print(" 2. Use the data URI in your code to save/display the image") - print(" 3. Or copy the base64 data to an online base64 image decoder") - else: - print(f" Image URL generated: {data_uri}") - print(" You can open this URL in a browser to view the image") - - except Exception as e: - print(f" Error processing image data: {e}") - print(" Image generated but couldn't parse details") - - -async def main() -> None: - print("=== OpenAI Responses Image Generation Agent Example ===") - - # Create an agent with customized image generation options - agent = OpenAIResponsesClient().as_agent( - instructions="You are a helpful AI that can generate images.", - tools=[ - HostedImageGenerationTool( - options={ - "size": "1024x1024", - "output_format": "webp", - } - ) - ], - ) - - query = "Generate a nice beach scenery with blue skies in summer time." - print(f"User: {query}") - print("Generating image with parameters: 1024x1024 size, transparent background, low quality, WebP format...") - - result = await agent.run(query) - print(f"Agent: {result.text}") - - # Show information about the generated image - for message in result.messages: - for content in message.contents: - if content.type == "image_generation_tool_result" and content.outputs: - for output in content.outputs: - if output.type in ("data", "uri") and output.uri: - show_image_info(output.uri) - break - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_thread.py b/python/samples/getting_started/agents/openai/openai_responses_client_with_thread.py deleted file mode 100644 index e17c2d2748..0000000000 --- a/python/samples/getting_started/agents/openai/openai_responses_client_with_thread.py +++ /dev/null @@ -1,145 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from random import randint -from typing import Annotated - -from agent_framework import AgentThread, ChatAgent, tool -from agent_framework.openai import OpenAIResponsesClient -from pydantic import Field - -""" -OpenAI Responses Client with Thread Management Example - -This sample demonstrates thread management with OpenAI Responses Client, showing -persistent conversation context and simplified response handling. -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." - - -async def example_with_automatic_thread_creation() -> None: - """Example showing automatic thread creation.""" - print("=== Automatic Thread Creation Example ===") - - agent = ChatAgent( - chat_client=OpenAIResponsesClient(), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # First conversation - no thread provided, will be created automatically - query1 = "What's the weather like in Seattle?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1.text}") - - # Second conversation - still no thread provided, will create another new thread - query2 = "What was the last city I asked about?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2) - print(f"Agent: {result2.text}") - print("Note: Each call creates a separate thread, so the agent doesn't remember previous context.\n") - - -async def example_with_thread_persistence_in_memory() -> None: - """ - Example showing thread persistence across multiple conversations. - In this example, messages are stored in-memory. - """ - print("=== Thread Persistence Example (In-Memory) ===") - - agent = ChatAgent( - chat_client=OpenAIResponsesClient(), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Create a new thread that will be reused - thread = agent.get_new_thread() - - # First conversation - query1 = "What's the weather like in Tokyo?" - print(f"User: {query1}") - result1 = await agent.run(query1, thread=thread, store=False) - print(f"Agent: {result1.text}") - - # Second conversation using the same thread - maintains context - query2 = "How about London?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2, thread=thread, store=False) - print(f"Agent: {result2.text}") - - # Third conversation - agent should remember both previous cities - query3 = "Which of the cities I asked about has better weather?" - print(f"\nUser: {query3}") - result3 = await agent.run(query3, thread=thread, store=False) - print(f"Agent: {result3.text}") - print("Note: The agent remembers context from previous messages in the same thread.\n") - - -async def example_with_existing_thread_id() -> None: - """ - Example showing how to work with an existing thread ID from the service. - In this example, messages are stored on the server using OpenAI conversation state. - """ - print("=== Existing Thread ID Example ===") - - # First, create a conversation and capture the thread ID - existing_thread_id = None - - agent = ChatAgent( - chat_client=OpenAIResponsesClient(), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Start a conversation and get the thread ID - thread = agent.get_new_thread() - - query1 = "What's the weather in Paris?" - print(f"User: {query1}") - result1 = await agent.run(query1, thread=thread) - print(f"Agent: {result1.text}") - - # The thread ID is set after the first response - existing_thread_id = thread.service_thread_id - print(f"Thread ID: {existing_thread_id}") - - if existing_thread_id: - print("\n--- Continuing with the same thread ID in a new agent instance ---") - - agent = ChatAgent( - chat_client=OpenAIResponsesClient(), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Create a thread with the existing ID - thread = AgentThread(service_thread_id=existing_thread_id) - - query2 = "What was the last city I asked about?" - print(f"User: {query2}") - result2 = await agent.run(query2, thread=thread) - print(f"Agent: {result2.text}") - print("Note: The agent continues the conversation from the previous thread by using thread ID.\n") - - -async def main() -> None: - print("=== OpenAI Response Client Agent Thread Management Examples ===\n") - - await example_with_automatic_thread_creation() - await example_with_thread_persistence_in_memory() - await example_with_existing_thread_id() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/context_providers/README.md b/python/samples/getting_started/context_providers/README.md deleted file mode 100644 index ddcc5ffe8d..0000000000 --- a/python/samples/getting_started/context_providers/README.md +++ /dev/null @@ -1,179 +0,0 @@ -# Context Provider Examples - -Context providers enable agents to maintain memory, retrieve relevant information, and enhance conversations with external context. The Agent Framework supports various context providers for different use cases, from simple in-memory storage to advanced persistent solutions with search capabilities. - -This folder contains examples demonstrating how to use different context providers with the Agent Framework. - -## Overview - -Context providers implement two key methods: - -- **`invoking`**: Called before the agent processes a request. Provides additional context, instructions, or retrieved information to enhance the agent's response. -- **`invoked`**: Called after the agent generates a response. Allows for storing information, updating memory, or performing post-processing. - -## Examples - -### Simple Context Provider - -| File | Description | Installation | -|------|-------------|--------------| -| [`simple_context_provider.py`](simple_context_provider.py) | Demonstrates building a custom context provider that extracts and stores user information (name and age) from conversations. Shows how to use structured output to extract data and provide dynamic instructions based on stored context. | No additional package required - uses core `agent-framework` | - -**Install:** -```bash -pip install agent-framework-azure-ai -``` - -### Azure AI Search - -| File | Description | -|------|-------------| -| [`azure_ai_search/azure_ai_with_search_context_agentic.py`](azure_ai_search/azure_ai_with_search_context_agentic.py) | **Agentic mode** (recommended for most scenarios): Uses Knowledge Bases in Azure AI Search for query planning and multi-hop reasoning. Provides more accurate results through intelligent retrieval. Slightly slower with more token consumption. | -| [`azure_ai_search/azure_ai_with_search_context_semantic.py`](azure_ai_search/azure_ai_with_search_context_semantic.py) | **Semantic mode** (fast queries): Fast hybrid search combining vector and keyword search with semantic ranking. Best for scenarios where speed is critical. | - -**Install:** -```bash -pip install agent-framework-azure-ai-search agent-framework-azure-ai -``` - -**Prerequisites:** -- Azure AI Search service with a search index -- Azure AI Foundry project with a model deployment -- For agentic mode: Azure OpenAI resource for Knowledge Base model calls -- Environment variables: `AZURE_SEARCH_ENDPOINT`, `AZURE_SEARCH_INDEX_NAME`, `AZURE_AI_PROJECT_ENDPOINT` - -**Key Concepts:** -- **Agentic mode**: Intelligent retrieval with multi-hop reasoning, better for complex queries -- **Semantic mode**: Fast hybrid search with semantic ranking, better for simple queries and speed - -### Mem0 - -The [mem0](mem0/) folder contains examples using Mem0, a self-improving memory layer that enables applications to have long-term memory capabilities. - -| File | Description | -|------|-------------| -| [`mem0/mem0_basic.py`](mem0/mem0_basic.py) | Basic example storing and retrieving user preferences across different conversation threads. | -| [`mem0/mem0_threads.py`](mem0/mem0_threads.py) | Advanced thread scoping strategies: global scope (memories shared), per-operation scope (memories isolated), and multiple agents with different memory configurations. | -| [`mem0/mem0_oss.py`](mem0/mem0_oss.py) | Using Mem0 Open Source self-hosted version as the context provider. | - -**Install:** -```bash -pip install agent-framework-mem0 -``` - -**Prerequisites:** -- Mem0 API key from [app.mem0.ai](https://app.mem0.ai/) OR self-host [Mem0 Open Source](https://docs.mem0.ai/open-source/overview) -- For Mem0 Platform: `MEM0_API_KEY` environment variable -- For Mem0 OSS: `OPENAI_API_KEY` for embedding generation - -**Key Concepts:** -- **Global Scope**: Memories shared across all conversation threads -- **Thread Scope**: Memories isolated per conversation thread -- **Memory Association**: Records can be associated with `user_id`, `agent_id`, `thread_id`, or `application_id` - -See the [mem0 README](mem0/README.md) for detailed documentation. - -### Redis - -The [redis](redis/) folder contains examples using Redis (RediSearch) for persistent, searchable memory with full-text and optional hybrid vector search. - -| File | Description | -|------|-------------| -| [`redis/redis_basics.py`](redis/redis_basics.py) | Standalone provider usage and agent integration. Demonstrates writing messages, full-text/hybrid search, persisting preferences, and tool output memory. | -| [`redis/redis_conversation.py`](redis/redis_conversation.py) | Conversational examples showing memory persistence across sessions. | -| [`redis/redis_threads.py`](redis/redis_threads.py) | Thread scoping: global scope, per-operation scope, and multiple agents with isolated memory via different `agent_id` values. | - -**Install:** -```bash -pip install agent-framework-redis -``` - -**Prerequisites:** -- Running Redis with RediSearch (Redis Stack or managed service) - - **Docker**: `docker run --name redis -p 6379:6379 -d redis:8.0.3` - - **Redis Cloud**: [redis.io/cloud](https://redis.io/cloud/) - - **Azure Managed Redis**: [Azure quickstart](https://learn.microsoft.com/azure/redis/quickstart-create-managed-redis) -- Optional: `OPENAI_API_KEY` for vector embeddings (hybrid search) - -**Key Concepts:** -- **Full-text search**: Fast keyword-based retrieval -- **Hybrid vector search**: Optional embeddings for semantic search (`vectorizer_choice="openai"` or `"hf"`) -- **Memory scoping**: Partition by `application_id`, `agent_id`, `user_id`, or `thread_id` -- **Thread scoping**: `scope_to_per_operation_thread_id=True` isolates memory per operation - -See the [redis README](redis/README.md) for detailed documentation. - -## Choosing a Context Provider - -| Provider | Use Case | Persistence | Search | Complexity | -|----------|----------|-------------|--------|------------| -| **Simple/Custom** | Learning, prototyping, simple memory needs | No (in-memory) | No | Low | -| **Azure AI Search** | RAG, document search, enterprise knowledge bases | Yes | Hybrid + Semantic | Medium | -| **Mem0** | Long-term user memory, preferences, personalization | Yes (cloud/self-hosted) | Semantic | Low-Medium | -| **Redis** | Fast retrieval, session memory, full-text + vector search | Yes | Full-text + Hybrid | Medium | - -## Common Patterns - -### 1. User Preference Memory -Store and retrieve user preferences, settings, or personal information across sessions. -- **Examples**: `simple_context_provider.py`, `mem0/mem0_basic.py`, `redis/redis_basics.py` - -### 2. Document Retrieval (RAG) -Retrieve relevant documents or knowledge base articles to answer questions. -- **Examples**: `azure_ai_search/azure_ai_with_search_context_*.py` - -### 3. Conversation History -Maintain conversation context across multiple turns and sessions. -- **Examples**: `redis/redis_conversation.py`, `mem0/mem0_threads.py` - -### 4. Thread Scoping -Isolate memory per conversation thread or share globally across threads. -- **Examples**: `mem0/mem0_threads.py`, `redis/redis_threads.py` - -### 5. Multi-Agent Memory -Different agents with isolated or shared memory configurations. -- **Examples**: `mem0/mem0_threads.py`, `redis/redis_threads.py` - -## Building Custom Context Providers - -To create a custom context provider, implement the `ContextProvider` protocol: - -```python -from agent_framework import ContextProvider, Context, ChatMessage -from collections.abc import MutableSequence, Sequence -from typing import Any - -class MyContextProvider(ContextProvider): - async def invoking( - self, - messages: ChatMessage | MutableSequence[ChatMessage], - **kwargs: Any - ) -> Context: - """Provide context before the agent processes the request.""" - # Return additional instructions, messages, or context - return Context(instructions="Additional instructions here") - - async def invoked( - self, - request_messages: ChatMessage | Sequence[ChatMessage], - response_messages: ChatMessage | Sequence[ChatMessage] | None = None, - invoke_exception: Exception | None = None, - **kwargs: Any, - ) -> None: - """Process the response after the agent generates it.""" - # Store information, update memory, etc. - pass - - def serialize(self) -> str: - """Serialize the provider state for persistence.""" - return "{}" -``` - -See `simple_context_provider.py` for a complete example. - -## Additional Resources - -- [Agent Framework Documentation](https://github.com/microsoft/agent-framework) -- [Azure AI Search Documentation](https://learn.microsoft.com/azure/search/) -- [Mem0 Documentation](https://docs.mem0.ai/) -- [Redis Documentation](https://redis.io/docs/) diff --git a/python/samples/getting_started/context_providers/aggregate_context_provider.py b/python/samples/getting_started/context_providers/aggregate_context_provider.py deleted file mode 100644 index 4d44c0766c..0000000000 --- a/python/samples/getting_started/context_providers/aggregate_context_provider.py +++ /dev/null @@ -1,276 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -""" -This sample demonstrates how to use an AggregateContextProvider to combine multiple context providers. - -The AggregateContextProvider is a convenience class that allows you to aggregate multiple -ContextProviders into a single provider. It delegates events to all providers and combines -their context before returning. - -You can use this implementation as-is, or implement your own aggregation logic. -""" - -import asyncio -import sys -from collections.abc import MutableSequence, Sequence -from contextlib import AsyncExitStack -from types import TracebackType -from typing import TYPE_CHECKING, Any, cast - -from agent_framework import ChatAgent, ChatMessage, Context, ContextProvider -from agent_framework.azure import AzureAIClient -from azure.identity.aio import AzureCliCredential - -if TYPE_CHECKING: - from agent_framework import ToolProtocol - -if sys.version_info >= (3, 12): - from typing import override # type: ignore # pragma: no cover -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 -else: - from typing_extensions import Self # pragma: no cover - - -# region AggregateContextProvider - - -class AggregateContextProvider(ContextProvider): - """A ContextProvider that contains multiple context providers. - - It delegates events to multiple context providers and aggregates responses from those - events before returning. This allows you to combine multiple context providers into a - single provider. - - Examples: - .. code-block:: python - - from agent_framework import ChatAgent - - # Create multiple context providers - provider1 = CustomContextProvider1() - provider2 = CustomContextProvider2() - provider3 = CustomContextProvider3() - - # Combine them using AggregateContextProvider - aggregate = AggregateContextProvider([provider1, provider2, provider3]) - - # Pass the aggregate to the agent - agent = ChatAgent(chat_client=client, name="assistant", context_provider=aggregate) - - # You can also add more providers later - provider4 = CustomContextProvider4() - aggregate.add(provider4) - """ - - def __init__(self, context_providers: ContextProvider | Sequence[ContextProvider] | None = None) -> None: - """Initialize the AggregateContextProvider with context providers. - - Args: - context_providers: The context provider(s) to add. - """ - if isinstance(context_providers, ContextProvider): - self.providers = [context_providers] - else: - self.providers = cast(list[ContextProvider], context_providers) or [] - self._exit_stack: AsyncExitStack | None = None - - def add(self, context_provider: ContextProvider) -> None: - """Add a new context provider. - - Args: - context_provider: The context provider to add. - """ - self.providers.append(context_provider) - - @override - async def thread_created(self, thread_id: str | None = None) -> None: - await asyncio.gather(*[x.thread_created(thread_id) for x in self.providers]) - - @override - async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context: - contexts = await asyncio.gather(*[provider.invoking(messages, **kwargs) for provider in self.providers]) - instructions: str = "" - return_messages: list[ChatMessage] = [] - tools: list["ToolProtocol"] = [] - for ctx in contexts: - if ctx.instructions: - instructions += ctx.instructions - if ctx.messages: - return_messages.extend(ctx.messages) - if ctx.tools: - tools.extend(ctx.tools) - return Context(instructions=instructions, messages=return_messages, tools=tools) - - @override - async def invoked( - self, - request_messages: ChatMessage | Sequence[ChatMessage], - response_messages: ChatMessage | Sequence[ChatMessage] | None = None, - invoke_exception: Exception | None = None, - **kwargs: Any, - ) -> None: - await asyncio.gather(*[ - x.invoked( - request_messages=request_messages, - response_messages=response_messages, - invoke_exception=invoke_exception, - **kwargs, - ) - for x in self.providers - ]) - - @override - async def __aenter__(self) -> "Self": - """Enter the async context manager and set up all providers. - - Returns: - The AggregateContextProvider instance for chaining. - """ - self._exit_stack = AsyncExitStack() - await self._exit_stack.__aenter__() - - # Enter all context providers - for provider in self.providers: - await self._exit_stack.enter_async_context(provider) - - return self - - @override - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - """Exit the async context manager and clean up all providers. - - Args: - exc_type: The exception type if an exception occurred, None otherwise. - exc_val: The exception value if an exception occurred, None otherwise. - exc_tb: The exception traceback if an exception occurred, None otherwise. - """ - if self._exit_stack is not None: - await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb) - self._exit_stack = None - - -# endregion - - -# region Example Context Providers - - -class TimeContextProvider(ContextProvider): - """A simple context provider that adds time-related instructions.""" - - @override - async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context: - from datetime import datetime - - current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - return Context(instructions=f"The current date and time is: {current_time}. ") - - -class PersonaContextProvider(ContextProvider): - """A context provider that adds a persona to the agent.""" - - def __init__(self, persona: str): - self.persona = persona - - @override - async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context: - return Context(instructions=f"Your persona: {self.persona}. ") - - -class PreferencesContextProvider(ContextProvider): - """A context provider that adds user preferences.""" - - def __init__(self): - self.preferences: dict[str, str] = {} - - @override - async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context: - if not self.preferences: - return Context() - prefs_str = ", ".join(f"{k}: {v}" for k, v in self.preferences.items()) - return Context(instructions=f"User preferences: {prefs_str}. ") - - @override - async def invoked( - self, - request_messages: ChatMessage | Sequence[ChatMessage], - response_messages: ChatMessage | Sequence[ChatMessage] | None = None, - invoke_exception: Exception | None = None, - **kwargs: Any, - ) -> None: - # Simple example: extract and store preferences from user messages - # In a real implementation, you might use structured extraction - msgs = [request_messages] if isinstance(request_messages, ChatMessage) else list(request_messages) - - for msg in msgs: - content = msg.text if hasattr(msg, "text") else "" - # Very simple extraction - in production, use LLM-based extraction - if isinstance(content, str) and "prefer" in content.lower() and ":" in content: - parts = content.split(":") - if len(parts) >= 2: - key = parts[0].strip().lower().replace("i prefer ", "") - value = parts[1].strip() - self.preferences[key] = value - - -# endregion - - -# region Main - - -async def main(): - """Demonstrate using AggregateContextProvider to combine multiple providers.""" - async with AzureCliCredential() as credential: - chat_client = AzureAIClient(credential=credential) - - # Create individual context providers - time_provider = TimeContextProvider() - persona_provider = PersonaContextProvider("You are a helpful and friendly AI assistant named Max.") - preferences_provider = PreferencesContextProvider() - - # Combine them using AggregateContextProvider - aggregate_provider = AggregateContextProvider([ - time_provider, - persona_provider, - preferences_provider, - ]) - - # Create the agent with the aggregate provider - async with ChatAgent( - chat_client=chat_client, - instructions="You are a helpful assistant.", - context_provider=aggregate_provider, - ) as agent: - # Create a new thread for the conversation - thread = agent.get_new_thread() - - # First message - the agent should include time and persona context - print("User: Hello! Who are you?") - result = await agent.run("Hello! Who are you?", thread=thread) - print(f"Agent: {result}\n") - - # Set a preference - print("User: I prefer language: formal English") - result = await agent.run("I prefer language: formal English", thread=thread) - print(f"Agent: {result}\n") - - # Ask something - the agent should now include the preference - print("User: Can you tell me a fun fact?") - result = await agent.run("Can you tell me a fun fact?", thread=thread) - print(f"Agent: {result}\n") - - # Show what the aggregate provider is tracking - print(f"\nPreferences tracked: {preferences_provider.preferences}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/middleware/README.md b/python/samples/getting_started/middleware/README.md deleted file mode 100644 index 659e81647a..0000000000 --- a/python/samples/getting_started/middleware/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# Middleware Examples - -This folder contains examples demonstrating various middleware patterns with the Agent Framework. Middleware allows you to intercept and modify behavior at different execution stages, including agent runs, function calls, and chat interactions. - -## Examples - -| File | Description | -|------|-------------| -| [`function_based_middleware.py`](function_based_middleware.py) | Demonstrates how to implement middleware using simple async functions instead of classes. Shows security validation, logging, and performance monitoring middleware. Function-based middleware is ideal for simple, stateless operations and provides a lightweight approach. | -| [`class_based_middleware.py`](class_based_middleware.py) | Shows how to implement middleware using class-based approach by inheriting from `AgentMiddleware` and `FunctionMiddleware` base classes. Includes security checks for sensitive information and detailed function execution logging with timing. | -| [`decorator_middleware.py`](decorator_middleware.py) | Demonstrates how to use `@agent_middleware` and `@function_middleware` decorators to explicitly mark middleware functions without requiring type annotations. Shows different middleware detection scenarios and explicit decorator usage. | -| [`middleware_termination.py`](middleware_termination.py) | Shows how middleware can terminate execution using the `context.terminate` flag. Includes examples of pre-termination (prevents agent processing) and post-termination (allows processing but stops further execution). Useful for security checks, rate limiting, or early exit conditions. | -| [`exception_handling_with_middleware.py`](exception_handling_with_middleware.py) | Demonstrates how to use middleware for centralized exception handling in function calls. Shows how to catch exceptions from functions, provide graceful error responses, and override function results when errors occur to provide user-friendly messages. | -| [`override_result_with_middleware.py`](override_result_with_middleware.py) | Shows how to use middleware to intercept and modify function results after execution, supporting both regular and streaming agent responses. Demonstrates result filtering, formatting, enhancement, and custom streaming response generation. | -| [`shared_state_middleware.py`](shared_state_middleware.py) | Demonstrates how to implement function-based middleware within a class to share state between multiple middleware functions. Shows how middleware can work together by sharing state, including call counting and result enhancement. | -| [`thread_behavior_middleware.py`](thread_behavior_middleware.py) | Demonstrates how middleware can access and track thread state across multiple agent runs. Shows how `AgentContext.thread` behaves differently before and after the `next()` call, how conversation history accumulates in threads, and timing of thread message updates. Essential for understanding conversation flow in middleware. | -| [`agent_and_run_level_middleware.py`](agent_and_run_level_middleware.py) | Explains the difference between agent-level middleware (applied to ALL runs of the agent) and run-level middleware (applied to specific runs only). Shows security validation, performance monitoring, and context-specific middleware patterns. | -| [`chat_middleware.py`](chat_middleware.py) | Demonstrates how to use chat middleware to observe and override inputs sent to AI models. Shows how to intercept chat requests, log and modify input messages, and override entire responses before they reach the underlying AI service. | - -## Key Concepts - -### Middleware Types - -- **Agent Middleware**: Intercepts agent run execution, allowing you to modify requests and responses -- **Function Middleware**: Intercepts function calls within agents, enabling logging, validation, and result modification -- **Chat Middleware**: Intercepts chat requests sent to AI models, allowing input/output transformation - -### Implementation Approaches - -- **Function-based**: Simple async functions for lightweight, stateless operations -- **Class-based**: Inherit from base middleware classes for complex, stateful operations -- **Decorator-based**: Use decorators for explicit middleware marking - -### Common Use Cases - -- **Security**: Validate requests, block sensitive information, implement access controls -- **Logging**: Track execution timing, log parameters and results, monitor performance -- **Error Handling**: Catch exceptions, provide graceful fallbacks, implement retry logic -- **Result Transformation**: Filter, format, or enhance function outputs -- **State Management**: Share data between middleware functions, maintain execution context - -### Execution Control - -- **Termination**: Use `context.terminate` to stop execution early -- **Result Override**: Modify or replace function/agent results -- **Streaming Support**: Handle both regular and streaming responses diff --git a/python/samples/getting_started/middleware/thread_behavior_middleware.py b/python/samples/getting_started/middleware/thread_behavior_middleware.py deleted file mode 100644 index e3306eef7b..0000000000 --- a/python/samples/getting_started/middleware/thread_behavior_middleware.py +++ /dev/null @@ -1,102 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from collections.abc import Awaitable, Callable -from typing import Annotated - -from agent_framework import ( - AgentContext, - ChatMessageStore, - tool, -) -from agent_framework.azure import AzureOpenAIChatClient -from azure.identity import AzureCliCredential -from pydantic import Field - -""" -Thread Behavior MiddlewareTypes Example - -This sample demonstrates how middleware can access and track thread state across multiple agent runs. -The example shows: - -- How AgentContext.thread property behaves across multiple runs -- How middleware can access conversation history through the thread -- The timing of when thread messages are populated (before vs after call_next() call) -- How to track thread state changes across runs - -Key behaviors demonstrated: -1. First run: context.messages is populated, context.thread is initially empty (before call_next()) -2. After call_next(): thread contains input message + response from agent -3. Second run: context.messages contains only current input, thread contains previous history -4. After call_next(): thread contains full conversation history (all previous + current messages) -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - from random import randint - - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." - - -async def thread_tracking_middleware( - context: AgentContext, - call_next: Callable[[AgentContext], Awaitable[None]], -) -> None: - """MiddlewareTypes that tracks and logs thread behavior across runs.""" - thread_messages = [] - if context.thread and context.thread.message_store: - thread_messages = await context.thread.message_store.list_messages() - - print(f"[MiddlewareTypes pre-execution] Current input messages: {len(context.messages)}") - print(f"[MiddlewareTypes pre-execution] Thread history messages: {len(thread_messages)}") - - # Call call_next to execute the agent - await call_next(context) - - # Check thread state after agent execution - updated_thread_messages = [] - if context.thread and context.thread.message_store: - updated_thread_messages = await context.thread.message_store.list_messages() - - print(f"[MiddlewareTypes post-execution] Updated thread messages: {len(updated_thread_messages)}") - - -async def main() -> None: - """Example demonstrating thread behavior in middleware across multiple runs.""" - print("=== Thread Behavior MiddlewareTypes Example ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( - name="WeatherAgent", - instructions="You are a helpful weather assistant.", - tools=get_weather, - middleware=[thread_tracking_middleware], - # Configure agent with message store factory to persist conversation history - chat_message_store_factory=ChatMessageStore, - ) - - # Create a thread that will persist messages between runs - thread = agent.get_new_thread() - - print("\nFirst Run:") - query1 = "What's the weather like in Tokyo?" - print(f"User: {query1}") - result1 = await agent.run(query1, thread=thread) - print(f"Agent: {result1.text}") - - print("\nSecond Run:") - query2 = "How about in London?" - print(f"User: {query2}") - result2 = await agent.run(query2, thread=thread) - print(f"Agent: {result2.text}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/minimal_sample.py b/python/samples/getting_started/minimal_sample.py deleted file mode 100644 index a3315b4962..0000000000 --- a/python/samples/getting_started/minimal_sample.py +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.openai import OpenAIChatClient - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, "The location to get the weather for."], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." - - -agent = OpenAIChatClient().as_agent( - name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather -) -print(asyncio.run(agent.run("What's the weather like in Seattle?"))) diff --git a/python/samples/getting_started/orchestrations/README.md b/python/samples/getting_started/orchestrations/README.md deleted file mode 100644 index 14f0be5fad..0000000000 --- a/python/samples/getting_started/orchestrations/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# Orchestration Getting Started Samples - -## Installation - -The orchestrations package is included when you install `agent-framework` (which pulls in all optional packages): - -```bash -pip install agent-framework -``` - -Or install the orchestrations package directly: - -```bash -pip install agent-framework-orchestrations -``` - -Orchestration builders are available via the `agent_framework.orchestrations` submodule: - -```python -from agent_framework.orchestrations import ( - SequentialBuilder, - ConcurrentBuilder, - HandoffBuilder, - GroupChatBuilder, - MagenticBuilder, -) -``` - -## Samples Overview - -| Sample | File | Concepts | -| ------------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | -| Concurrent Orchestration (Default Aggregator) | [concurrent_agents.py](./concurrent_agents.py) | Fan-out to multiple agents; fan-in with default aggregator returning combined ChatMessages | -| Concurrent Orchestration (Custom Aggregator) | [concurrent_custom_aggregator.py](./concurrent_custom_aggregator.py) | Override aggregator via callback; summarize results with an LLM | -| Concurrent Orchestration (Custom Agent Executors) | [concurrent_custom_agent_executors.py](./concurrent_custom_agent_executors.py) | Child executors own ChatAgents; concurrent fan-out/fan-in via ConcurrentBuilder | -| Concurrent Orchestration (Participant Factory) | [concurrent_participant_factory.py](./concurrent_participant_factory.py) | Use participant factories for state isolation between workflow instances | -| Group Chat with Agent Manager | [group_chat_agent_manager.py](./group_chat_agent_manager.py) | Agent-based manager using `with_orchestrator(agent=)` to select next speaker | -| Group Chat Philosophical Debate | [group_chat_philosophical_debate.py](./group_chat_philosophical_debate.py) | Agent manager moderates long-form, multi-round debate across diverse participants | -| Group Chat with Simple Function Selector | [group_chat_simple_selector.py](./group_chat_simple_selector.py) | Group chat with a simple function selector for next speaker | -| Handoff (Simple) | [handoff_simple.py](./handoff_simple.py) | Single-tier routing: triage agent routes to specialists, control returns to user after each specialist response | -| Handoff (Autonomous) | [handoff_autonomous.py](./handoff_autonomous.py) | Autonomous mode: specialists iterate independently until invoking a handoff tool using `.with_autonomous_mode()` | -| Handoff (Participant Factory) | [handoff_participant_factory.py](./handoff_participant_factory.py) | Use participant factories for state isolation between workflow instances | -| Handoff with Code Interpreter | [handoff_with_code_interpreter_file.py](./handoff_with_code_interpreter_file.py) | Retrieve file IDs from code interpreter output in handoff workflow | -| Magentic Workflow (Multi-Agent) | [magentic.py](./magentic.py) | Orchestrate multiple agents with Magentic manager and streaming | -| Magentic + Human Plan Review | [magentic_human_plan_review.py](./magentic_human_plan_review.py) | Human reviews/updates the plan before execution | -| Magentic + Checkpoint Resume | [magentic_checkpoint.py](./magentic_checkpoint.py) | Resume Magentic orchestration from saved checkpoints | -| Sequential Orchestration (Agents) | [sequential_agents.py](./sequential_agents.py) | Chain agents sequentially with shared conversation context | -| Sequential Orchestration (Custom Executor) | [sequential_custom_executors.py](./sequential_custom_executors.py) | Mix agents with a summarizer that appends a compact summary | -| Sequential Orchestration (Participant Factories) | [sequential_participant_factory.py](./sequential_participant_factory.py) | Use participant factories for state isolation between workflow instances | - -## Tips - -**Magentic checkpointing tip**: Treat `MagenticBuilder.participants` keys as stable identifiers. When resuming from a checkpoint, the rebuilt workflow must reuse the same participant names; otherwise the checkpoint cannot be applied and the run will fail fast. - -**Handoff workflow tip**: Handoff workflows maintain the full conversation history including any `ChatMessage.additional_properties` emitted by your agents. This ensures routing metadata remains intact across all agent transitions. For specialist-to-specialist handoffs, use `.add_handoff(source, targets)` to configure which agents can route to which others with a fluent, type-safe API. - -**Sequential orchestration note**: Sequential orchestration uses a few small adapter nodes for plumbing: -- `input-conversation` normalizes input to `list[ChatMessage]` -- `to-conversation:` converts agent responses into the shared conversation -- `complete` publishes the final output event (type='output') - -These may appear in event streams (executor_invoked/executor_completed). They're analogous to concurrent's dispatcher and aggregator and can be ignored if you only care about agent activity. - -## Environment Variables - -- **AzureOpenAIChatClient**: Set Azure OpenAI environment variables as documented [here](https://github.com/microsoft/agent-framework/blob/main/python/samples/getting_started/chat_client/README.md#environment-variables). - -- **OpenAI** (used in some orchestration samples): - - [OpenAIChatClient env vars](https://github.com/microsoft/agent-framework/blob/main/python/samples/getting_started/agents/openai_chat_client/README.md) - - [OpenAIResponsesClient env vars](https://github.com/microsoft/agent-framework/blob/main/python/samples/getting_started/agents/openai_responses_client/README.md) diff --git a/python/samples/getting_started/orchestrations/concurrent_participant_factory.py b/python/samples/getting_started/orchestrations/concurrent_participant_factory.py deleted file mode 100644 index acb824e1ef..0000000000 --- a/python/samples/getting_started/orchestrations/concurrent_participant_factory.py +++ /dev/null @@ -1,168 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from typing import Any - -from agent_framework import ( - ChatAgent, - ChatMessage, - Executor, - Workflow, - WorkflowContext, - handler, -) -from agent_framework.azure import AzureOpenAIChatClient -from agent_framework.orchestrations import ConcurrentBuilder -from azure.identity import AzureCliCredential -from typing_extensions import Never - -""" -Sample: Concurrent Orchestration with participant factories and Custom Aggregator - -Build a concurrent workflow with ConcurrentBuilder that fans out one prompt to -multiple domain agents and fans in their responses. - -Override the default aggregator with a custom Executor class that uses -AzureOpenAIChatClient.get_response() to synthesize a concise, consolidated summary -from the experts' outputs. - -All participants and the aggregator are created via factory functions that return -their respective ChatAgent or Executor instances. - -Using participant factories allows you to set up proper state isolation between workflow -instances created by the same builder. This is particularly useful when you need to handle -requests or tasks in parallel with stateful participants. - -Demonstrates: -- ConcurrentBuilder(participant_factories=[...]).with_aggregator(callback) -- Fan-out to agents and fan-in at an aggregator -- Aggregation implemented via an LLM call (chat_client.get_response) -- Workflow output yielded with the synthesized summary string - -Prerequisites: -- Azure OpenAI configured for AzureOpenAIChatClient (az login + required env vars) -""" - - -def create_researcher() -> ChatAgent: - """Factory function to create a researcher agent instance.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( - instructions=( - "You're an expert market and product researcher. Given a prompt, provide concise, factual insights," - " opportunities, and risks." - ), - name="researcher", - ) - - -def create_marketer() -> ChatAgent: - """Factory function to create a marketer agent instance.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( - instructions=( - "You're a creative marketing strategist. Craft compelling value propositions and target messaging" - " aligned to the prompt." - ), - name="marketer", - ) - - -def create_legal() -> ChatAgent: - """Factory function to create a legal/compliance agent instance.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( - instructions=( - "You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns" - " based on the prompt." - ), - name="legal", - ) - - -class SummarizationExecutor(Executor): - """Custom aggregator executor that synthesizes expert outputs into a concise summary.""" - - def __init__(self) -> None: - super().__init__(id="summarization_executor") - self.chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - - @handler - async def summarize_results(self, results: list[Any], ctx: WorkflowContext[Never, str]) -> None: - expert_sections: list[str] = [] - for r in results: - try: - messages = getattr(r.agent_response, "messages", []) - final_text = messages[-1].text if messages and hasattr(messages[-1], "text") else "(no content)" - expert_sections.append(f"{getattr(r, 'executor_id', 'expert')}:\n{final_text}") - except Exception as e: - expert_sections.append(f"{getattr(r, 'executor_id', 'expert')}: (error: {type(e).__name__}: {e})") - - # Ask the model to synthesize a concise summary of the experts' outputs - system_msg = ChatMessage( - "system", - text=( - "You are a helpful assistant that consolidates multiple domain expert outputs " - "into one cohesive, concise summary with clear takeaways. Keep it under 200 words." - ), - ) - user_msg = ChatMessage("user", text="\n\n".join(expert_sections)) - - response = await self.chat_client.get_response([system_msg, user_msg]) - - await ctx.yield_output(response.messages[-1].text if response.messages else "") - - -async def run_workflow(workflow: Workflow, query: str) -> None: - events = await workflow.run(query) - outputs = events.get_outputs() - - if outputs: - print(outputs[0]) # Get the first (and typically only) output - else: - raise RuntimeError("No outputs received from the workflow.") - - -async def main() -> None: - # Create a concurrent builder with participant factories and a custom aggregator - # - register_participants([...]) accepts factory functions that return - # SupportsAgentRun (agents) or Executor instances. - # - register_aggregator(...) takes a factory function that returns an Executor instance. - concurrent_builder = ( - ConcurrentBuilder(participant_factories=[create_researcher, create_marketer, create_legal]) - .register_aggregator(SummarizationExecutor) - ) - - # Build workflow_a - workflow_a = concurrent_builder.build() - - # Run workflow_a - # Context is maintained across runs - print("=== First Run on workflow_a ===") - await run_workflow(workflow_a, "We are launching a new budget-friendly electric bike for urban commuters.") - print("\n=== Second Run on workflow_a ===") - await run_workflow(workflow_a, "Refine your response to focus on the California market.") - - # Build workflow_b - # This will create new instances of all participants and the aggregator - # The agents will also get new threads - workflow_b = concurrent_builder.build() - # Run workflow_b - # Context is not maintained across instances - # Should not expect mentions of electric bikes in the results - print("\n=== First Run on workflow_b ===") - await run_workflow(workflow_b, "Refine your response to focus on the California market.") - - """ - Sample Output: - - === First Run on workflow_a === - The budget-friendly electric bike market is poised for significant growth, driven by urbanization, ... - - === Second Run on workflow_a === - Launching a budget-friendly electric bike in California presents significant opportunities, driven ... - - === First Run on workflow_b === - To successfully penetrate the California market, consider these tailored strategies focused on ... - """ - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/orchestrations/handoff_participant_factory.py b/python/samples/getting_started/orchestrations/handoff_participant_factory.py deleted file mode 100644 index 2465609071..0000000000 --- a/python/samples/getting_started/orchestrations/handoff_participant_factory.py +++ /dev/null @@ -1,271 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import logging -from typing import Annotated, cast - -from agent_framework import ( - AgentResponse, - ChatAgent, - ChatMessage, - Workflow, - WorkflowEvent, - WorkflowRunState, - tool, -) -from agent_framework.azure import AzureOpenAIChatClient -from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder -from azure.identity import AzureCliCredential - -logging.basicConfig(level=logging.ERROR) - -"""Sample: Handoff workflow with participant factories for state isolation. - -This sample demonstrates how to use participant factories in HandoffBuilder to create -agents dynamically. - -Using participant factories allows you to set up proper state isolation between workflow -instances created by the same builder. This is particularly useful when you need to handle -requests or tasks in parallel with stateful participants. - -Routing Pattern: - User -> Triage Agent -> Specialist (Refund/Order Status/Return) -> User - -Prerequisites: - - `az login` (Azure CLI authentication) - - Environment variables for AzureOpenAIChatClient (AZURE_OPENAI_ENDPOINT, etc.) - -Key Concepts: - - Participant factories: create agents via factory functions for isolation - - State isolation: each workflow instance gets its own agent instances -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# See: -# samples/getting_started/tools/function_tool_with_approval.py -# samples/getting_started/tools/function_tool_with_approval_and_threads.py. -@tool(approval_mode="never_require") -def process_refund(order_number: Annotated[str, "Order number to process refund for"]) -> str: - """Simulated function to process a refund for a given order number.""" - return f"Refund processed successfully for order {order_number}." - - -@tool(approval_mode="never_require") -def check_order_status(order_number: Annotated[str, "Order number to check status for"]) -> str: - """Simulated function to check the status of a given order number.""" - return f"Order {order_number} is currently being processed and will ship in 2 business days." - - -@tool(approval_mode="never_require") -def process_return(order_number: Annotated[str, "Order number to process return for"]) -> str: - """Simulated function to process a return for a given order number.""" - return f"Return initiated successfully for order {order_number}. You will receive return instructions via email." - - -def create_triage_agent() -> ChatAgent: - """Factory function to create a triage agent instance.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( - instructions=( - "You are frontline support triage. Route customer issues to the appropriate specialist agents " - "based on the problem described." - ), - name="triage_agent", - ) - - -def create_refund_agent() -> ChatAgent: - """Factory function to create a refund agent instance.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( - instructions="You process refund requests.", - name="refund_agent", - # In a real application, an agent can have multiple tools; here we keep it simple - tools=[process_refund], - ) - - -def create_order_status_agent() -> ChatAgent: - """Factory function to create an order status agent instance.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( - instructions="You handle order and shipping inquiries.", - name="order_agent", - # In a real application, an agent can have multiple tools; here we keep it simple - tools=[check_order_status], - ) - - -def create_return_agent() -> ChatAgent: - """Factory function to create a return agent instance.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( - instructions="You manage product return requests.", - name="return_agent", - # In a real application, an agent can have multiple tools; here we keep it simple - tools=[process_return], - ) - - -def _handle_events(events: list[WorkflowEvent]) -> list[WorkflowEvent[HandoffAgentUserRequest]]: - """Process workflow events and extract any pending user input requests. - - This function inspects each event type and: - - Prints workflow status changes (IDLE, IDLE_WITH_PENDING_REQUESTS, etc.) - - Displays final conversation snapshots when workflow completes - - Prints user input request prompts - - Collects all request_info events for response handling - - Args: - events: List of WorkflowEvent to process - - Returns: - List of WorkflowEvent[HandoffAgentUserRequest] representing pending user input requests - """ - requests: list[WorkflowEvent[HandoffAgentUserRequest]] = [] - - for event in events: - if event.type == "handoff_sent": - # handoff_sent event: Indicates a handoff has been initiated - print(f"\n[Handoff from {event.data.source} to {event.data.target} initiated.]") - elif event.type == "status" and event.state in { - WorkflowRunState.IDLE, - WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, - }: - # Status event: Indicates workflow state changes - print(f"\n[Workflow Status] {event.state.name}") - elif event.type == "output": - # Output event: Contains contents generated by the workflow - data = event.data - if isinstance(data, AgentResponse): - for message in data.messages: - if not message.text: - # Skip messages without text (e.g., tool calls) - continue - speaker = message.author_name or message.role - print(f"- {speaker}: {message.text}") - elif event.type == "output": - # The output of the handoff workflow is a collection of chat messages from all participants - conversation = cast(list[ChatMessage], event.data) - if isinstance(conversation, list): - print("\n=== Final Conversation Snapshot ===") - for message in conversation: - speaker = message.author_name or message.role - print(f"- {speaker}: {message.text or [content.type for content in message.contents]}") - print("===================================") - elif event.type == "request_info" and isinstance(event.data, HandoffAgentUserRequest): - # Request info event: Workflow is requesting user input - _print_handoff_agent_user_request(event.data.agent_response) - requests.append(cast(WorkflowEvent[HandoffAgentUserRequest], event)) - - return requests - - -def _print_handoff_agent_user_request(response: AgentResponse) -> None: - """Display the agent's response messages when requesting user input. - - This will happen when an agent generates a response that doesn't trigger - a handoff, i.e., the agent is asking the user for more information. - - Args: - response: The AgentResponse from the agent requesting user input - """ - if not response.messages: - raise RuntimeError("Cannot print agent responses: response has no messages.") - - print("\n[Agent is requesting your input...]") - - # Print agent responses - for message in response.messages: - if not message.text: - # Skip messages without text (e.g., tool calls) - continue - speaker = message.author_name or message.role - print(f"- {speaker}: {message.text}") - - -async def _run_workflow(workflow: Workflow, user_inputs: list[str]) -> None: - """Run the workflow with the given user input and display events.""" - print(f"- User: {user_inputs[0]}") - workflow_result = await workflow.run(user_inputs[0]) - pending_requests = _handle_events(workflow_result) - - # Process the request/response cycle - # The workflow will continue requesting input until: - # 1. The termination condition is met (4 user messages in this case), OR - # 2. We run out of scripted responses - while pending_requests: - if user_inputs[1:]: - # Get the next scripted response - user_response = user_inputs.pop(1) - print(f"\n- User: {user_response}") - - # Send response(s) to all pending requests - # In this demo, there's typically one request per cycle, but the API supports multiple - responses = { - req.request_id: HandoffAgentUserRequest.create_response(user_response) for req in pending_requests - } - else: - # No more scripted responses; terminate the workflow - responses = {req.request_id: HandoffAgentUserRequest.terminate() for req in pending_requests} - - # Send responses and get new events - # We use run(responses=...) to get events, allowing us to - # display agent responses and handle new requests as they arrive - workflow_result = await workflow.run(responses=responses) - pending_requests = _handle_events(workflow_result) - - -async def main() -> None: - """Run the autonomous handoff workflow with participant factories.""" - # Build the handoff workflow using participant factories - # termination_condition: Custom termination that checks if the triage agent has provided a closing message. - # This looks for the last message being from triage_agent and containing "welcome", - # which indicates the conversation has concluded naturally. - workflow_builder = ( - HandoffBuilder( - name="Autonomous Handoff with Participant Factories", - participant_factories={ - "triage": create_triage_agent, - "refund": create_refund_agent, - "order_status": create_order_status_agent, - "return": create_return_agent, - }, - termination_condition=lambda conversation: ( - len(conversation) > 0 - and conversation[-1].author_name == "triage_agent" - and "welcome" in conversation[-1].text.lower() - ), - ) - .with_start_agent("triage") - ) - - # Scripted user responses for reproducible demo - # In a console application, replace this with: - # user_input = input("Your response: ") - # or integrate with a UI/chat interface - user_inputs = [ - "Hello, I need assistance with my recent purchase.", - "My order 1234 arrived damaged and the packaging was destroyed. I'd like to return it.", - "Is my return being processed?", - "Thanks for resolving this.", - ] - - workflow_a = workflow_builder.build() - print("=== Running workflow_a ===") - await _run_workflow(workflow_a, list(user_inputs)) - - workflow_b = workflow_builder.build() - print("=== Running workflow_b ===") - # Only provide the last two inputs to workflow_b to demonstrate state isolation - # The agents in this workflow have no prior context thus should not have knowledge of - # order 1234 or previous interactions. - await _run_workflow(workflow_b, user_inputs[2:]) - """ - Expected behavior: - - workflow_a and workflow_b maintain separate states for their participants. - - Each workflow processes its requests independently without interference. - - workflow_a will answer the follow-up request based on its own conversation history, - while workflow_b will provide a general answer without prior context. - """ - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/orchestrations/handoff_with_code_interpreter_file.py b/python/samples/getting_started/orchestrations/handoff_with_code_interpreter_file.py deleted file mode 100644 index 159105d54c..0000000000 --- a/python/samples/getting_started/orchestrations/handoff_with_code_interpreter_file.py +++ /dev/null @@ -1,236 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -""" -Handoff Workflow with Code Interpreter File Generation Sample - -This sample demonstrates retrieving file IDs from code interpreter output -in a handoff workflow context. A triage agent routes to a code specialist -that generates a text file, and we verify the file_id is captured correctly -from the streaming workflow events. - -Verifies GitHub issue #2718: files generated by code interpreter in -HandoffBuilder workflows can be properly retrieved. - -Toggle USE_V2_CLIENT to switch between: - - V1: AzureAIAgentClient (azure-ai-agents SDK) - - V2: AzureAIClient (azure-ai-projects 2.x with Responses API) - -IMPORTANT: When using V2 AzureAIClient with HandoffBuilder, each agent must -have its own client instance. The V2 client binds to a single server-side -agent name, so sharing a client between agents causes routing issues. - -Prerequisites: - - `az login` (Azure CLI authentication) - - V1: AZURE_AI_AGENT_PROJECT_CONNECTION_STRING - - V2: AZURE_AI_PROJECT_ENDPOINT, AZURE_AI_MODEL_DEPLOYMENT_NAME -""" - -import asyncio -from collections.abc import AsyncIterable, AsyncIterator -from contextlib import asynccontextmanager -from typing import cast - -from agent_framework import ( - AgentResponseUpdate, - ChatAgent, - ChatMessage, - HostedCodeInterpreterTool, - WorkflowEvent, - WorkflowRunState, -) -from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder -from azure.identity.aio import AzureCliCredential - -# Toggle between V1 (AzureAIAgentClient) and V2 (AzureAIClient) -USE_V2_CLIENT = False - - -async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]: - """Collect all events from an async stream.""" - return [event async for event in stream] - - -def _handle_events(events: list[WorkflowEvent]) -> tuple[list[WorkflowEvent[HandoffAgentUserRequest]], list[str]]: - """Process workflow events and extract file IDs and pending requests. - - Returns: - Tuple of (pending_requests, file_ids_found) - """ - - requests: list[WorkflowEvent[HandoffAgentUserRequest]] = [] - file_ids: list[str] = [] - - for event in events: - if event.type == "handoff_sent": - print(f"\n[Handoff from {event.data.source} to {event.data.target} initiated.]") - elif event.type == "status" and event.state in { - WorkflowRunState.IDLE, - WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, - }: - print(f"[status] {event.state.name}") - elif event.type == "request_info" and isinstance(event.data, HandoffAgentUserRequest): - requests.append(cast(WorkflowEvent[HandoffAgentUserRequest], event)) - elif event.type == "output": - data = event.data - if isinstance(data, AgentResponseUpdate): - for content in data.contents: - if content.type == "hosted_file": - file_ids.append(content.file_id) # type: ignore - print(f"[Found HostedFileContent: file_id={content.file_id}]") - elif content.type == "text" and content.annotations: - for annotation in content.annotations: - file_id = annotation["file_id"] # type: ignore - file_ids.append(file_id) - print(f"[Found file annotation: file_id={file_id}]") - elif event.type == "output": - conversation = cast(list[ChatMessage], event.data) - if isinstance(conversation, list): - print("\n=== Final Conversation Snapshot ===") - for message in conversation: - speaker = message.author_name or message.role - print(f"- {speaker}: {message.text or [content.type for content in message.contents]}") - print("===================================") - - return requests, file_ids - - -@asynccontextmanager -async def create_agents_v1(credential: AzureCliCredential) -> AsyncIterator[tuple[ChatAgent, ChatAgent]]: - """Create agents using V1 AzureAIAgentClient.""" - from agent_framework.azure import AzureAIAgentClient - - async with AzureAIAgentClient(credential=credential) as client: - triage = client.as_agent( - name="triage_agent", - instructions=( - "You are a triage agent. Route code-related requests to the code_specialist. " - "When the user asks to create or generate files, hand off to code_specialist " - "by calling handoff_to_code_specialist." - ), - ) - - code_specialist = client.as_agent( - name="code_specialist", - instructions=( - "You are a Python code specialist. Use the code interpreter to execute Python code " - "and create files when requested. Always save files to /mnt/data/ directory." - ), - tools=[HostedCodeInterpreterTool()], - ) - - yield triage, code_specialist # type: ignore - - -@asynccontextmanager -async def create_agents_v2(credential: AzureCliCredential) -> AsyncIterator[tuple[ChatAgent, ChatAgent]]: - """Create agents using V2 AzureAIClient. - - Each agent needs its own client instance because the V2 client binds - to a single server-side agent name. - """ - from agent_framework.azure import AzureAIClient - - async with ( - AzureAIClient(credential=credential) as triage_client, - AzureAIClient(credential=credential) as code_client, - ): - triage = triage_client.as_agent( - name="TriageAgent", - instructions="You are a triage agent. Your ONLY job is to route requests to the appropriate specialist.", - ) - - code_specialist = code_client.as_agent( - name="CodeSpecialist", - instructions=( - "You are a Python code specialist. You have access to a code interpreter tool. " - "Use the code interpreter to execute Python code and create files. " - "Always save files to /mnt/data/ directory. " - "Do NOT discuss handoffs or routing - just complete the coding task directly." - ), - tools=[HostedCodeInterpreterTool()], - ) - - yield triage, code_specialist - - -async def main() -> None: - """Run a simple handoff workflow with code interpreter file generation.""" - client_version = "V2 (AzureAIClient)" if USE_V2_CLIENT else "V1 (AzureAIAgentClient)" - print(f"=== Handoff Workflow with Code Interpreter File Generation [{client_version}] ===\n") - - async with AzureCliCredential() as credential: - create_agents = create_agents_v2 if USE_V2_CLIENT else create_agents_v1 - - async with create_agents(credential) as (triage, code_specialist): - workflow = ( - HandoffBuilder( - termination_condition=lambda conv: sum(1 for msg in conv if msg.role == "user") >= 2, - ) - .participants([triage, code_specialist]) - .with_start_agent(triage) - .build() - ) - - user_inputs = [ - "Please create a text file called hello.txt with 'Hello from handoff workflow!' inside it.", - "exit", - ] - input_index = 0 - all_file_ids: list[str] = [] - - print(f"User: {user_inputs[0]}") - events = await _drain(workflow.run(user_inputs[0], stream=True)) - requests, file_ids = _handle_events(events) - all_file_ids.extend(file_ids) - input_index += 1 - - while requests: - request = requests[0] - if input_index >= len(user_inputs): - break - user_input = user_inputs[input_index] - print(f"\nUser: {user_input}") - - responses = {request.request_id: HandoffAgentUserRequest.create_response(user_input)} - events = await _drain(workflow.run(stream=True, responses=responses)) - requests, file_ids = _handle_events(events) - all_file_ids.extend(file_ids) - input_index += 1 - - print("\n" + "=" * 50) - if all_file_ids: - print(f"SUCCESS: Found {len(all_file_ids)} file ID(s) in handoff workflow:") - for fid in all_file_ids: - print(f" - {fid}") - else: - print("WARNING: No file IDs captured from the handoff workflow.") - print("=" * 50) - - """ - Sample Output: - - User: Please create a text file called hello.txt with 'Hello from handoff workflow!' inside it. - [Found HostedFileContent: file_id=assistant-JT1sA...] - - === Conversation So Far === - - user: Please create a text file called hello.txt with 'Hello from handoff workflow!' inside it. - - triage_agent: I am handing off your request to create the text file "hello.txt" with the specified content to the code specialist. They will assist you shortly. - - code_specialist: The file "hello.txt" has been created with the content "Hello from handoff workflow!". You can download it using the link below: - - [hello.txt](sandbox:/mnt/data/hello.txt) - =========================== - - [status] IDLE_WITH_PENDING_REQUESTS - - User: exit - [status] IDLE - - ================================================== - SUCCESS: Found 1 file ID(s) in handoff workflow: - - assistant-JT1sA... - ================================================== - """ # noqa: E501 - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/orchestrations/sequential_participant_factory.py b/python/samples/getting_started/orchestrations/sequential_participant_factory.py deleted file mode 100644 index 38cacfffcd..0000000000 --- a/python/samples/getting_started/orchestrations/sequential_participant_factory.py +++ /dev/null @@ -1,126 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework import ( - ChatAgent, - ChatMessage, - Executor, - Workflow, - WorkflowContext, - handler, -) -from agent_framework.azure import AzureOpenAIChatClient -from agent_framework.orchestrations import SequentialBuilder -from azure.identity import AzureCliCredential - -""" -Sample: Sequential workflow with participant factories - -This sample demonstrates how to create a sequential workflow with participant factories. - -Using participant factories allows you to set up proper state isolation between workflow -instances created by the same builder. This is particularly useful when you need to handle -requests or tasks in parallel with stateful participants. - -In this example, we create a sequential workflow with two participants: an accumulator -and a content producer. The accumulator is stateful and maintains a list of all messages it has -received. Context is maintained across runs of the same workflow instance but not across different -workflow instances. -""" - - -class Accumulate(Executor): - """Simple accumulator. - - Accumulates all messages from the conversation and prints them out. - """ - - def __init__(self, id: str): - super().__init__(id) - # Some internal state to accumulate messages - self._accumulated: list[str] = [] - - @handler - async def accumulate(self, conversation: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None: - self._accumulated.extend([msg.text for msg in conversation]) - print(f"Number of queries received so far: {len(self._accumulated)}") - await ctx.send_message(conversation) - - -def create_agent() -> ChatAgent: - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( - instructions="Produce a concise paragraph answering the user's request.", - name="ContentProducer", - ) - - -async def run_workflow(workflow: Workflow, query: str) -> None: - events = await workflow.run(query) - outputs = events.get_outputs() - - if outputs: - messages: list[ChatMessage] = outputs[0] - for message in messages: - name = message.author_name or ("assistant" if message.role == "assistant" else "user") - print(f"{name}: {message.text}") - else: - raise RuntimeError("No outputs received from the workflow.") - - -async def main() -> None: - # 1) Create a builder with participant factories - builder = SequentialBuilder(participant_factories=[ - lambda: Accumulate("accumulator"), - create_agent, - ]) - # 2) Build workflow_a - workflow_a = builder.build() - - # 3) Run workflow_a - # Context is maintained across runs - print("=== First Run on workflow_a ===") - await run_workflow(workflow_a, "Why is the sky blue?") - print("\n=== Second Run on workflow_a ===") - await run_workflow(workflow_a, "Repeat my previous question.") - - # 4) Build workflow_b - # This will create a new instance of the accumulator and content producer - # using the same workflow builder - workflow_b = builder.build() - - # 5) Run workflow_b - # Context is not maintained across instances - print("\n=== First Run on workflow_b ===") - await run_workflow(workflow_b, "Repeat my previous question.") - - """ - Sample Output: - - === First Run on workflow_a === - Number of queries received so far: 1 - user: Why is the sky blue? - ContentProducer: The sky appears blue due to a phenomenon called Rayleigh scattering. - When sunlight enters the Earth's atmosphere, it collides with gases - and particles, scattering shorter wavelengths of light (blue and violet) - more than the longer wavelengths (red and yellow). Although violet light - is scattered even more than blue, our eyes are more sensitive to blue - light, and some violet light is absorbed by the ozone layer. As a result, - we perceive the sky as predominantly blue during the day. - - === Second Run on workflow_a === - Number of queries received so far: 2 - user: Repeat my previous question. - ContentProducer: Why is the sky blue? - - === First Run on workflow_b === - Number of queries received so far: 1 - user: Repeat my previous question. - ContentProducer: I'm sorry, but I can't repeat your previous question as I don't have - access to your past queries. However, feel free to ask anything again, - and I'll be happy to help! - """ - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/sessions/README.md b/python/samples/getting_started/sessions/README.md new file mode 100644 index 0000000000..daee274c2c --- /dev/null +++ b/python/samples/getting_started/sessions/README.md @@ -0,0 +1,103 @@ +# Sessions & Context Provider Examples + +Sessions and context providers are the core building blocks for agent memory in the Agent Framework. Sessions hold conversation state across turns, while context providers add, retrieve, and persist context before and after each agent invocation. + +## Core Concepts + +- **`AgentSession`**: Lightweight state container holding a `session_id` and a mutable `state` dict. Pass to `agent.run()` to maintain conversation across turns. +- **`BaseContextProvider`**: Hook that runs `before_run` / `after_run` around each invocation. Use for injecting instructions, RAG context, or metadata. +- **`BaseHistoryProvider`**: Subclass of `BaseContextProvider` for conversation history storage. Implements `get_messages()` / `save_messages()` and handles load/store automatically. +- **`InMemoryHistoryProvider`**: Built-in provider storing messages in `session.state`. Auto-injected when no providers are configured. + +## Examples + +### Session Management + +| File | Description | +|------|-------------| +| [`suspend_resume_session.py`](suspend_resume_session.py) | Suspend and resume sessions via `to_dict()` / `from_dict()` — both service-managed (Azure AI) and in-memory (OpenAI). | +| [`custom_history_provider.py`](custom_history_provider.py) | Implement a custom `BaseHistoryProvider` with dict-based storage. Shows serialization/deserialization. | +| [`redis_history_provider.py`](redis_history_provider.py) | `RedisHistoryProvider` for persistent storage: basic usage, user sessions, persistence across restarts, serialization, and message trimming. | + +### Custom Context Providers + +| File | Description | +|------|-------------| +| [`simple_context_provider.py`](simple_context_provider.py) | Build a custom `BaseContextProvider` that extracts and stores user information using structured output, then provides dynamic instructions based on stored context. | + +### Azure AI Search + +| File | Description | +|------|-------------| +| [`azure_ai_search/azure_ai_with_search_context_agentic.py`](azure_ai_search/azure_ai_with_search_context_agentic.py) | **Agentic mode** — Knowledge Bases with query planning and multi-hop reasoning. | +| [`azure_ai_search/azure_ai_with_search_context_semantic.py`](azure_ai_search/azure_ai_with_search_context_semantic.py) | **Semantic mode** — fast hybrid search with semantic ranking. | + +### Mem0 + +| File | Description | +|------|-------------| +| [`mem0/mem0_basic.py`](mem0/mem0_basic.py) | Basic Mem0 integration for user preference memory. | +| [`mem0/mem0_sessions.py`](mem0/mem0_sessions.py) | Session scoping: global scope, per-operation scope, and multi-agent isolation. | +| [`mem0/mem0_oss.py`](mem0/mem0_oss.py) | Mem0 Open Source (self-hosted) integration. | + +### Redis + +| File | Description | +|------|-------------| +| [`redis/redis_basics.py`](redis/redis_basics.py) | Standalone provider usage, full-text/hybrid search, preferences, and tool output memory. | +| [`redis/redis_conversation.py`](redis/redis_conversation.py) | Conversation persistence across sessions. | +| [`redis/redis_sessions.py`](redis/redis_sessions.py) | Session scoping: global, per-operation, and multi-agent isolation. | +| [`redis/azure_redis_conversation.py`](redis/azure_redis_conversation.py) | Azure Managed Redis with Entra ID authentication. | + +## Choosing a Provider + +| Provider | Use Case | Persistence | Search | +|----------|----------|-------------|--------| +| **InMemoryHistoryProvider** | Prototyping, stateless apps | Session state only | No | +| **Custom BaseHistoryProvider** | File/DB-backed storage | Your choice | Your choice | +| **RedisHistoryProvider** | Fast persistent chat history | Yes (Redis) | No | +| **RedisContextProvider** | Searchable memory / RAG | Yes (Redis) | Full-text + Hybrid | +| **Mem0ContextProvider** | Long-term user memory | Yes (cloud/self-hosted) | Semantic | +| **AzureAISearchContextProvider** | Enterprise RAG | Yes (Azure) | Hybrid + Semantic | + +## Building Custom Providers + +### Custom Context Provider + +```python +from agent_framework import AgentSession, BaseContextProvider, SessionContext, Message +from typing import Any + +class MyContextProvider(BaseContextProvider): + def __init__(self): + super().__init__("my-context") + + async def before_run(self, *, agent: Any, session: AgentSession | None, + context: SessionContext, state: dict[str, Any]) -> None: + context.extend_messages(self.source_id, [Message("system", ["Extra context here"])]) + + async def after_run(self, *, agent: Any, session: AgentSession | None, + context: SessionContext, state: dict[str, Any]) -> None: + pass # Store information, update memory, etc. +``` + +### Custom History Provider + +```python +from agent_framework import BaseHistoryProvider, Message +from collections.abc import Sequence +from typing import Any + +class MyHistoryProvider(BaseHistoryProvider): + def __init__(self): + super().__init__("my-history") + + async def get_messages(self, session_id: str | None, **kwargs: Any) -> list[Message]: + ... # Load from your storage + + async def save_messages(self, session_id: str | None, + messages: Sequence[Message], **kwargs: Any) -> None: + ... # Persist to your storage +``` + +See `custom_history_provider.py` and `simple_context_provider.py` for complete examples. diff --git a/python/samples/getting_started/sessions/azure_ai_search/README.md b/python/samples/getting_started/sessions/azure_ai_search/README.md new file mode 100644 index 0000000000..49403d106c --- /dev/null +++ b/python/samples/getting_started/sessions/azure_ai_search/README.md @@ -0,0 +1,264 @@ +# Azure AI Search Context Provider Examples + +Azure AI Search context provider enables Retrieval Augmented Generation (RAG) with your agents by retrieving relevant documents from Azure AI Search indexes. It supports two search modes optimized for different use cases. + +This folder contains examples demonstrating how to use the Azure AI Search context provider with the Agent Framework. + +## Examples + +| File | Description | +|------|-------------| +| [`azure_ai_with_search_context_agentic.py`](azure_ai_with_search_context_agentic.py) | **Agentic mode** (recommended for most scenarios): Uses Knowledge Bases in Azure AI Search for query planning and multi-hop reasoning. Provides more accurate results through intelligent retrieval with automatic query reformulation. Slightly slower with more token consumption for query planning. [Learn more](https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/foundry-iq-boost-response-relevance-by-36-with-agentic-retrieval/4470720) | +| [`azure_ai_with_search_context_semantic.py`](azure_ai_with_search_context_semantic.py) | **Semantic mode** (fast queries): Fast hybrid search combining vector and keyword search with semantic ranking. Returns raw search results as context. Best for scenarios where speed is critical and simple retrieval is sufficient. | + +## Installation + +```bash +pip install agent-framework-azure-ai-search agent-framework-azure-ai +``` + +## Prerequisites + +### Required Resources + +1. **Azure AI Search service** with a search index containing your documents + - [Create Azure AI Search service](https://learn.microsoft.com/azure/search/search-create-service-portal) + - [Create and populate a search index](https://learn.microsoft.com/azure/search/search-what-is-an-index) + +2. **Azure AI Foundry project** with a model deployment + - [Create Azure AI Foundry project](https://learn.microsoft.com/azure/ai-studio/how-to/create-projects) + - Deploy a model (e.g., GPT-4o) + +3. **For Agentic mode only**: Azure OpenAI resource for Knowledge Base model calls + - [Create Azure OpenAI resource](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource) + - Note: This is separate from your Azure AI Foundry project endpoint + +### Authentication + +Both examples support two authentication methods: + +- **API Key**: Set `AZURE_SEARCH_API_KEY` environment variable +- **Entra ID (Managed Identity)**: Uses `DefaultAzureCredential` when API key is not provided + +Run `az login` if using Entra ID authentication. + +## Configuration + +### Environment Variables + +**Common (both modes):** +- `AZURE_SEARCH_ENDPOINT`: Your Azure AI Search endpoint (e.g., `https://myservice.search.windows.net`) +- `AZURE_SEARCH_INDEX_NAME`: Name of your search index +- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint +- `AZURE_AI_MODEL_DEPLOYMENT_NAME`: Model deployment name (e.g., `gpt-4o`, defaults to `gpt-4o`) +- `AZURE_SEARCH_API_KEY`: _(Optional)_ Your search API key - if not provided, uses DefaultAzureCredential + +**Agentic mode only:** +- `AZURE_SEARCH_KNOWLEDGE_BASE_NAME`: Name of your Knowledge Base in Azure AI Search +- `AZURE_OPENAI_RESOURCE_URL`: Your Azure OpenAI resource URL (e.g., `https://myresource.openai.azure.com`) + - **Important**: This is different from `AZURE_AI_PROJECT_ENDPOINT` - Knowledge Base needs the OpenAI endpoint for model calls + +### Example .env file + +**For Semantic Mode:** +```env +AZURE_SEARCH_ENDPOINT=https://myservice.search.windows.net +AZURE_SEARCH_INDEX_NAME=my-index +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +# Optional - omit to use Entra ID +AZURE_SEARCH_API_KEY=your-search-key +``` + +**For Agentic Mode (add these to semantic mode variables):** +```env +AZURE_SEARCH_KNOWLEDGE_BASE_NAME=my-knowledge-base +AZURE_OPENAI_RESOURCE_URL=https://myresource.openai.azure.com +``` + +## Search Modes Comparison + +| Feature | Semantic Mode | Agentic Mode | +|---------|--------------|--------------| +| **Speed** | Fast | Slower (query planning overhead) | +| **Token Usage** | Lower | Higher (query reformulation) | +| **Retrieval Strategy** | Hybrid search + semantic ranking | Multi-hop reasoning with Knowledge Base | +| **Query Handling** | Direct search | Automatic query reformulation | +| **Best For** | Simple queries, speed-critical apps | Complex queries, multi-document reasoning | +| **Additional Setup** | None | Requires Knowledge Base + OpenAI resource | + +### When to Use Semantic Mode + +- **Simple queries** where direct keyword/vector search is sufficient +- **Speed is critical** and you need low latency +- **Straightforward retrieval** from single documents +- **Lower token costs** are important + +### When to Use Agentic Mode + +- **Complex queries** requiring multi-hop reasoning +- **Cross-document analysis** where information spans multiple sources +- **Ambiguous queries** that benefit from automatic reformulation +- **Higher accuracy** is more important than speed +- You need **intelligent query planning** and document synthesis + +## How the Examples Work + +### Semantic Mode Flow + +1. User query is sent to Azure AI Search +2. Hybrid search (vector + keyword) retrieves relevant documents +3. Semantic ranking reorders results for relevance +4. Top-k documents are returned as context +5. Agent generates response using retrieved context + +### Agentic Mode Flow + +1. User query is sent to the Knowledge Base +2. Knowledge Base plans the retrieval strategy +3. Multiple search queries may be executed (multi-hop) +4. Retrieved information is synthesized +5. Enhanced context is provided to the agent +6. Agent generates response with comprehensive context + +## Code Example + +### Semantic Mode + +```python +from agent_framework import Agent +from agent_framework.azure import AzureAIAgentClient, AzureAISearchContextProvider +from azure.identity.aio import DefaultAzureCredential + +# Create search provider with semantic mode (default) +search_provider = AzureAISearchContextProvider( + endpoint=search_endpoint, + index_name=index_name, + api_key=search_key, # Or use credential for Entra ID + mode="semantic", # Default mode + top_k=3, # Number of documents to retrieve +) + +# Create agent with search context +async with AzureAIAgentClient(credential=DefaultAzureCredential()) as client: + async with Agent( + client=client, + model=model_deployment, + context_providers=[search_provider], + ) as agent: + response = await agent.run("What information is in the knowledge base?") +``` + +### Agentic Mode + +```python +from agent_framework.azure import AzureAISearchContextProvider + +# Create search provider with agentic mode +search_provider = AzureAISearchContextProvider( + endpoint=search_endpoint, + index_name=index_name, + api_key=search_key, + mode="agentic", # Enable agentic retrieval + knowledge_base_name=knowledge_base_name, + azure_openai_resource_url=azure_openai_resource_url, + top_k=5, +) + +# Use with agent (same as semantic mode) +async with Agent( + client=client, + model=model_deployment, + context_providers=[search_provider], +) as agent: + response = await agent.run("Analyze and compare topics across documents") +``` + +## Running the Examples + +1. **Set up environment variables** (see Configuration section above) + +2. **Ensure you have an Azure AI Search index** with documents: + ```bash + # Verify your index exists + curl -X GET "https://myservice.search.windows.net/indexes/my-index?api-version=2024-07-01" \ + -H "api-key: YOUR_API_KEY" + ``` + +3. **For agentic mode**: Create a Knowledge Base in Azure AI Search + - [Knowledge Base documentation](https://learn.microsoft.com/azure/search/knowledge-store-create-portal) + +4. **Run the examples**: + ```bash + # Semantic mode (fast, simple) + python azure_ai_with_search_context_semantic.py + + # Agentic mode (intelligent, complex) + python azure_ai_with_search_context_agentic.py + ``` + +## Key Parameters + +### Common Parameters + +- `endpoint`: Azure AI Search service endpoint +- `index_name`: Name of the search index +- `api_key`: API key for authentication (optional, can use credential instead) +- `credential`: Azure credential for Entra ID auth (e.g., `DefaultAzureCredential()`) +- `mode`: Search mode - `"semantic"` (default) or `"agentic"` +- `top_k`: Number of documents to retrieve (default: 3 for semantic, 5 for agentic) + +### Semantic Mode Parameters + +- `semantic_configuration`: Name of semantic configuration in your index (optional) +- `query_type`: Query type - `"semantic"` for semantic search (default) + +### Agentic Mode Parameters + +- `knowledge_base_name`: Name of your Knowledge Base (required) +- `azure_openai_resource_url`: Azure OpenAI resource URL (required) +- `max_search_queries`: Maximum number of search queries to generate (default: 3) + +## Troubleshooting + +### Common Issues + +1. **Authentication errors** + - Ensure `AZURE_SEARCH_API_KEY` is set, or run `az login` for Entra ID auth + - Verify your credentials have search permissions + +2. **Index not found** + - Verify `AZURE_SEARCH_INDEX_NAME` matches your index name exactly + - Check that the index exists and contains documents + +3. **Agentic mode errors** + - Ensure `AZURE_SEARCH_KNOWLEDGE_BASE_NAME` is correctly configured + - Verify `AZURE_OPENAI_RESOURCE_URL` points to your Azure OpenAI resource (not AI Foundry endpoint) + - Check that your OpenAI resource has the necessary model deployments + +4. **No results returned** + - Verify your index has documents with vector embeddings (for semantic/hybrid search) + - Check that your queries match the content in your index + - Try increasing `top_k` parameter + +5. **Slow responses in agentic mode** + - This is expected - agentic mode trades speed for accuracy + - Reduce `max_search_queries` if needed + - Consider semantic mode for speed-critical applications + +## Performance Tips + +- **Use semantic mode** as the default for most scenarios - it's fast and effective +- **Switch to agentic mode** when you need multi-hop reasoning or complex queries +- **Adjust `top_k`** based on your needs - higher values provide more context but increase token usage +- **Enable semantic configuration** in your index for better semantic ranking +- **Use Entra ID authentication** in production for better security + +## Additional Resources + +- [Azure AI Search Documentation](https://learn.microsoft.com/azure/search/) +- [Azure AI Foundry Documentation](https://learn.microsoft.com/azure/ai-studio/) +- [RAG with Azure AI Search](https://learn.microsoft.com/azure/search/retrieval-augmented-generation-overview) +- [Semantic Search in Azure AI Search](https://learn.microsoft.com/azure/search/semantic-search-overview) +- [Knowledge Bases in Azure AI Search](https://learn.microsoft.com/azure/search/knowledge-store-concept-intro) +- [Agentic Retrieval Blog Post](https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/foundry-iq-boost-response-relevance-by-36-with-agentic-retrieval/4470720) diff --git a/python/samples/getting_started/sessions/azure_ai_search/azure_ai_with_search_context_agentic.py b/python/samples/getting_started/sessions/azure_ai_search/azure_ai_with_search_context_agentic.py new file mode 100644 index 0000000000..5a4503f920 --- /dev/null +++ b/python/samples/getting_started/sessions/azure_ai_search/azure_ai_with_search_context_agentic.py @@ -0,0 +1,141 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +from agent_framework import Agent +from agent_framework.azure import AzureAIAgentClient, AzureAISearchContextProvider +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +""" +This sample demonstrates how to use Azure AI Search with agentic mode for RAG +(Retrieval Augmented Generation) with Azure AI agents. + +**Agentic mode** is recommended for most scenarios: +- Uses Knowledge Bases in Azure AI Search for query planning +- Performs multi-hop reasoning across documents +- Provides more accurate results through intelligent retrieval +- Slightly slower with more token consumption for query planning +- See: https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/foundry-iq-boost-response-relevance-by-36-with-agentic-retrieval/4470720 + +For simple queries where speed is critical, use semantic mode instead (see azure_ai_with_search_context_semantic.py). + +Prerequisites: +1. An Azure AI Search service +2. An Azure AI Foundry project with a model deployment +3. Either an existing Knowledge Base OR a search index (to auto-create a KB) + +Environment variables: + - AZURE_SEARCH_ENDPOINT: Your Azure AI Search endpoint + - AZURE_SEARCH_API_KEY: (Optional) API key - if not provided, uses DefaultAzureCredential + - AZURE_AI_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint + - AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name (e.g., "gpt-4o") + +For using an existing Knowledge Base (recommended): + - AZURE_SEARCH_KNOWLEDGE_BASE_NAME: Your Knowledge Base name + +For auto-creating a Knowledge Base from an index: + - AZURE_SEARCH_INDEX_NAME: Your search index name + - AZURE_OPENAI_RESOURCE_URL: Azure OpenAI resource URL (e.g., "https://myresource.openai.azure.com") +""" + +# Sample queries to demonstrate agentic RAG +USER_INPUTS = [ + "What information is available in the knowledge base?", + "Analyze and compare the main topics from different documents", + "What connections can you find across different sections?", +] + + +async def main() -> None: + """Main function demonstrating Azure AI Search agentic mode.""" + + # Get configuration from environment + search_endpoint = os.environ["AZURE_SEARCH_ENDPOINT"] + search_key = os.environ.get("AZURE_SEARCH_API_KEY") + project_endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] + model_deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o") + + # Agentic mode requires exactly ONE of: knowledge_base_name OR index_name + # Option 1: Use existing Knowledge Base (recommended) + knowledge_base_name = os.environ.get("AZURE_SEARCH_KNOWLEDGE_BASE_NAME") + # Option 2: Auto-create KB from index (requires azure_openai_resource_url) + index_name = os.environ.get("AZURE_SEARCH_INDEX_NAME") + azure_openai_resource_url = os.environ.get("AZURE_OPENAI_RESOURCE_URL") + + # Create Azure AI Search context provider with agentic mode (recommended for accuracy) + print("Using AGENTIC mode (Knowledge Bases with query planning, recommended)\n") + print("This mode is slightly slower but provides more accurate results.\n") + + # Configure based on whether using existing KB or auto-creating from index + if knowledge_base_name: + # Use existing Knowledge Base - simplest approach + search_provider = AzureAISearchContextProvider( + endpoint=search_endpoint, + api_key=search_key, + credential=AzureCliCredential() if not search_key else None, + mode="agentic", + knowledge_base_name=knowledge_base_name, + # Optional: Configure retrieval behavior + knowledge_base_output_mode="extractive_data", # or "answer_synthesis" + retrieval_reasoning_effort="minimal", # or "medium", "low" + ) + else: + # Auto-create Knowledge Base from index + if not index_name: + raise ValueError("Set AZURE_SEARCH_KNOWLEDGE_BASE_NAME or AZURE_SEARCH_INDEX_NAME") + if not azure_openai_resource_url: + raise ValueError("AZURE_OPENAI_RESOURCE_URL required when using index_name") + search_provider = AzureAISearchContextProvider( + endpoint=search_endpoint, + index_name=index_name, + api_key=search_key, + credential=AzureCliCredential() if not search_key else None, + mode="agentic", + azure_openai_resource_url=azure_openai_resource_url, + model_deployment_name=model_deployment, + # Optional: Configure retrieval behavior + knowledge_base_output_mode="extractive_data", # or "answer_synthesis" + retrieval_reasoning_effort="minimal", # or "medium", "low" + top_k=3, + ) + + # Create agent with search context provider + async with ( + search_provider, + AzureAIAgentClient( + project_endpoint=project_endpoint, + model_deployment_name=model_deployment, + credential=AzureCliCredential(), + ) as client, + Agent( + client=client, + name="SearchAgent", + instructions=( + "You are a helpful assistant with advanced reasoning capabilities. " + "Use the provided context from the knowledge base to answer complex " + "questions that may require synthesizing information from multiple sources." + ), + context_providers=[search_provider], + ) as agent, + ): + print("=== Azure AI Agent with Search Context (Agentic Mode) ===\n") + + for user_input in USER_INPUTS: + print(f"User: {user_input}") + print("Agent: ", end="", flush=True) + + # Stream response + async for chunk in agent.run(user_input, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + + print("\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/sessions/azure_ai_search/azure_ai_with_search_context_semantic.py b/python/samples/getting_started/sessions/azure_ai_search/azure_ai_with_search_context_semantic.py new file mode 100644 index 0000000000..8309d5197c --- /dev/null +++ b/python/samples/getting_started/sessions/azure_ai_search/azure_ai_with_search_context_semantic.py @@ -0,0 +1,97 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +from agent_framework import Agent +from agent_framework.azure import AzureAIAgentClient, AzureAISearchContextProvider +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +""" +This sample demonstrates how to use Azure AI Search with semantic mode for RAG +(Retrieval Augmented Generation) with Azure AI agents. + +**Semantic mode** is the recommended default mode: +- Fast hybrid search combining vector and keyword search +- Uses semantic ranking for improved relevance +- Returns raw search results as context +- Best for most RAG use cases + +Prerequisites: +1. An Azure AI Search service with a search index +2. An Azure AI Foundry project with a model deployment +3. Set the following environment variables: + - AZURE_SEARCH_ENDPOINT: Your Azure AI Search endpoint + - AZURE_SEARCH_API_KEY: (Optional) Your search API key - if not provided, uses DefaultAzureCredential for Entra ID + - AZURE_SEARCH_INDEX_NAME: Your search index name + - AZURE_AI_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint + - AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name (e.g., "gpt-4o") +""" + +# Sample queries to demonstrate RAG +USER_INPUTS = [ + "What information is available in the knowledge base?", + "Summarize the main topics from the documents", + "Find specific details about the content", +] + + +async def main() -> None: + """Main function demonstrating Azure AI Search semantic mode.""" + + # Get configuration from environment + search_endpoint = os.environ["AZURE_SEARCH_ENDPOINT"] + search_key = os.environ.get("AZURE_SEARCH_API_KEY") + index_name = os.environ["AZURE_SEARCH_INDEX_NAME"] + project_endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] + model_deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o") + + # Create Azure AI Search context provider with semantic mode (recommended, fast) + print("Using SEMANTIC mode (hybrid search + semantic ranking, fast)\n") + search_provider = AzureAISearchContextProvider( + endpoint=search_endpoint, + index_name=index_name, + api_key=search_key, # Use api_key for API key auth, or credential for managed identity + credential=AzureCliCredential() if not search_key else None, + mode="semantic", # Default mode + top_k=3, # Retrieve top 3 most relevant documents + ) + + # Create agent with search context provider + async with ( + search_provider, + AzureAIAgentClient( + project_endpoint=project_endpoint, + model_deployment_name=model_deployment, + credential=AzureCliCredential(), + ) as client, + Agent( + client=client, + name="SearchAgent", + instructions=( + "You are a helpful assistant. Use the provided context from the " + "knowledge base to answer questions accurately." + ), + context_providers=[search_provider], + ) as agent, + ): + print("=== Azure AI Agent with Search Context (Semantic Mode) ===\n") + + for user_input in USER_INPUTS: + print(f"User: {user_input}") + print("Agent: ", end="", flush=True) + + # Stream response + async for chunk in agent.run(user_input, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + + print("\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/sessions/custom_history_provider.py b/python/samples/getting_started/sessions/custom_history_provider.py new file mode 100644 index 0000000000..e3ce5c5905 --- /dev/null +++ b/python/samples/getting_started/sessions/custom_history_provider.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from collections.abc import Sequence +from typing import Any + +from agent_framework import AgentSession, BaseHistoryProvider, Message +from agent_framework.openai import OpenAIChatClient + +""" +Custom History Provider Example + +This sample demonstrates how to implement and use a custom history provider +for session management, allowing you to persist conversation history in your +preferred storage solution (database, file system, etc.). +""" + + +class CustomHistoryProvider(BaseHistoryProvider): + """Implementation of custom history provider. + In real applications, this can be an implementation of relational database or vector store.""" + + def __init__(self) -> None: + super().__init__("custom-history") + self._storage: dict[str, list[Message]] = {} + + async def get_messages( + self, session_id: str | None, *, state: dict[str, Any] | None = None, **kwargs: Any + ) -> list[Message]: + key = session_id or "default" + return list(self._storage.get(key, [])) + + async def save_messages( + self, + session_id: str | None, + messages: Sequence[Message], + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + key = session_id or "default" + if key not in self._storage: + self._storage[key] = [] + self._storage[key].extend(messages) + + +async def main() -> None: + """Demonstrates how to use 3rd party or custom history provider for sessions.""" + print("=== Session with 3rd party or custom history provider ===") + + # OpenAI Chat Client is used as an example here, + # other chat clients can be used as well. + agent = OpenAIChatClient().as_agent( + name="CustomBot", + instructions="You are a helpful assistant that remembers our conversation.", + # Use custom history provider. + # If not provided, the default in-memory provider will be used. + context_providers=[CustomHistoryProvider()], + ) + + # Start a new session for the agent conversation. + session = agent.create_session() + + # Respond to user input. + query = "Hello! My name is Alice and I love pizza." + print(f"User: {query}") + print(f"Agent: {await agent.run(query, session=session)}\n") + + # Serialize the session state, so it can be stored for later use. + serialized_session = session.to_dict() + + # The session can now be saved to a database, file, or any other storage mechanism and loaded again later. + print(f"Serialized session: {serialized_session}\n") + + # Deserialize the session state after loading from storage. + resumed_session = AgentSession.from_dict(serialized_session) + + # Respond to user input. + query = "What do you remember about me?" + print(f"User: {query}") + print(f"Agent: {await agent.run(query, session=resumed_session)}\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/sessions/mem0/README.md b/python/samples/getting_started/sessions/mem0/README.md new file mode 100644 index 0000000000..667455a536 --- /dev/null +++ b/python/samples/getting_started/sessions/mem0/README.md @@ -0,0 +1,55 @@ +# Mem0 Context Provider Examples + +[Mem0](https://mem0.ai/) is a self-improving memory layer for Large Language Models that enables applications to have long-term memory capabilities. The Agent Framework's Mem0 context provider integrates with Mem0's API to provide persistent memory across conversation sessions. + +This folder contains examples demonstrating how to use the Mem0 context provider with the Agent Framework for persistent memory and context management across conversations. + +## Examples + +| File | Description | +|------|-------------| +| [`mem0_basic.py`](mem0_basic.py) | Basic example of using Mem0 context provider to store and retrieve user preferences across different conversation sessions. | +| [`mem0_sessions.py`](mem0_sessions.py) | Advanced example demonstrating different session scoping strategies with Mem0. Covers global session scope (memories shared across all operations), per-operation session scope (memories isolated per session), and multiple agents with different memory configurations for personal vs. work contexts. | +| [`mem0_oss.py`](mem0_oss.py) | Example of using the Mem0 Open Source self-hosted version as the context provider. Demonstrates setup and configuration for local deployment. | + +## Prerequisites + +### Required Resources + +1. [Mem0 API Key](https://app.mem0.ai/) - Sign up for a Mem0 account and get your API key - _or_ self-host [Mem0 Open Source](https://docs.mem0.ai/open-source/overview) +2. Azure AI project endpoint (used in these examples) +3. Azure CLI authentication (run `az login`) + +## Configuration + +### Environment Variables + +Set the following environment variables: + +**For Mem0 Platform:** +- `MEM0_API_KEY`: Your Mem0 API key (alternatively, pass it as `api_key` parameter to `Mem0Provider`). Not required if you are self-hosting [Mem0 Open Source](https://docs.mem0.ai/open-source/overview) + +**For Mem0 Open Source:** +- `OPENAI_API_KEY`: Your OpenAI API key (used by Mem0 OSS for embedding generation and automatic memory extraction) + +**For Azure AI:** +- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI project endpoint +- `AZURE_AI_MODEL_DEPLOYMENT_NAME`: The name of your model deployment + +## Key Concepts + +### Memory Scoping + +The Mem0 context provider supports different scoping strategies: + +- **Global Scope** (`scope_to_per_operation_thread_id=False`): Memories are shared across all conversation sessions +- **Session Scope** (`scope_to_per_operation_thread_id=True`): Memories are isolated per conversation session + +### Memory Association + +Mem0 records can be associated with different identifiers: + +- `user_id`: Associate memories with a specific user +- `agent_id`: Associate memories with a specific agent +- `thread_id`: Associate memories with a specific conversation session +- `application_id`: Associate memories with an application context diff --git a/python/samples/getting_started/sessions/mem0/mem0_basic.py b/python/samples/getting_started/sessions/mem0/mem0_basic.py new file mode 100644 index 0000000000..b4e99e0a9f --- /dev/null +++ b/python/samples/getting_started/sessions/mem0/mem0_basic.py @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import uuid + +from agent_framework import tool +from agent_framework.azure import AzureAIAgentClient +from agent_framework.mem0 import Mem0ContextProvider +from azure.identity.aio import AzureCliCredential + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def retrieve_company_report(company_code: str, detailed: bool) -> str: + if company_code != "CNTS": + raise ValueError("Company code not found") + if not detailed: + return "CNTS is a company that specializes in technology." + return ( + "CNTS is a company that specializes in technology. " + "It had a revenue of $10 million in 2022. It has 100 employees." + ) + + +async def main() -> None: + """Example of memory usage with Mem0 context provider.""" + print("=== Mem0 Context Provider Example ===") + + # Each record in Mem0 should be associated with agent_id or user_id or application_id or thread_id. + # In this example, we associate Mem0 records with user_id. + user_id = str(uuid.uuid4()) + + # For Azure authentication, run `az login` command in terminal or replace AzureCliCredential with preferred + # authentication option. + # For Mem0 authentication, set Mem0 API key via "api_key" parameter or MEM0_API_KEY environment variable. + async with ( + AzureCliCredential() as credential, + AzureAIAgentClient(credential=credential).as_agent( + name="FriendlyAssistant", + instructions="You are a friendly assistant.", + tools=retrieve_company_report, + context_providers=[Mem0ContextProvider(user_id=user_id)], + ) as agent, + ): + # First ask the agent to retrieve a company report with no previous context. + # The agent will not be able to invoke the tool, since it doesn't know + # the company code or the report format, so it should ask for clarification. + query = "Please retrieve my company report" + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result}\n") + + # Now tell the agent the company code and the report format that you want to use + # and it should be able to invoke the tool and return the report. + query = "I always work with CNTS and I always want a detailed report format. Please remember and retrieve it." + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result}\n") + + # Mem0 processes and indexes memories asynchronously. + # Wait for memories to be indexed before querying in a new thread. + # In production, consider implementing retry logic or using Mem0's + # eventual consistency handling instead of a fixed delay. + print("Waiting for memories to be processed...") + await asyncio.sleep(12) # Empirically determined delay for Mem0 indexing + + print("\nRequest within a new session:") + # Create a new session for the agent. + # The new session has no context of the previous conversation. + session = agent.create_session() + + # Since we have the mem0 component in the session, the agent should be able to + # retrieve the company report without asking for clarification, as it will + # be able to remember the user preferences from Mem0 component. + query = "Please retrieve my company report" + print(f"User: {query}") + result = await agent.run(query, session=session) + print(f"Agent: {result}\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/sessions/mem0/mem0_oss.py b/python/samples/getting_started/sessions/mem0/mem0_oss.py new file mode 100644 index 0000000000..1b03ac5fc1 --- /dev/null +++ b/python/samples/getting_started/sessions/mem0/mem0_oss.py @@ -0,0 +1,79 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import uuid + +from agent_framework import tool +from agent_framework.azure import AzureAIAgentClient +from agent_framework.mem0 import Mem0ContextProvider +from azure.identity.aio import AzureCliCredential +from mem0 import AsyncMemory + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def retrieve_company_report(company_code: str, detailed: bool) -> str: + if company_code != "CNTS": + raise ValueError("Company code not found") + if not detailed: + return "CNTS is a company that specializes in technology." + return ( + "CNTS is a company that specializes in technology. " + "It had a revenue of $10 million in 2022. It has 100 employees." + ) + + +async def main() -> None: + """Example of memory usage with local Mem0 OSS context provider.""" + print("=== Mem0 Context Provider Example ===") + + # Each record in Mem0 should be associated with agent_id or user_id or application_id or thread_id. + # In this example, we associate Mem0 records with user_id. + user_id = str(uuid.uuid4()) + + # For Azure authentication, run `az login` command in terminal or replace AzureCliCredential with preferred + # authentication option. + # By default, local Mem0 authenticates to your OpenAI using the OPENAI_API_KEY environment variable. + # See the Mem0 documentation for other LLM providers and authentication options. + local_mem0_client = AsyncMemory() + async with ( + AzureCliCredential() as credential, + AzureAIAgentClient(credential=credential).as_agent( + name="FriendlyAssistant", + instructions="You are a friendly assistant.", + tools=retrieve_company_report, + context_providers=[Mem0ContextProvider(user_id=user_id, mem0_client=local_mem0_client)], + ) as agent, + ): + # First ask the agent to retrieve a company report with no previous context. + # The agent will not be able to invoke the tool, since it doesn't know + # the company code or the report format, so it should ask for clarification. + query = "Please retrieve my company report" + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result}\n") + + # Now tell the agent the company code and the report format that you want to use + # and it should be able to invoke the tool and return the report. + query = "I always work with CNTS and I always want a detailed report format. Please remember and retrieve it." + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result}\n") + + print("\nRequest within a new session:") + + # Create a new session for the agent. + # The new session has no context of the previous conversation. + session = agent.create_session() + + # Since we have the mem0 component in the session, the agent should be able to + # retrieve the company report without asking for clarification, as it will + # be able to remember the user preferences from Mem0 component. + query = "Please retrieve my company report" + print(f"User: {query}") + result = await agent.run(query, session=session) + print(f"Agent: {result}\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/sessions/mem0/mem0_sessions.py b/python/samples/getting_started/sessions/mem0/mem0_sessions.py new file mode 100644 index 0000000000..cc5548e979 --- /dev/null +++ b/python/samples/getting_started/sessions/mem0/mem0_sessions.py @@ -0,0 +1,167 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import uuid + +from agent_framework import tool +from agent_framework.azure import AzureAIAgentClient +from agent_framework.mem0 import Mem0ContextProvider +from azure.identity.aio import AzureCliCredential + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def get_user_preferences(user_id: str) -> str: + """Mock function to get user preferences.""" + preferences = { + "user123": "Prefers concise responses and technical details", + "user456": "Likes detailed explanations with examples", + } + return preferences.get(user_id, "No specific preferences found") + + +async def example_global_thread_scope() -> None: + """Example 1: Global thread_id scope (memories shared across all operations).""" + print("1. Global Thread Scope Example:") + print("-" * 40) + + global_thread_id = str(uuid.uuid4()) + user_id = "user123" + + async with ( + AzureCliCredential() as credential, + AzureAIAgentClient(credential=credential).as_agent( + name="GlobalMemoryAssistant", + instructions="You are an assistant that remembers user preferences across conversations.", + tools=get_user_preferences, + context_providers=[Mem0ContextProvider( + user_id=user_id, + thread_id=global_thread_id, + scope_to_per_operation_thread_id=False, # Share memories across all sessions + )], + ) as global_agent, + ): + # Store some preferences in the global scope + query = "Remember that I prefer technical responses with code examples when discussing programming." + print(f"User: {query}") + result = await global_agent.run(query) + print(f"Agent: {result}\n") + + # Create a new session - but memories should still be accessible due to global scope + new_session = global_agent.create_session() + query = "What do you know about my preferences?" + print(f"User (new session): {query}") + result = await global_agent.run(query, session=new_session) + print(f"Agent: {result}\n") + + +async def example_per_operation_thread_scope() -> None: + """Example 2: Per-operation thread scope (memories isolated per session). + + Note: When scope_to_per_operation_thread_id=True, the provider is bound to a single session + throughout its lifetime. Use the same session object for all operations with that provider. + """ + print("2. Per-Operation Thread Scope Example:") + print("-" * 40) + + user_id = "user123" + + async with ( + AzureCliCredential() as credential, + AzureAIAgentClient(credential=credential).as_agent( + name="ScopedMemoryAssistant", + instructions="You are an assistant with thread-scoped memory.", + tools=get_user_preferences, + context_providers=[Mem0ContextProvider( + user_id=user_id, + scope_to_per_operation_thread_id=True, # Isolate memories per session + )], + ) as scoped_agent, + ): + # Create a specific session for this scoped provider + dedicated_session = scoped_agent.create_session() + + # Store some information in the dedicated session + query = "Remember that for this conversation, I'm working on a Python project about data analysis." + print(f"User (dedicated session): {query}") + result = await scoped_agent.run(query, session=dedicated_session) + print(f"Agent: {result}\n") + + # Test memory retrieval in the same dedicated session + query = "What project am I working on?" + print(f"User (same dedicated session): {query}") + result = await scoped_agent.run(query, session=dedicated_session) + print(f"Agent: {result}\n") + + # Store more information in the same session + query = "Also remember that I prefer using pandas and matplotlib for this project." + print(f"User (same dedicated session): {query}") + result = await scoped_agent.run(query, session=dedicated_session) + print(f"Agent: {result}\n") + + # Test comprehensive memory retrieval + query = "What do you know about my current project and preferences?" + print(f"User (same dedicated session): {query}") + result = await scoped_agent.run(query, session=dedicated_session) + print(f"Agent: {result}\n") + + +async def example_multiple_agents() -> None: + """Example 3: Multiple agents with different thread configurations.""" + print("3. Multiple Agents with Different Thread Configurations:") + print("-" * 40) + + agent_id_1 = "agent_personal" + agent_id_2 = "agent_work" + + async with ( + AzureCliCredential() as credential, + AzureAIAgentClient(credential=credential).as_agent( + name="PersonalAssistant", + instructions="You are a personal assistant that helps with personal tasks.", + context_providers=[Mem0ContextProvider( + agent_id=agent_id_1, + )], + ) as personal_agent, + AzureAIAgentClient(credential=credential).as_agent( + name="WorkAssistant", + instructions="You are a work assistant that helps with professional tasks.", + context_providers=[Mem0ContextProvider( + agent_id=agent_id_2, + )], + ) as work_agent, + ): + # Store personal information + query = "Remember that I like to exercise at 6 AM and prefer outdoor activities." + print(f"User to Personal Agent: {query}") + result = await personal_agent.run(query) + print(f"Personal Agent: {result}\n") + + # Store work information + query = "Remember that I have team meetings every Tuesday at 2 PM." + print(f"User to Work Agent: {query}") + result = await work_agent.run(query) + print(f"Work Agent: {result}\n") + + # Test memory isolation + query = "What do you know about my schedule?" + print(f"User to Personal Agent: {query}") + result = await personal_agent.run(query) + print(f"Personal Agent: {result}\n") + + print(f"User to Work Agent: {query}") + result = await work_agent.run(query) + print(f"Work Agent: {result}\n") + + +async def main() -> None: + """Run all Mem0 thread management examples.""" + print("=== Mem0 Thread Management Example ===\n") + + await example_global_thread_scope() + await example_per_operation_thread_scope() + await example_multiple_agents() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/sessions/redis/README.md b/python/samples/getting_started/sessions/redis/README.md new file mode 100644 index 0000000000..03c41295f3 --- /dev/null +++ b/python/samples/getting_started/sessions/redis/README.md @@ -0,0 +1,113 @@ +# Redis Context Provider Examples + +The Redis context provider enables persistent, searchable memory for your agents using Redis (RediSearch). It supports full‑text search and optional hybrid search with vector embeddings, letting agents remember and retrieve user context across sessions. + +This folder contains an example demonstrating how to use the Redis context provider with the Agent Framework. + +## Examples + +| File | Description | +|------|-------------| +| [`azure_redis_conversation.py`](azure_redis_conversation.py) | Demonstrates conversation persistence with RedisHistoryProvider and Azure Redis with Azure AD (Entra ID) authentication using credential provider. | +| [`redis_basics.py`](redis_basics.py) | Shows standalone provider usage and agent integration. Demonstrates writing messages to Redis, retrieving context via full‑text or hybrid vector search, and persisting preferences across sessions. Also includes a simple tool example whose outputs are remembered. | +| [`redis_conversation.py`](redis_conversation.py) | Simple example showing conversation persistence with RedisContextProvider using traditional connection string authentication. | +| [`redis_sessions.py`](redis_sessions.py) | Demonstrates session scoping. Includes: (1) global session scope with a fixed `thread_id` shared across operations; (2) per‑operation session scope where `scope_to_per_operation_thread_id=True` binds memory to a single session for the provider's lifetime; and (3) multiple agents with isolated memory via different `agent_id` values. | + + +## Prerequisites + +### Required resources + +1. A running Redis with RediSearch (Redis Stack or a managed service) +2. Python environment with Agent Framework Redis extra installed +3. Optional: OpenAI API key if using vector embeddings + +### Install the package + +```bash +pip install "agent-framework-redis" +``` + +## Running Redis + +Pick one option: + +### Option A: Docker (local Redis Stack) + +```bash +docker run --name redis -p 6379:6379 -d redis:8.0.3 +``` + +### Option B: Redis Cloud + +Create a free database and get the connection URL at `https://redis.io/cloud/`. + +### Option C: Azure Managed Redis + +See quickstart: `https://learn.microsoft.com/azure/redis/quickstart-create-managed-redis` + +## Configuration + +### Environment variables + +- `OPENAI_API_KEY` (optional): Required only if you set `vectorizer_choice="openai"` to enable hybrid search. + +### Provider configuration highlights + +The provider supports both full‑text only and hybrid vector search: + +- Set `vectorizer_choice` to `"openai"` or `"hf"` to enable embeddings and hybrid search. +- When using a vectorizer, also set `vector_field_name` (e.g., `"vector"`). +- Partition fields for scoping memory: `application_id`, `agent_id`, `user_id`, `thread_id`. +- Session scoping: `scope_to_per_operation_thread_id=True` isolates memory per operation session. +- Index management: `index_name`, `overwrite_redis_index`, `drop_redis_index`. + +## What the example does + +`redis_basics.py` walks through three scenarios: + +1. Standalone provider usage: adds messages and retrieves context via `invoking`. +2. Agent integration: teaches the agent a preference and verifies it is remembered across turns. +3. Agent + tool: calls a sample tool (flight search) and then asks the agent to recall details remembered from the tool output. + +It uses OpenAI for both chat (via `OpenAIChatClient`) and, in some steps, optional embeddings for hybrid search. + +## How to run + +1) Start Redis (see options above). For local default, ensure it's reachable at `redis://localhost:6379`. + +2) Set your OpenAI key if using embeddings and for the chat client used in the sample: + +```bash +export OPENAI_API_KEY="" +``` + +3) Run the example: + +```bash +python redis_basics.py +``` + +You should see the agent responses and, when using embeddings, context retrieved from Redis. The example includes commented debug helpers you can print, such as index info or all stored docs. + +## Key concepts + +### Memory scoping + +- Global scope: set `application_id`, `agent_id`, `user_id`, or `thread_id` on the provider to filter memory. +- Per‑operation session scope: set `scope_to_per_operation_thread_id=True` to isolate memory to the current session created by the framework. + +### Hybrid vector search (optional) + +- Enable by setting `vectorizer_choice` to `"openai"` (requires `OPENAI_API_KEY`) or `"hf"` (offline model). +- Provide `vector_field_name` (e.g., `"vector"`); other vector settings have sensible defaults. + +### Index lifecycle controls + +- `overwrite_redis_index` and `drop_redis_index` help recreate indexes during iteration. + +## Troubleshooting + +- Ensure at least one of `application_id`, `agent_id`, `user_id`, or `thread_id` is set; the provider requires a scope. +- If using embeddings, verify `OPENAI_API_KEY` is set and reachable. +- Make sure Redis exposes RediSearch (Redis Stack image or managed service with search enabled). diff --git a/python/samples/getting_started/sessions/redis/azure_redis_conversation.py b/python/samples/getting_started/sessions/redis/azure_redis_conversation.py new file mode 100644 index 0000000000..ce569be8cb --- /dev/null +++ b/python/samples/getting_started/sessions/redis/azure_redis_conversation.py @@ -0,0 +1,123 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Azure Managed Redis History Provider with Azure AD Authentication + +This example demonstrates how to use Azure Managed Redis with Azure AD authentication +to persist conversational details using RedisHistoryProvider. + +Requirements: + - Azure Managed Redis instance with Azure AD authentication enabled + - Azure credentials configured (az login or managed identity) + - agent-framework-redis: pip install agent-framework-redis + - azure-identity: pip install azure-identity + +Environment Variables: + - AZURE_REDIS_HOST: Your Azure Managed Redis host (e.g., myredis.redis.cache.windows.net) + - OPENAI_API_KEY: Your OpenAI API key + - OPENAI_CHAT_MODEL_ID: OpenAI model (e.g., gpt-4o-mini) + - AZURE_USER_OBJECT_ID: Your Azure AD User Object ID for authentication +""" + +import asyncio +import os + +from agent_framework.openai import OpenAIChatClient +from agent_framework.redis import RedisHistoryProvider +from azure.identity.aio import AzureCliCredential +from redis.credentials import CredentialProvider + + +class AzureCredentialProvider(CredentialProvider): + """Credential provider for Azure AD authentication with Redis Enterprise.""" + + def __init__(self, azure_credential: AzureCliCredential, user_object_id: str): + self.azure_credential = azure_credential + self.user_object_id = user_object_id + + async def get_credentials_async(self) -> tuple[str] | tuple[str, str]: + """Get Azure AD token for Redis authentication. + + Returns (username, token) where username is the Azure user's Object ID. + """ + token = await self.azure_credential.get_token("https://redis.azure.com/.default") + return (self.user_object_id, token.token) + + +async def main() -> None: + redis_host = os.environ.get("AZURE_REDIS_HOST") + if not redis_host: + print("ERROR: Set AZURE_REDIS_HOST environment variable") + return + + # For Azure Redis with Entra ID, username must be your Object ID + user_object_id = os.environ.get("AZURE_USER_OBJECT_ID") + if not user_object_id: + print("ERROR: Set AZURE_USER_OBJECT_ID environment variable") + print("Get your Object ID from the Azure Portal") + return + + # Create Azure CLI credential provider (uses 'az login' credentials) + azure_credential = AzureCliCredential() + credential_provider = AzureCredentialProvider(azure_credential, user_object_id) + + session_id = "azure_test_session" + + # Create Azure Redis history provider + history_provider = RedisHistoryProvider( + credential_provider=credential_provider, + host=redis_host, + port=10000, + ssl=True, + thread_id=session_id, + key_prefix="chat_messages", + max_messages=100, + ) + + # Create chat client + client = OpenAIChatClient() + + # Create agent with Azure Redis history provider + agent = client.as_agent( + name="AzureRedisAssistant", + instructions="You are a helpful assistant.", + context_providers=[history_provider], + ) + + # Conversation + query = "Remember that I enjoy gumbo" + result = await agent.run(query) + print("User: ", query) + print("Agent: ", result) + + # Ask the agent to recall the stored preference; it should retrieve from memory + query = "What do I enjoy?" + result = await agent.run(query) + print("User: ", query) + print("Agent: ", result) + + query = "What did I say to you just now?" + result = await agent.run(query) + print("User: ", query) + print("Agent: ", result) + + query = "Remember that I have a meeting at 3pm tomorrow" + result = await agent.run(query) + print("User: ", query) + print("Agent: ", result) + + query = "Tulips are red" + result = await agent.run(query) + print("User: ", query) + print("Agent: ", result) + + query = "What was the first thing I said to you this conversation?" + result = await agent.run(query) + print("User: ", query) + print("Agent: ", result) + + # Cleanup + await azure_credential.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/sessions/redis/redis_basics.py b/python/samples/getting_started/sessions/redis/redis_basics.py new file mode 100644 index 0000000000..5f78d65320 --- /dev/null +++ b/python/samples/getting_started/sessions/redis/redis_basics.py @@ -0,0 +1,256 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Redis Context Provider: Basic usage and agent integration + +This example demonstrates how to use the Redis context provider to persist and +retrieve conversational memory for agents. It covers three progressively more +realistic scenarios: + +1) Standalone provider usage ("basic cache") + - Write messages to Redis and retrieve relevant context using full-text or + hybrid vector search. + +2) Agent + provider + - Connect the provider to an agent so the agent can store user preferences + and recall them across turns. + +3) Agent + provider + tool memory + - Expose a simple tool to the agent, then verify that details from the tool + outputs are captured and retrievable as part of the agent's memory. + +Requirements: + - A Redis instance with RediSearch enabled (e.g., Redis Stack) + - agent-framework with the Redis extra installed: pip install "agent-framework-redis" + - Optionally an OpenAI API key if enabling embeddings for hybrid search + +Run: + python redis_basics.py +""" + +import asyncio +import os + +from agent_framework import Message, tool +from agent_framework.openai import OpenAIChatClient +from agent_framework.redis import RedisContextProvider +from redisvl.extensions.cache.embeddings import EmbeddingsCache +from redisvl.utils.vectorize import OpenAITextVectorizer + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def search_flights(origin_airport_code: str, destination_airport_code: str, detailed: bool = False) -> str: + """Simulated flight-search tool to demonstrate tool memory. + + The agent can call this function, and the returned details can be stored + by the Redis context provider. We later ask the agent to recall facts from + these tool results to verify memory is working as expected. + """ + # Minimal static catalog used to simulate a tool's structured output + flights = { + ("JFK", "LAX"): { + "airline": "SkyJet", + "duration": "6h 15m", + "price": 325, + "cabin": "Economy", + "baggage": "1 checked bag", + }, + ("SFO", "SEA"): { + "airline": "Pacific Air", + "duration": "2h 5m", + "price": 129, + "cabin": "Economy", + "baggage": "Carry-on only", + }, + ("LHR", "DXB"): { + "airline": "EuroWings", + "duration": "6h 50m", + "price": 499, + "cabin": "Business", + "baggage": "2 bags included", + }, + } + + route = (origin_airport_code.upper(), destination_airport_code.upper()) + if route not in flights: + return f"No flights found between {origin_airport_code} and {destination_airport_code}" + + flight = flights[route] + if not detailed: + return f"Flights available from {origin_airport_code} to {destination_airport_code}." + + return ( + f"{flight['airline']} operates flights from {origin_airport_code} to {destination_airport_code}. " + f"Duration: {flight['duration']}. " + f"Price: ${flight['price']}. " + f"Cabin: {flight['cabin']}. " + f"Baggage policy: {flight['baggage']}." + ) + + +async def main() -> None: + """Walk through provider-only, agent integration, and tool-memory scenarios. + + Helpful debugging (uncomment when iterating): + - print(await provider.redis_index.info()) + - print(await provider.search_all()) + """ + + print("1. Standalone provider usage:") + print("-" * 40) + # Create a provider with partition scope and OpenAI embeddings + + # Please set the OPENAI_API_KEY and OPENAI_CHAT_MODEL_ID environment variables to use the OpenAI vectorizer + # Recommend default for OPENAI_CHAT_MODEL_ID is gpt-4o-mini + + # We attach an embedding vectorizer so the provider can perform hybrid (text + vector) + # retrieval. If you prefer text-only retrieval, instantiate RedisContextProvider without the + # 'vectorizer' and vector_* parameters. + vectorizer = OpenAITextVectorizer( + model="text-embedding-ada-002", + api_config={"api_key": os.getenv("OPENAI_API_KEY")}, + cache=EmbeddingsCache(name="openai_embeddings_cache", redis_url="redis://localhost:6379"), + ) + # The provider manages persistence and retrieval. application_id/agent_id/user_id + # scope data for multi-tenant separation; thread_id (set later) narrows to a + # specific conversation. + provider = RedisContextProvider( + redis_url="redis://localhost:6379", + index_name="redis_basics", + application_id="matrix_of_kermits", + agent_id="agent_kermit", + user_id="kermit", + redis_vectorizer=vectorizer, + vector_field_name="vector", + vector_algorithm="hnsw", + vector_distance_metric="cosine", + ) + + # Build sample chat messages to persist to Redis + messages = [ + Message("user", ["runA CONVO: User Message"]), + Message("assistant", ["runA CONVO: Assistant Message"]), + Message("system", ["runA CONVO: System Message"]), + ] + + # Use the provider's before_run/after_run API to store and retrieve messages. + # In practice, the agent handles this automatically; this shows the low-level API. + from agent_framework import AgentSession, SessionContext + + session = AgentSession(session_id="runA") + context = SessionContext() + context.extend_messages("input", messages) + state = session.state + + # Store messages via after_run + await provider.after_run(agent=None, session=session, context=context, state=state) + + # Retrieve relevant memories via before_run + query_context = SessionContext() + query_context.extend_messages("input", [Message("system", ["B: Assistant Message"])]) + await provider.before_run(agent=None, session=session, context=query_context, state=state) + + # Inspect retrieved memories that would be injected into instructions + # (Debug-only output so you can verify retrieval works as expected.) + print("Before Run Result:") + print(query_context) + + # Drop / delete the provider index in Redis + await provider.redis_index.delete() + + # --- Agent + provider: teach and recall a preference --- + + print("\n2. Agent + provider: teach and recall a preference") + print("-" * 40) + # Fresh provider for the agent demo (recreates index) + vectorizer = OpenAITextVectorizer( + model="text-embedding-ada-002", + api_config={"api_key": os.getenv("OPENAI_API_KEY")}, + cache=EmbeddingsCache(name="openai_embeddings_cache", redis_url="redis://localhost:6379"), + ) + # Recreate a clean index so the next scenario starts fresh + provider = RedisContextProvider( + redis_url="redis://localhost:6379", + index_name="redis_basics_2", + prefix="context_2", + application_id="matrix_of_kermits", + agent_id="agent_kermit", + user_id="kermit", + redis_vectorizer=vectorizer, + vector_field_name="vector", + vector_algorithm="hnsw", + vector_distance_metric="cosine", + ) + + # Create chat client for the agent + client = OpenAIChatClient(model_id=os.getenv("OPENAI_CHAT_MODEL_ID"), api_key=os.getenv("OPENAI_API_KEY")) + # Create agent wired to the Redis context provider. The provider automatically + # persists conversational details and surfaces relevant context on each turn. + agent = client.as_agent( + name="MemoryEnhancedAssistant", + instructions=( + "You are a helpful assistant. Personalize replies using provided context. " + "Before answering, always check for stored context" + ), + tools=[], + context_providers=[provider], + ) + + # Teach a user preference; the agent writes this to the provider's memory + query = "Remember that I enjoy glugenflorgle" + result = await agent.run(query) + print("User: ", query) + print("Agent: ", result) + + # Ask the agent to recall the stored preference; it should retrieve from memory + query = "What do I enjoy?" + result = await agent.run(query) + print("User: ", query) + print("Agent: ", result) + + # Drop / delete the provider index in Redis + await provider.redis_index.delete() + + # --- Agent + provider + tool: store and recall tool-derived context --- + + print("\n3. Agent + provider + tool: store and recall tool-derived context") + print("-" * 40) + # Text-only provider (full-text search only). Omits vectorizer and related params. + provider = RedisContextProvider( + redis_url="redis://localhost:6379", + index_name="redis_basics_3", + prefix="context_3", + application_id="matrix_of_kermits", + agent_id="agent_kermit", + user_id="kermit", + ) + + # Create agent exposing the flight search tool. Tool outputs are captured by the + # provider and become retrievable context for later turns. + client = OpenAIChatClient(model_id=os.getenv("OPENAI_CHAT_MODEL_ID"), api_key=os.getenv("OPENAI_API_KEY")) + agent = client.as_agent( + name="MemoryEnhancedAssistant", + instructions=( + "You are a helpful assistant. Personalize replies using provided context. " + "Before answering, always check for stored context" + ), + tools=search_flights, + context_providers=[provider], + ) + # Invoke the tool; outputs become part of memory/context + query = "Are there any flights from new york city (jfk) to la? Give me details" + result = await agent.run(query) + print("User: ", query) + print("Agent: ", result) + # Verify the agent can recall tool-derived context + query = "Which flight did I ask about?" + result = await agent.run(query) + print("User: ", query) + print("Agent: ", result) + + # Drop / delete the provider index in Redis + await provider.redis_index.delete() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/sessions/redis/redis_conversation.py b/python/samples/getting_started/sessions/redis/redis_conversation.py new file mode 100644 index 0000000000..2d345d9930 --- /dev/null +++ b/python/samples/getting_started/sessions/redis/redis_conversation.py @@ -0,0 +1,105 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Redis Context Provider: Basic usage and agent integration + +This example demonstrates how to use the Redis context provider to persist +conversational details. Pass it as a constructor argument to create_agent. + +Requirements: + - A Redis instance with RediSearch enabled (e.g., Redis Stack) + - agent-framework with the Redis extra installed: pip install "agent-framework-redis" + - Optionally an OpenAI API key if enabling embeddings for hybrid search + +Run: + python redis_conversation.py +""" + +import asyncio +import os + +from agent_framework.openai import OpenAIChatClient +from agent_framework.redis import RedisContextProvider +from redisvl.extensions.cache.embeddings import EmbeddingsCache +from redisvl.utils.vectorize import OpenAITextVectorizer + + +async def main() -> None: + """Walk through provider and chat message store usage. + + Helpful debugging (uncomment when iterating): + - print(await provider.redis_index.info()) + - print(await provider.search_all()) + """ + vectorizer = OpenAITextVectorizer( + model="text-embedding-ada-002", + api_config={"api_key": os.getenv("OPENAI_API_KEY")}, + cache=EmbeddingsCache(name="openai_embeddings_cache", redis_url="redis://localhost:6379"), + ) + + session_id = "test_session" + + provider = RedisContextProvider( + redis_url="redis://localhost:6379", + index_name="redis_conversation", + prefix="redis_conversation", + application_id="matrix_of_kermits", + agent_id="agent_kermit", + user_id="kermit", + redis_vectorizer=vectorizer, + vector_field_name="vector", + vector_algorithm="hnsw", + vector_distance_metric="cosine", + thread_id=session_id, + ) + + # Create chat client for the agent + client = OpenAIChatClient(model_id=os.getenv("OPENAI_CHAT_MODEL_ID"), api_key=os.getenv("OPENAI_API_KEY")) + # Create agent wired to the Redis context provider. The provider automatically + # persists conversational details and surfaces relevant context on each turn. + agent = client.as_agent( + name="MemoryEnhancedAssistant", + instructions=( + "You are a helpful assistant. Personalize replies using provided context. " + "Before answering, always check for stored context" + ), + tools=[], + context_providers=[provider], + ) + + # Teach a user preference; the agent writes this to the provider's memory + query = "Remember that I enjoy gumbo" + result = await agent.run(query) + print("User: ", query) + print("Agent: ", result) + + # Ask the agent to recall the stored preference; it should retrieve from memory + query = "What do I enjoy?" + result = await agent.run(query) + print("User: ", query) + print("Agent: ", result) + + query = "What did I say to you just now?" + result = await agent.run(query) + print("User: ", query) + print("Agent: ", result) + + query = "Remember that I have a meeting at 3pm tomorro" + result = await agent.run(query) + print("User: ", query) + print("Agent: ", result) + + query = "Tulips are red" + result = await agent.run(query) + print("User: ", query) + print("Agent: ", result) + + query = "What was the first thing I said to you this conversation?" + result = await agent.run(query) + print("User: ", query) + print("Agent: ", result) + # Drop / delete the provider index in Redis + await provider.redis_index.delete() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/sessions/redis/redis_sessions.py b/python/samples/getting_started/sessions/redis/redis_sessions.py new file mode 100644 index 0000000000..34179048d9 --- /dev/null +++ b/python/samples/getting_started/sessions/redis/redis_sessions.py @@ -0,0 +1,249 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Redis Context Provider: Thread scoping examples + +This sample demonstrates how conversational memory can be scoped when using the +Redis context provider. It covers three scenarios: + +1) Global thread scope + - Provide a fixed thread_id to share memories across operations/threads. + +2) Per-operation thread scope + - Enable scope_to_per_operation_thread_id to bind the provider to a single + thread for the lifetime of that provider instance. Use the same thread + object for reads/writes with that provider. + +3) Multiple agents with isolated memory + - Use different agent_id values to keep memories separated for different + agent personas, even when the user_id is the same. + +Requirements: + - A Redis instance with RediSearch enabled (e.g., Redis Stack) + - agent-framework with the Redis extra installed: pip install "agent-framework-redis" + - Optionally an OpenAI API key for the chat client in this demo + +Run: + python redis_threads.py +""" + +import asyncio +import os +import uuid + +from agent_framework.openai import OpenAIChatClient +from agent_framework.redis import RedisContextProvider +from redisvl.extensions.cache.embeddings import EmbeddingsCache +from redisvl.utils.vectorize import OpenAITextVectorizer + +# Please set the OPENAI_API_KEY and OPENAI_CHAT_MODEL_ID environment variables to use the OpenAI vectorizer +# Recommend default for OPENAI_CHAT_MODEL_ID is gpt-4o-mini + + +async def example_global_thread_scope() -> None: + """Example 1: Global thread_id scope (memories shared across all operations).""" + print("1. Global Thread Scope Example:") + print("-" * 40) + + global_thread_id = str(uuid.uuid4()) + + client = OpenAIChatClient( + model_id=os.getenv("OPENAI_CHAT_MODEL_ID", "gpt-4o-mini"), + api_key=os.getenv("OPENAI_API_KEY"), + ) + + provider = RedisContextProvider( + redis_url="redis://localhost:6379", + index_name="redis_threads_global", + application_id="threads_demo_app", + agent_id="threads_demo_agent", + user_id="threads_demo_user", + thread_id=global_thread_id, + scope_to_per_operation_thread_id=False, # Share memories across all sessions + ) + + agent = client.as_agent( + name="GlobalMemoryAssistant", + instructions=( + "You are a helpful assistant. Personalize replies using provided context. " + "Before answering, always check for stored context containing information" + ), + tools=[], + context_providers=[provider], + ) + + # Store a preference in the global scope + query = "Remember that I prefer technical responses with code examples when discussing programming." + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result}\n") + + # Create a new session - memories should still be accessible due to global scope + new_session = agent.create_session() + query = "What technical responses do I prefer?" + print(f"User (new session): {query}") + result = await agent.run(query, session=new_session) + print(f"Agent: {result}\n") + + # Clean up the Redis index + await provider.redis_index.delete() + + +async def example_per_operation_thread_scope() -> None: + """Example 2: Per-operation thread scope (memories isolated per session). + + Note: When scope_to_per_operation_thread_id=True, the provider is bound to a single session + throughout its lifetime. Use the same session object for all operations with that provider. + """ + print("2. Per-Operation Thread Scope Example:") + print("-" * 40) + + client = OpenAIChatClient( + model_id=os.getenv("OPENAI_CHAT_MODEL_ID", "gpt-4o-mini"), + api_key=os.getenv("OPENAI_API_KEY"), + ) + + vectorizer = OpenAITextVectorizer( + model="text-embedding-ada-002", + api_config={"api_key": os.getenv("OPENAI_API_KEY")}, + cache=EmbeddingsCache(name="openai_embeddings_cache", redis_url="redis://localhost:6379"), + ) + + provider = RedisContextProvider( + redis_url="redis://localhost:6379", + index_name="redis_threads_dynamic", + # overwrite_redis_index=True, + # drop_redis_index=True, + application_id="threads_demo_app", + agent_id="threads_demo_agent", + user_id="threads_demo_user", + scope_to_per_operation_thread_id=True, # Isolate memories per session + redis_vectorizer=vectorizer, + vector_field_name="vector", + vector_algorithm="hnsw", + vector_distance_metric="cosine", + ) + + agent = client.as_agent( + name="ScopedMemoryAssistant", + instructions="You are an assistant with thread-scoped memory.", + context_providers=[provider], + ) + + # Create a specific session for this scoped provider + dedicated_session = agent.create_session() + + # Store some information in the dedicated session + query = "Remember that for this conversation, I'm working on a Python project about data analysis." + print(f"User (dedicated session): {query}") + result = await agent.run(query, session=dedicated_session) + print(f"Agent: {result}\n") + + # Test memory retrieval in the same dedicated session + query = "What project am I working on?" + print(f"User (same dedicated session): {query}") + result = await agent.run(query, session=dedicated_session) + print(f"Agent: {result}\n") + + # Store more information in the same session + query = "Also remember that I prefer using pandas and matplotlib for this project." + print(f"User (same dedicated session): {query}") + result = await agent.run(query, session=dedicated_session) + print(f"Agent: {result}\n") + + # Test comprehensive memory retrieval + query = "What do you know about my current project and preferences?" + print(f"User (same dedicated session): {query}") + result = await agent.run(query, session=dedicated_session) + print(f"Agent: {result}\n") + + # Clean up the Redis index + await provider.redis_index.delete() + + +async def example_multiple_agents() -> None: + """Example 3: Multiple agents with different thread configurations (isolated via agent_id) but within 1 index.""" + print("3. Multiple Agents with Different Thread Configurations:") + print("-" * 40) + + client = OpenAIChatClient( + model_id=os.getenv("OPENAI_CHAT_MODEL_ID", "gpt-4o-mini"), + api_key=os.getenv("OPENAI_API_KEY"), + ) + + vectorizer = OpenAITextVectorizer( + model="text-embedding-ada-002", + api_config={"api_key": os.getenv("OPENAI_API_KEY")}, + cache=EmbeddingsCache(name="openai_embeddings_cache", redis_url="redis://localhost:6379"), + ) + + personal_provider = RedisContextProvider( + redis_url="redis://localhost:6379", + index_name="redis_threads_agents", + application_id="threads_demo_app", + agent_id="agent_personal", + user_id="threads_demo_user", + redis_vectorizer=vectorizer, + vector_field_name="vector", + vector_algorithm="hnsw", + vector_distance_metric="cosine", + ) + + personal_agent = client.as_agent( + name="PersonalAssistant", + instructions="You are a personal assistant that helps with personal tasks.", + context_providers=[personal_provider], + ) + + work_provider = RedisContextProvider( + redis_url="redis://localhost:6379", + index_name="redis_threads_agents", + application_id="threads_demo_app", + agent_id="agent_work", + user_id="threads_demo_user", + redis_vectorizer=vectorizer, + vector_field_name="vector", + vector_algorithm="hnsw", + vector_distance_metric="cosine", + ) + + work_agent = client.as_agent( + name="WorkAssistant", + instructions="You are a work assistant that helps with professional tasks.", + context_providers=[work_provider], + ) + + # Store personal information + query = "Remember that I like to exercise at 6 AM and prefer outdoor activities." + print(f"User to Personal Agent: {query}") + result = await personal_agent.run(query) + print(f"Personal Agent: {result}\n") + + # Store work information + query = "Remember that I have team meetings every Tuesday at 2 PM." + print(f"User to Work Agent: {query}") + result = await work_agent.run(query) + print(f"Work Agent: {result}\n") + + # Test memory isolation + query = "What do you know about my schedule?" + print(f"User to Personal Agent: {query}") + result = await personal_agent.run(query) + print(f"Personal Agent: {result}\n") + + print(f"User to Work Agent: {query}") + result = await work_agent.run(query) + print(f"Work Agent: {result}\n") + + # Clean up the Redis index (shared) + await work_provider.redis_index.delete() + + +async def main() -> None: + print("=== Redis Thread Scoping Examples ===\n") + await example_global_thread_scope() + await example_per_operation_thread_scope() + await example_multiple_agents() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/sessions/redis_history_provider.py b/python/samples/getting_started/sessions/redis_history_provider.py new file mode 100644 index 0000000000..f54edd8170 --- /dev/null +++ b/python/samples/getting_started/sessions/redis_history_provider.py @@ -0,0 +1,257 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from uuid import uuid4 + +from agent_framework import AgentSession +from agent_framework.openai import OpenAIChatClient +from agent_framework.redis import RedisHistoryProvider + +""" +Redis History Provider Session Example + +This sample demonstrates how to use Redis as a history provider for session +management, enabling persistent conversation history storage across sessions +with Redis as the backend data store. +""" + + +async def example_manual_memory_store() -> None: + """Basic example of using Redis history provider.""" + print("=== Basic Redis History Provider Example ===") + + # Create Redis history provider + redis_provider = RedisHistoryProvider( + source_id="redis_basic_chat", + redis_url="redis://localhost:6379", + ) + + # Create agent with Redis history provider + agent = OpenAIChatClient().as_agent( + name="RedisBot", + instructions="You are a helpful assistant that remembers our conversation using Redis.", + context_providers=[redis_provider], + ) + + # Create session + session = agent.create_session() + + # Have a conversation + print("\n--- Starting conversation ---") + query1 = "Hello! My name is Alice and I love pizza." + print(f"User: {query1}") + response1 = await agent.run(query1, session=session) + print(f"Agent: {response1.text}") + + query2 = "What do you remember about me?" + print(f"User: {query2}") + response2 = await agent.run(query2, session=session) + print(f"Agent: {response2.text}") + + print("Done\n") + + +async def example_user_session_management() -> None: + """Example of managing user sessions with Redis.""" + print("=== User Session Management Example ===") + + user_id = "alice_123" + session_id = f"session_{uuid4()}" + + # Create Redis history provider for specific user session + redis_provider = RedisHistoryProvider( + source_id=f"redis_{user_id}", + redis_url="redis://localhost:6379", + max_messages=10, # Keep only last 10 messages + ) + + # Create agent with history provider + agent = OpenAIChatClient().as_agent( + name="SessionBot", + instructions="You are a helpful assistant. Keep track of user preferences.", + context_providers=[redis_provider], + ) + + # Start conversation + session = agent.create_session(session_id=session_id) + + print(f"Started session for user {user_id}") + + # Simulate conversation + queries = [ + "Hi, I'm Alice and I prefer vegetarian food.", + "What restaurants would you recommend?", + "I also love Italian cuisine.", + "Can you remember my food preferences?", + ] + + for i, query in enumerate(queries, 1): + print(f"\n--- Message {i} ---") + print(f"User: {query}") + response = await agent.run(query, session=session) + print(f"Agent: {response.text}") + + print("Done\n") + + +async def example_conversation_persistence() -> None: + """Example of conversation persistence across application restarts.""" + print("=== Conversation Persistence Example ===") + + # Phase 1: Start conversation + print("--- Phase 1: Starting conversation ---") + redis_provider = RedisHistoryProvider( + source_id="redis_persistent_chat", + redis_url="redis://localhost:6379", + ) + + agent = OpenAIChatClient().as_agent( + name="PersistentBot", + instructions="You are a helpful assistant. Remember our conversation history.", + context_providers=[redis_provider], + ) + + session = agent.create_session() + + # Start conversation + query1 = "Hello! I'm working on a Python project about machine learning." + print(f"User: {query1}") + response1 = await agent.run(query1, session=session) + print(f"Agent: {response1.text}") + + query2 = "I'm specifically interested in neural networks." + print(f"User: {query2}") + response2 = await agent.run(query2, session=session) + print(f"Agent: {response2.text}") + + # Serialize session state + serialized = session.to_dict() + + # Phase 2: Resume conversation (simulating app restart) + print("\n--- Phase 2: Resuming conversation (after 'restart') ---") + restored_session = AgentSession.from_dict(serialized) + + # Continue conversation - agent should remember context + query3 = "What was I working on before?" + print(f"User: {query3}") + response3 = await agent.run(query3, session=restored_session) + print(f"Agent: {response3.text}") + + query4 = "Can you suggest some Python libraries for neural networks?" + print(f"User: {query4}") + response4 = await agent.run(query4, session=restored_session) + print(f"Agent: {response4.text}") + + print("Done\n") + + +async def example_session_serialization() -> None: + """Example of session state serialization and deserialization.""" + print("=== Session Serialization Example ===") + + redis_provider = RedisHistoryProvider( + source_id="redis_serialization_chat", + redis_url="redis://localhost:6379", + ) + + agent = OpenAIChatClient().as_agent( + name="SerializationBot", + instructions="You are a helpful assistant.", + context_providers=[redis_provider], + ) + + session = agent.create_session() + + # Have initial conversation + print("--- Initial conversation ---") + query1 = "Hello! I'm testing serialization." + print(f"User: {query1}") + response1 = await agent.run(query1, session=session) + print(f"Agent: {response1.text}") + + # Serialize session state + serialized = session.to_dict() + print(f"\nSerialized session state: {serialized}") + + # Deserialize session state (simulating loading from database/file) + print("\n--- Deserializing session state ---") + restored_session = AgentSession.from_dict(serialized) + + # Continue conversation with restored session + query2 = "Do you remember what I said about testing?" + print(f"User: {query2}") + response2 = await agent.run(query2, session=restored_session) + print(f"Agent: {response2.text}") + + print("Done\n") + + +async def example_message_limits() -> None: + """Example of automatic message trimming with limits.""" + print("=== Message Limits Example ===") + + # Create provider with small message limit + redis_provider = RedisHistoryProvider( + source_id="redis_limited_chat", + redis_url="redis://localhost:6379", + max_messages=3, # Keep only 3 most recent messages + ) + + agent = OpenAIChatClient().as_agent( + name="LimitBot", + instructions="You are a helpful assistant with limited memory.", + context_providers=[redis_provider], + ) + + session = agent.create_session() + + # Send multiple messages to test trimming + messages = [ + "Message 1: Hello!", + "Message 2: How are you?", + "Message 3: What's the weather?", + "Message 4: Tell me a joke.", + "Message 5: This should trigger trimming.", + ] + + for i, query in enumerate(messages, 1): + print(f"\n--- Sending message {i} ---") + print(f"User: {query}") + response = await agent.run(query, session=session) + print(f"Agent: {response.text}") + + print("Done\n") + + +async def main() -> None: + """Run all Redis history provider examples.""" + print("Redis History Provider Examples") + print("=" * 50) + print("Prerequisites:") + print("- Redis server running on localhost:6379") + print("- OPENAI_API_KEY environment variable set") + print("=" * 50) + + # Check prerequisites + if not os.getenv("OPENAI_API_KEY"): + print("ERROR: OPENAI_API_KEY environment variable not set") + return + + try: + # Run all examples + await example_manual_memory_store() + await example_user_session_management() + await example_conversation_persistence() + await example_session_serialization() + await example_message_limits() + + print("All examples completed successfully!") + + except Exception as e: + print(f"Error running examples: {e}") + raise + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/sessions/simple_context_provider.py b/python/samples/getting_started/sessions/simple_context_provider.py new file mode 100644 index 0000000000..7ef1ba6ea4 --- /dev/null +++ b/python/samples/getting_started/sessions/simple_context_provider.py @@ -0,0 +1,123 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import Any + +from agent_framework import ( + Agent, + AgentSession, + BaseContextProvider, + SessionContext, + SupportsChatGetResponse, +) +from agent_framework.azure import AzureOpenAIResponsesClient +from azure.identity import AzureCliCredential +from pydantic import BaseModel + + +class UserInfo(BaseModel): + name: str | None = None + age: int | None = None + + +class UserInfoMemory(BaseContextProvider): + """Context provider that extracts and remembers user info (name, age). + + State is stored in ``session.state["user-info-memory"]`` so it survives + serialization via ``session.to_dict()`` / ``AgentSession.from_dict()``. + """ + + def __init__(self, client: SupportsChatGetResponse): + super().__init__("user-info-memory") + self._chat_client = client + + async def before_run( + self, + *, + agent: Any, + session: AgentSession | None, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Provide user information context before each agent call.""" + my_state = state.setdefault(self.source_id, {}) + user_info = my_state.setdefault("user_info", UserInfo()) + + instructions: list[str] = [] + + if user_info.name is None: + instructions.append( + "Ask the user for their name and politely decline to answer any questions until they provide it." + ) + else: + instructions.append(f"The user's name is {user_info.name}.") + + if user_info.age is None: + instructions.append( + "Ask the user for their age and politely decline to answer any questions until they provide it." + ) + else: + instructions.append(f"The user's age is {user_info.age}.") + + context.extend_instructions(self.source_id, " ".join(instructions)) + + async def after_run( + self, + *, + agent: Any, + session: AgentSession | None, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Extract user information from messages after each agent call.""" + my_state = state.setdefault(self.source_id, {}) + user_info = my_state.setdefault("user_info", UserInfo()) + if user_info.name is not None and user_info.age is not None: + return # Already have everything + + request_messages = context.get_messages(include_input=True, include_response=True) + user_messages = [msg for msg in request_messages if hasattr(msg, "role") and msg.role == "user"] # type: ignore + if not user_messages: + return + + try: + result = await self._chat_client.get_response( + messages=request_messages, # type: ignore + instructions="Extract the user's name and age from the message if present. " + "If not present return nulls.", + options={"response_format": UserInfo}, + ) + extracted = result.value + if extracted and user_info.name is None and extracted.name: + user_info.name = extracted.name + if extracted and user_info.age is None and extracted.age: + user_info.age = extracted.age + state.setdefault(self.source_id, {})["user_info"] = user_info + except Exception: + pass # Failed to extract, continue without updating + + +async def main(): + client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) + + async with Agent( + client=client, + instructions="You are a friendly assistant. Always address the user by their name.", + default_options={"store": True}, + context_providers=[UserInfoMemory(client)], + ) as agent: + session = agent.create_session() + + print(await agent.run("Hello, what is the square root of 9?", session=session)) + print(await agent.run("My name is Ruaidhrí", session=session)) + print(await agent.run("I am 20 years old", session=session)) + + # Inspect extracted user info from session state + user_info = session.state.get("user-info-memory", {}).get("user_info", UserInfo()) + print() + print(f"MEMORY - User Name: {user_info.name}") + print(f"MEMORY - User Age: {user_info.age}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/sessions/suspend_resume_session.py b/python/samples/getting_started/sessions/suspend_resume_session.py new file mode 100644 index 0000000000..dcbb00d06a --- /dev/null +++ b/python/samples/getting_started/sessions/suspend_resume_session.py @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import AgentSession +from agent_framework.azure import AzureAIAgentClient +from agent_framework.openai import OpenAIChatClient +from azure.identity.aio import AzureCliCredential + +""" +Session Suspend and Resume Example + +This sample demonstrates how to suspend and resume conversation sessions, comparing +service-managed sessions (Azure AI) with in-memory sessions (OpenAI) for persistent +conversation state across sessions. +""" + + +async def suspend_resume_service_managed_session() -> None: + """Demonstrates how to suspend and resume a service-managed session.""" + print("=== Suspend-Resume Service-Managed Session ===") + + # AzureAIAgentClient supports service-managed sessions. + async with ( + AzureCliCredential() as credential, + AzureAIAgentClient(credential=credential).as_agent( + name="MemoryBot", instructions="You are a helpful assistant that remembers our conversation." + ) as agent, + ): + # Start a new session for the agent conversation. + session = agent.create_session() + + # Respond to user input. + query = "Hello! My name is Alice and I love pizza." + print(f"User: {query}") + print(f"Agent: {await agent.run(query, session=session)}\n") + + # Serialize the session state, so it can be stored for later use. + serialized_session = session.to_dict() + + # The session can now be saved to a database, file, or any other storage mechanism and loaded again later. + print(f"Serialized session: {serialized_session}\n") + + # Deserialize the session state after loading from storage. + resumed_session = AgentSession.from_dict(serialized_session) + + # Respond to user input. + query = "What do you remember about me?" + print(f"User: {query}") + print(f"Agent: {await agent.run(query, session=resumed_session)}\n") + + +async def suspend_resume_in_memory_session() -> None: + """Demonstrates how to suspend and resume an in-memory session.""" + print("=== Suspend-Resume In-Memory Session ===") + + # OpenAI Chat Client is used as an example here, + # other chat clients can be used as well. + agent = OpenAIChatClient().as_agent( + name="MemoryBot", instructions="You are a helpful assistant that remembers our conversation." + ) + + # Start a new session for the agent conversation. + session = agent.create_session() + + # Respond to user input. + query = "Hello! My name is Alice and I love pizza." + print(f"User: {query}") + print(f"Agent: {await agent.run(query, session=session)}\n") + + # Serialize the session state, so it can be stored for later use. + serialized_session = session.to_dict() + + # The session can now be saved to a database, file, or any other storage mechanism and loaded again later. + print(f"Serialized session: {serialized_session}\n") + + # Deserialize the session state after loading from storage. + resumed_session = AgentSession.from_dict(serialized_session) + + # Respond to user input. + query = "What do you remember about me?" + print(f"User: {query}") + print(f"Agent: {await agent.run(query, session=resumed_session)}\n") + + +async def main() -> None: + print("=== Suspend-Resume Session Examples ===") + await suspend_resume_service_managed_session() + await suspend_resume_in_memory_session() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/threads/README.md b/python/samples/getting_started/threads/README.md deleted file mode 100644 index 32c19d537f..0000000000 --- a/python/samples/getting_started/threads/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Thread Management Examples - -This folder contains examples demonstrating different ways to manage conversation threads and chat message stores with the Agent Framework. - -## Examples - -| File | Description | -|------|-------------| -| [`custom_chat_message_store_thread.py`](custom_chat_message_store_thread.py) | Demonstrates how to implement a custom `ChatMessageStore` for persisting conversation history. Shows how to create a custom store with serialization/deserialization capabilities and integrate it with agents for thread management across multiple sessions. | -| [`redis_chat_message_store_thread.py`](redis_chat_message_store_thread.py) | Comprehensive examples of using the Redis-backed `RedisChatMessageStore` for persistent conversation storage. Covers basic usage, user session management, conversation persistence across app restarts, thread serialization, and automatic message trimming. Requires Redis server and demonstrates production-ready patterns for scalable chat applications. | -| [`suspend_resume_thread.py`](suspend_resume_thread.py) | Shows how to suspend and resume conversation threads, comparing service-managed threads (Azure AI) with in-memory threads (OpenAI). Demonstrates saving conversation state and continuing it later, useful for long-running conversations or persisting state across application restarts. | - -## Environment Variables - -Make sure to set the following environment variables before running the examples: - -- `OPENAI_API_KEY`: Your OpenAI API key (required for all samples) -- `OPENAI_CHAT_MODEL_ID`: The OpenAI model to use (e.g., `gpt-4o`, `gpt-4o-mini`, `gpt-3.5-turbo`) (required for all samples) -- `AZURE_AI_PROJECT_ENDPOINT`: Azure AI Project endpoint URL (required for service-managed thread examples) -- `AZURE_AI_MODEL_DEPLOYMENT_NAME`: The name of your model deployment (required for service-managed thread examples) diff --git a/python/samples/getting_started/threads/custom_chat_message_store_thread.py b/python/samples/getting_started/threads/custom_chat_message_store_thread.py deleted file mode 100644 index 709f9d45de..0000000000 --- a/python/samples/getting_started/threads/custom_chat_message_store_thread.py +++ /dev/null @@ -1,93 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from collections.abc import Collection -from typing import Any - -from agent_framework import ChatMessage, ChatMessageStoreProtocol -from agent_framework._threads import ChatMessageStoreState -from agent_framework.openai import OpenAIChatClient - -""" -Custom Chat Message Store Thread Example - -This sample demonstrates how to implement and use a custom chat message store -for thread management, allowing you to persist conversation history in your -preferred storage solution (database, file system, etc.). -""" - - -class CustomChatMessageStore(ChatMessageStoreProtocol): - """Implementation of custom chat message store. - In real applications, this can be an implementation of relational database or vector store.""" - - def __init__(self, messages: Collection[ChatMessage] | None = None) -> None: - self._messages: list[ChatMessage] = [] - if messages: - self._messages.extend(messages) - - async def add_messages(self, messages: Collection[ChatMessage]) -> None: - self._messages.extend(messages) - - async def list_messages(self) -> list[ChatMessage]: - return self._messages - - @classmethod - async def deserialize(cls, serialized_store_state: Any, **kwargs: Any) -> "CustomChatMessageStore": - """Create a new instance from serialized state.""" - store = cls() - await store.update_from_state(serialized_store_state, **kwargs) - return store - - async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> None: - """Update this instance from serialized state.""" - if serialized_store_state: - state = ChatMessageStoreState.from_dict(serialized_store_state, **kwargs) - if state.messages: - self._messages.extend(state.messages) - - async def serialize(self, **kwargs: Any) -> Any: - """Serialize this store's state.""" - state = ChatMessageStoreState(messages=self._messages) - return state.to_dict(**kwargs) - - -async def main() -> None: - """Demonstrates how to use 3rd party or custom chat message store for threads.""" - print("=== Thread with 3rd party or custom chat message store ===") - - # OpenAI Chat Client is used as an example here, - # other chat clients can be used as well. - agent = OpenAIChatClient().as_agent( - name="CustomBot", - instructions="You are a helpful assistant that remembers our conversation.", - # Use custom chat message store. - # If not provided, the default in-memory store will be used. - chat_message_store_factory=CustomChatMessageStore, - ) - - # Start a new thread for the agent conversation. - thread = agent.get_new_thread() - - # Respond to user input. - query = "Hello! My name is Alice and I love pizza." - print(f"User: {query}") - print(f"Agent: {await agent.run(query, thread=thread)}\n") - - # Serialize the thread state, so it can be stored for later use. - serialized_thread = await thread.serialize() - - # The thread can now be saved to a database, file, or any other storage mechanism and loaded again later. - print(f"Serialized thread: {serialized_thread}\n") - - # Deserialize the thread state after loading from storage. - resumed_thread = await agent.deserialize_thread(serialized_thread) - - # Respond to user input. - query = "What do you remember about me?" - print(f"User: {query}") - print(f"Agent: {await agent.run(query, thread=resumed_thread)}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/threads/redis_chat_message_store_thread.py b/python/samples/getting_started/threads/redis_chat_message_store_thread.py deleted file mode 100644 index 217355eb72..0000000000 --- a/python/samples/getting_started/threads/redis_chat_message_store_thread.py +++ /dev/null @@ -1,322 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from uuid import uuid4 - -from agent_framework import AgentThread -from agent_framework.openai import OpenAIChatClient -from agent_framework.redis import RedisChatMessageStore - -""" -Redis Chat Message Store Thread Example - -This sample demonstrates how to use Redis as a chat message store for thread -management, enabling persistent conversation history storage across sessions -with Redis as the backend data store. -""" - - -async def example_manual_memory_store() -> None: - """Basic example of using Redis chat message store.""" - print("=== Basic Redis Chat Message Store Example ===") - - # Create Redis store with auto-generated thread ID - redis_store = RedisChatMessageStore( - redis_url="redis://localhost:6379", - # thread_id will be auto-generated if not provided - ) - - print(f"Created store with thread ID: {redis_store.thread_id}") - - # Create thread with Redis store - thread = AgentThread(message_store=redis_store) - - # Create agent - agent = OpenAIChatClient().as_agent( - name="RedisBot", - instructions="You are a helpful assistant that remembers our conversation using Redis.", - ) - - # Have a conversation - print("\n--- Starting conversation ---") - query1 = "Hello! My name is Alice and I love pizza." - print(f"User: {query1}") - response1 = await agent.run(query1, thread=thread) - print(f"Agent: {response1.text}") - - query2 = "What do you remember about me?" - print(f"User: {query2}") - response2 = await agent.run(query2, thread=thread) - print(f"Agent: {response2.text}") - - # Show messages are stored in Redis - messages = await redis_store.list_messages() - print(f"\nTotal messages in Redis: {len(messages)}") - - # Cleanup - await redis_store.clear() - await redis_store.aclose() - print("Cleaned up Redis data\n") - - -async def example_user_session_management() -> None: - """Example of managing user sessions with Redis.""" - print("=== User Session Management Example ===") - - user_id = "alice_123" - session_id = f"session_{uuid4()}" - - # Create Redis store for specific user session - def create_user_session_store(): - return RedisChatMessageStore( - redis_url="redis://localhost:6379", - thread_id=f"user_{user_id}_{session_id}", - max_messages=10, # Keep only last 10 messages - ) - - # Create agent with factory pattern - agent = OpenAIChatClient().as_agent( - name="SessionBot", - instructions="You are a helpful assistant. Keep track of user preferences.", - chat_message_store_factory=create_user_session_store, - ) - - # Start conversation - thread = agent.get_new_thread() - - print(f"Started session for user {user_id}") - if hasattr(thread.message_store, "thread_id"): - print(f"Thread ID: {thread.message_store.thread_id}") # type: ignore[union-attr] - - # Simulate conversation - queries = [ - "Hi, I'm Alice and I prefer vegetarian food.", - "What restaurants would you recommend?", - "I also love Italian cuisine.", - "Can you remember my food preferences?", - ] - - for i, query in enumerate(queries, 1): - print(f"\n--- Message {i} ---") - print(f"User: {query}") - response = await agent.run(query, thread=thread) - print(f"Agent: {response.text}") - - # Show persistent storage - if thread.message_store: - messages = await thread.message_store.list_messages() # type: ignore[union-attr] - print(f"\nMessages stored for user {user_id}: {len(messages)}") - - # Cleanup - if thread.message_store: - await thread.message_store.clear() # type: ignore[union-attr] - await thread.message_store.aclose() # type: ignore[union-attr] - print("Cleaned up session data\n") - - -async def example_conversation_persistence() -> None: - """Example of conversation persistence across application restarts.""" - print("=== Conversation Persistence Example ===") - - conversation_id = "persistent_chat_001" - - # Phase 1: Start conversation - print("--- Phase 1: Starting conversation ---") - store1 = RedisChatMessageStore( - redis_url="redis://localhost:6379", - thread_id=conversation_id, - ) - - thread1 = AgentThread(message_store=store1) - agent = OpenAIChatClient().as_agent( - name="PersistentBot", - instructions="You are a helpful assistant. Remember our conversation history.", - ) - - # Start conversation - query1 = "Hello! I'm working on a Python project about machine learning." - print(f"User: {query1}") - response1 = await agent.run(query1, thread=thread1) - print(f"Agent: {response1.text}") - - query2 = "I'm specifically interested in neural networks." - print(f"User: {query2}") - response2 = await agent.run(query2, thread=thread1) - print(f"Agent: {response2.text}") - - print(f"Stored {len(await store1.list_messages())} messages in Redis") - await store1.aclose() - - # Phase 2: Resume conversation (simulating app restart) - print("\n--- Phase 2: Resuming conversation (after 'restart') ---") - store2 = RedisChatMessageStore( - redis_url="redis://localhost:6379", - thread_id=conversation_id, # Same thread ID - ) - - thread2 = AgentThread(message_store=store2) - - # Continue conversation - agent should remember context - query3 = "What was I working on before?" - print(f"User: {query3}") - response3 = await agent.run(query3, thread=thread2) - print(f"Agent: {response3.text}") - - query4 = "Can you suggest some Python libraries for neural networks?" - print(f"User: {query4}") - response4 = await agent.run(query4, thread=thread2) - print(f"Agent: {response4.text}") - - print(f"Total messages after resuming: {len(await store2.list_messages())}") - - # Cleanup - await store2.clear() - await store2.aclose() - print("Cleaned up persistent data\n") - - -async def example_thread_serialization() -> None: - """Example of thread state serialization and deserialization.""" - print("=== Thread Serialization Example ===") - - # Create initial thread with Redis store - original_store = RedisChatMessageStore( - redis_url="redis://localhost:6379", - thread_id="serialization_test", - max_messages=50, - ) - - original_thread = AgentThread(message_store=original_store) - - agent = OpenAIChatClient().as_agent( - name="SerializationBot", - instructions="You are a helpful assistant.", - ) - - # Have initial conversation - print("--- Initial conversation ---") - query1 = "Hello! I'm testing serialization." - print(f"User: {query1}") - response1 = await agent.run(query1, thread=original_thread) - print(f"Agent: {response1.text}") - - # Serialize thread state - serialized_thread = await original_thread.serialize() - print(f"\nSerialized thread state: {serialized_thread}") - - # Close original connection - await original_store.aclose() - - # Deserialize thread state (simulating loading from database/file) - print("\n--- Deserializing thread state ---") - - # Create a new thread with the same Redis store type - # This ensures the correct store type is used for deserialization - restored_store = RedisChatMessageStore(redis_url="redis://localhost:6379") - restored_thread = await AgentThread.deserialize(serialized_thread, message_store=restored_store) - - # Continue conversation with restored thread - query2 = "Do you remember what I said about testing?" - print(f"User: {query2}") - response2 = await agent.run(query2, thread=restored_thread) - print(f"Agent: {response2.text}") - - # Cleanup - if restored_thread.message_store: - await restored_thread.message_store.clear() # type: ignore[union-attr] - await restored_thread.message_store.aclose() # type: ignore[union-attr] - print("Cleaned up serialization test data\n") - - -async def example_message_limits() -> None: - """Example of automatic message trimming with limits.""" - print("=== Message Limits Example ===") - - # Create store with small message limit - store = RedisChatMessageStore( - redis_url="redis://localhost:6379", - thread_id="limits_test", - max_messages=3, # Keep only 3 most recent messages - ) - - thread = AgentThread(message_store=store) - agent = OpenAIChatClient().as_agent( - name="LimitBot", - instructions="You are a helpful assistant with limited memory.", - ) - - # Send multiple messages to test trimming - messages = [ - "Message 1: Hello!", - "Message 2: How are you?", - "Message 3: What's the weather?", - "Message 4: Tell me a joke.", - "Message 5: This should trigger trimming.", - ] - - for i, query in enumerate(messages, 1): - print(f"\n--- Sending message {i} ---") - print(f"User: {query}") - response = await agent.run(query, thread=thread) - print(f"Agent: {response.text}") - - stored_messages = await store.list_messages() - print(f"Messages in store: {len(stored_messages)}") - if len(stored_messages) > 0: - print(f"Oldest message: {stored_messages[0].text[:30]}...") - - # Final check - final_messages = await store.list_messages() - print(f"\nFinal message count: {len(final_messages)} (should be <= 6: 3 messages × 2 per exchange)") - - # Cleanup - await store.clear() - await store.aclose() - print("Cleaned up limits test data\n") - - -async def main() -> None: - """Run all Redis chat message store examples.""" - print("Redis Chat Message Store Examples") - print("=" * 50) - print("Prerequisites:") - print("- Redis server running on localhost:6379") - print("- OPENAI_API_KEY environment variable set") - print("=" * 50) - - # Check prerequisites - if not os.getenv("OPENAI_API_KEY"): - print("ERROR: OPENAI_API_KEY environment variable not set") - return - - try: - # Test Redis connection - test_store = RedisChatMessageStore(redis_url="redis://localhost:6379") - connection_ok = await test_store.ping() - await test_store.aclose() - if not connection_ok: - raise Exception("Redis ping failed") - print("✓ Redis connection successful\n") - except Exception as e: - print(f"ERROR: Cannot connect to Redis: {e}") - print("Please ensure Redis is running on localhost:6379") - return - - try: - # Run all examples - await example_manual_memory_store() - await example_user_session_management() - await example_conversation_persistence() - await example_thread_serialization() - await example_message_limits() - - print("All examples completed successfully!") - - except Exception as e: - print(f"Error running examples: {e}") - raise - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/threads/suspend_resume_thread.py b/python/samples/getting_started/threads/suspend_resume_thread.py deleted file mode 100644 index 5799505d02..0000000000 --- a/python/samples/getting_started/threads/suspend_resume_thread.py +++ /dev/null @@ -1,92 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework.azure import AzureAIAgentClient -from agent_framework.openai import OpenAIChatClient -from azure.identity.aio import AzureCliCredential - -""" -Thread Suspend and Resume Example - -This sample demonstrates how to suspend and resume conversation threads, comparing -service-managed threads (Azure AI) with in-memory threads (OpenAI) for persistent -conversation state across sessions. -""" - - -async def suspend_resume_service_managed_thread() -> None: - """Demonstrates how to suspend and resume a service-managed thread.""" - print("=== Suspend-Resume Service-Managed Thread ===") - - # AzureAIAgentClient supports service-managed threads. - async with ( - AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( - name="MemoryBot", instructions="You are a helpful assistant that remembers our conversation." - ) as agent, - ): - # Start a new thread for the agent conversation. - thread = agent.get_new_thread() - - # Respond to user input. - query = "Hello! My name is Alice and I love pizza." - print(f"User: {query}") - print(f"Agent: {await agent.run(query, thread=thread)}\n") - - # Serialize the thread state, so it can be stored for later use. - serialized_thread = await thread.serialize() - - # The thread can now be saved to a database, file, or any other storage mechanism and loaded again later. - print(f"Serialized thread: {serialized_thread}\n") - - # Deserialize the thread state after loading from storage. - resumed_thread = await agent.deserialize_thread(serialized_thread) - - # Respond to user input. - query = "What do you remember about me?" - print(f"User: {query}") - print(f"Agent: {await agent.run(query, thread=resumed_thread)}\n") - - -async def suspend_resume_in_memory_thread() -> None: - """Demonstrates how to suspend and resume an in-memory thread.""" - print("=== Suspend-Resume In-Memory Thread ===") - - # OpenAI Chat Client is used as an example here, - # other chat clients can be used as well. - agent = OpenAIChatClient().as_agent( - name="MemoryBot", instructions="You are a helpful assistant that remembers our conversation." - ) - - # Start a new thread for the agent conversation. - thread = agent.get_new_thread() - - # Respond to user input. - query = "Hello! My name is Alice and I love pizza." - print(f"User: {query}") - print(f"Agent: {await agent.run(query, thread=thread)}\n") - - # Serialize the thread state, so it can be stored for later use. - serialized_thread = await thread.serialize() - - # The thread can now be saved to a database, file, or any other storage mechanism and loaded again later. - print(f"Serialized thread: {serialized_thread}\n") - - # Deserialize the thread state after loading from storage. - resumed_thread = await agent.deserialize_thread(serialized_thread) - - # Respond to user input. - query = "What do you remember about me?" - print(f"User: {query}") - print(f"Agent: {await agent.run(query, thread=resumed_thread)}\n") - - -async def main() -> None: - print("=== Suspend-Resume Thread Examples ===") - await suspend_resume_service_managed_thread() - await suspend_resume_in_memory_thread() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/tools/README.md b/python/samples/getting_started/tools/README.md deleted file mode 100644 index 3f5445bfb8..0000000000 --- a/python/samples/getting_started/tools/README.md +++ /dev/null @@ -1,144 +0,0 @@ -# Tools Examples - -This folder contains examples demonstrating how to use local tools with the Agent Framework. Local tools allow agents to interact with external systems, perform computations, and execute custom logic. - -Note: Several examples set `approval_mode="never_require"` to keep the samples concise. For production scenarios, -keep `approval_mode="always_require"` unless you are confident in the tool behavior and approval flow. See -`function_tool_with_approval.py` and `function_tool_with_approval_and_threads.py` for end-to-end approval handling. - -## Examples - -| File | Description | -|------|-------------| -| [`function_tool_declaration_only.py`](function_tool_declaration_only.py) | Demonstrates how to create function declarations without implementations. Useful for testing agent reasoning about tool usage or when tools are defined elsewhere. Shows how agents request tool calls even when the tool won't be executed. | -| [`function_tool_from_dict_with_dependency_injection.py`](function_tool_from_dict_with_dependency_injection.py) | Shows how to create local tools from dictionary definitions using dependency injection. The function implementation is injected at runtime during deserialization, enabling dynamic tool creation and configuration. Note: This serialization/deserialization feature is in active development. | -| [`function_tool_recover_from_failures.py`](function_tool_recover_from_failures.py) | Demonstrates graceful error handling when tools raise exceptions. Shows how agents receive error information and can recover from failures, deciding whether to retry or respond differently based on the exception. | -| [`function_tool_with_approval.py`](function_tool_with_approval.py) | Shows how to implement user approval workflows for function calls without using threads. Demonstrates both streaming and non-streaming approval patterns where users can approve or reject function executions before they run. | -| [`function_tool_with_approval_and_threads.py`](function_tool_with_approval_and_threads.py) | Demonstrates tool approval workflows using threads for automatic conversation history management. Shows how threads simplify approval workflows by automatically storing and retrieving conversation context. Includes both approval and rejection examples. | -| [`function_tool_with_kwargs.py`](function_tool_with_kwargs.py) | Demonstrates how to inject custom arguments (context) into a local tool from the agent's run method. Useful for passing runtime information like access tokens or user IDs that the tool needs but the model shouldn't see. | -| [`function_tool_with_thread_injection.py`](function_tool_with_thread_injection.py) | Shows how to access the current `thread` object inside a local tool via `**kwargs`. | -| [`function_tool_with_max_exceptions.py`](function_tool_with_max_exceptions.py) | Shows how to limit the number of times a tool can fail with exceptions using `max_invocation_exceptions`. Useful for preventing expensive tools from being called repeatedly when they keep failing. | -| [`function_tool_with_max_invocations.py`](function_tool_with_max_invocations.py) | Demonstrates limiting the total number of times a tool can be invoked using `max_invocations`. Useful for rate-limiting expensive operations or ensuring tools are only called a specific number of times per conversation. | -| [`function_tool_with_explicit_schema.py`](function_tool_with_explicit_schema.py) | Demonstrates how to provide an explicit Pydantic model or JSON schema dictionary to the `@tool` decorator via the `schema` parameter, bypassing automatic inference from the function signature. | -| [`tool_in_class.py`](tool_in_class.py) | Shows how to use the `tool` decorator with class methods to create stateful tools. Demonstrates how class state can control tool behavior dynamically, allowing you to adjust tool functionality at runtime by modifying class properties. | - -## Key Concepts - -### Local Tool Features - -- **Function Declarations**: Define tool schemas without implementations for testing or external tools -- **Explicit Schema**: Provide a Pydantic model or JSON schema dict to control the tool's parameter schema directly -- **Dependency Injection**: Create tools from configurations with runtime-injected implementations -- **Error Handling**: Gracefully handle and recover from tool execution failures -- **Approval Workflows**: Require user approval before executing sensitive or important operations -- **Invocation Limits**: Control how many times tools can be called or fail -- **Stateful Tools**: Use class methods as tools to maintain state and dynamically control behavior - -### Common Patterns - -#### Basic Tool Definition - -```python -from agent_framework import tool -from typing import Annotated - -@tool(approval_mode="never_require") -def my_tool(param: Annotated[str, "Description"]) -> str: - """Tool description for the AI.""" - return f"Result: {param}" -``` - -#### Tool with Approval - -```python -@tool(approval_mode="always_require") -def sensitive_operation(data: Annotated[str, "Data to process"]) -> str: - """This requires user approval before execution.""" - return f"Processed: {data}" -``` - -#### Tool with Explicit Schema - -```python -from pydantic import BaseModel, Field -from agent_framework import tool -from typing import Annotated - -class WeatherInput(BaseModel): - location: Annotated[str, Field(description="City name")] - unit: str = "celsius" - -@tool(schema=WeatherInput) -def get_weather(location: str, unit: str = "celsius") -> str: - """Get the weather for a location.""" - return f"Weather in {location}: 22 {unit}" -``` - -#### Tool with Invocation Limits - -```python -@tool(max_invocations=3) -def limited_tool() -> str: - """Can only be called 3 times total.""" - return "Result" - -@tool(max_invocation_exceptions=2) -def fragile_tool() -> str: - """Can only fail 2 times before being disabled.""" - return "Result" -``` - -#### Stateful Tools with Classes - -```python -class MyTools: - def __init__(self, mode: str = "normal"): - self.mode = mode - - def process(self, data: Annotated[str, "Data to process"]) -> str: - """Process data based on current mode.""" - if self.mode == "safe": - return f"Safely processed: {data}" - return f"Processed: {data}" - -# Create instance and use methods as tools -tools = MyTools(mode="safe") -agent = client.as_agent(tools=tools.process) - -# Change behavior dynamically -tools.mode = "normal" -``` - -### Error Handling - -When tools raise exceptions: -1. The exception is captured and sent to the agent as a function result -2. The agent receives the error message and can reason about what went wrong -3. The agent can retry with different parameters, use alternative tools, or explain the issue to the user -4. With invocation limits, tools can be disabled after repeated failures - -### Approval Workflows - -Two approaches for handling approvals: - -1. **Without Threads**: Manually manage conversation context, including the query, approval request, and response in each iteration -2. **With Threads**: Thread automatically manages conversation history, simplifying the approval workflow - -## Usage Tips - -- Use **declaration-only** functions when you want to test agent reasoning without execution -- Use **dependency injection** for dynamic tool configuration and plugin architectures -- Implement **approval workflows** for operations that modify data, spend money, or require human oversight -- Set **invocation limits** to prevent runaway costs or infinite loops with expensive tools -- Handle **exceptions gracefully** to create robust agents that can recover from failures -- Use **class-based tools** when you need to maintain state or dynamically adjust tool behavior at runtime - -## Running the Examples - -Each example is a standalone Python script that can be run directly: - -```bash -uv run python function_tool_with_approval.py -``` - -Make sure you have the necessary environment variables configured (like `OPENAI_API_KEY` or Azure credentials) before running the examples. diff --git a/python/samples/getting_started/tools/function_tool_with_thread_injection.py b/python/samples/getting_started/tools/function_tool_with_thread_injection.py deleted file mode 100644 index 0a02ef09d7..0000000000 --- a/python/samples/getting_started/tools/function_tool_with_thread_injection.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from typing import Annotated, Any - -from agent_framework import AgentThread, tool -from agent_framework.openai import OpenAIChatClient -from pydantic import Field - -""" -AI Function with Thread Injection Example - -This example demonstrates the behavior when passing 'thread' to agent.run() -and accessing that thread in AI function. -""" - - -# Define the function tool with **kwargs -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py. -@tool(approval_mode="never_require") -async def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], - **kwargs: Any, -) -> str: - """Get the weather for a given location.""" - # Get thread object from kwargs - thread = kwargs.get("thread") - if thread and isinstance(thread, AgentThread): - if thread.message_store: - messages = await thread.message_store.list_messages() - print(f"Thread contains {len(messages)} messages.") - elif thread.service_thread_id: - print(f"Thread ID: {thread.service_thread_id}.") - - return f"The weather in {location} is cloudy." - - -async def main() -> None: - agent = OpenAIChatClient().as_agent( - name="WeatherAgent", instructions="You are a helpful weather assistant.", tools=[get_weather] - ) - - # Create a thread - thread = agent.get_new_thread() - - # Run the agent with the thread - print(f"Agent: {await agent.run('What is the weather in London?', thread=thread)}") - print(f"Agent: {await agent.run('What is the weather in Amsterdam?', thread=thread)}") - print(f"Agent: {await agent.run('What cities did I ask about?', thread=thread)}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/_start-here/step4_using_factories.py b/python/samples/getting_started/workflows/_start-here/step4_using_factories.py deleted file mode 100644 index b5554fae81..0000000000 --- a/python/samples/getting_started/workflows/_start-here/step4_using_factories.py +++ /dev/null @@ -1,104 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework import ( - AgentResponseUpdate, - ChatAgent, - Executor, - WorkflowBuilder, - WorkflowContext, - executor, - handler, -) -from agent_framework.azure import AzureOpenAIChatClient -from azure.identity import AzureCliCredential - -""" -Step 4: Using Factories to Define Executors and Agents - -What this example shows -- Defining custom executors using both class-based and function-based approaches. -- Registering executor and agent factories with WorkflowBuilder for lazy instantiation. -- Building a simple workflow that transforms input text through multiple steps. - -Benefits of using factories -- Decouples executor and agent creation from workflow definition. -- Isolated instances are created for workflow builder build, allowing for cleaner state management - and handling parallel workflow runs. - -It is recommended to use factories when defining executors and agents for production workflows. - -Prerequisites -- No external services required. -""" - - -class UpperCase(Executor): - def __init__(self, id: str): - super().__init__(id=id) - - @handler - async def to_upper_case(self, text: str, ctx: WorkflowContext[str]) -> None: - """Convert the input to uppercase and forward it to the next node.""" - result = text.upper() - - # Send the result to the next executor in the workflow. - await ctx.send_message(result) - - -@executor(id="reverse_text_executor") -async def reverse_text(text: str, ctx: WorkflowContext[str]) -> None: - """Reverse the input string and send it downstream.""" - result = text[::-1] - - # Send the result to the next executor in the workflow. - await ctx.send_message(result) - - -def create_agent() -> ChatAgent: - """Factory function to create a Writer agent.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( - instructions=("You decode messages. Try to reconstruct the original message."), - name="decoder", - ) - - -async def main(): - """Build and run a simple 2-step workflow using the fluent builder API.""" - # Build the workflow using a fluent pattern: - # 1) register_executor(factory, name) registers an executor factory - # 2) register_agent(factory, name) registers an agent factory - # 3) add_chain([node_names]) adds a sequence of nodes to the workflow - # 4) set_start_executor(node) declares the entry point - # 5) build() finalizes and returns an immutable Workflow object - workflow = ( - WorkflowBuilder(start_executor="UpperCase") - .register_executor(lambda: UpperCase(id="upper_case_executor"), name="UpperCase") - .register_executor(lambda: reverse_text, name="ReverseText") - .register_agent(create_agent, name="DecoderAgent") - .add_chain(["UpperCase", "ReverseText", "DecoderAgent"]) - .build() - ) - - first_update = True - async for event in workflow.run("hello world", stream=True): - # The outputs of the workflow are whatever the agents produce. So the events are expected to - # contain `AgentResponseUpdate` from the agents in the workflow. - if event.type == "output" and isinstance(event.data, AgentResponseUpdate): - update = event.data - if first_update: - print(f"{update.author_name}: {update.text}", end="", flush=True) - first_update = False - else: - print(update.text, end="", flush=True) - - """ - Sample Output: - - decoder: HELLO WORLD - """ - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/agents/azure_ai_agents_streaming.py b/python/samples/getting_started/workflows/agents/azure_ai_agents_streaming.py deleted file mode 100644 index d05fcbf319..0000000000 --- a/python/samples/getting_started/workflows/agents/azure_ai_agents_streaming.py +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework import AgentResponseUpdate, WorkflowBuilder -from agent_framework.azure import AzureAIAgentClient -from azure.identity.aio import AzureCliCredential - -""" -Sample: Azure AI Agents in a Workflow with Streaming - -This sample shows how to create Azure AI Agents and use them in a workflow with streaming. - -Prerequisites: -- Azure AI Agent Service configured, along with the required environment variables. -- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. -- Basic familiarity with WorkflowBuilder, edges, events, and streaming runs. -""" - - -async def main() -> None: - async with AzureCliCredential() as cred, AzureAIAgentClient(credential=cred) as client: - # Create two agents: a Writer and a Reviewer. - writer_agent = client.as_agent( - name="Writer", - instructions=( - "You are an excellent content writer. You create new content and edit contents based on the feedback." - ), - ) - - reviewer_agent = client.as_agent( - name="Reviewer", - instructions=( - "You are an excellent content reviewer. " - "Provide actionable feedback to the writer about the provided content. " - "Provide the feedback in the most concise manner possible." - ), - ) - - # Build the workflow by adding agents directly as edges. - # Agents adapt to workflow mode: run(stream=True) for incremental updates, run() for complete responses. - workflow = WorkflowBuilder(start_executor=writer_agent).add_edge(writer_agent, reviewer_agent).build() - - # Track the last author to format streaming output. - last_author: str | None = None - - events = workflow.run( - "Create a slogan for a new electric SUV that is affordable and fun to drive.", stream=True - ) - async for event in events: - # The outputs of the workflow are whatever the agents produce. So the events are expected to - # contain `AgentResponseUpdate` from the agents in the workflow. - if event.type == "output" and isinstance(event.data, AgentResponseUpdate): - update = event.data - author = update.author_name - if author != last_author: - if last_author is not None: - print() # Newline between different authors - print(f"{author}: {update.text}", end="", flush=True) - last_author = author - else: - print(update.text, end="", flush=True) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/agents/azure_ai_agents_with_shared_thread.py b/python/samples/getting_started/workflows/agents/azure_ai_agents_with_shared_thread.py deleted file mode 100644 index 890dbe396f..0000000000 --- a/python/samples/getting_started/workflows/agents/azure_ai_agents_with_shared_thread.py +++ /dev/null @@ -1,102 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework import ( - AgentExecutorRequest, - AgentExecutorResponse, - ChatMessageStore, - WorkflowBuilder, - WorkflowContext, - WorkflowRunState, - executor, -) -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential - -""" -Sample: Agents with a shared thread in a workflow - -A Writer agent generates content, then a Reviewer agent critiques it, sharing a common message thread. - -Purpose: -Show how to use a shared thread between multiple agents in a workflow. -By default, agents have individual threads, but sharing a thread allows them to share all messages. - -Notes: -- Not all agents can share threads; usually only the same type of agents can share threads. - -Demonstrate: -- Creating multiple agents with Azure AI Agent Service (V2 API). -- Setting up a shared thread between agents. - -Prerequisites: -- Azure AI Agent Service configured, along with the required environment variables. -- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. -- Basic familiarity with agents, workflows, and executors in the agent framework. -""" - - -@executor(id="intercept_agent_response") -async def intercept_agent_response( - agent_response: AgentExecutorResponse, ctx: WorkflowContext[AgentExecutorRequest] -) -> None: - """This executor intercepts the agent response and sends a request without messages. - - This essentially prevents duplication of messages in the shared thread. Without this - executor, the response will be added to the thread as input of the next agent call. - """ - await ctx.send_message(AgentExecutorRequest(messages=[])) - - -async def main() -> None: - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - writer = await provider.create_agent( - instructions=( - "You are a concise copywriter. Provide a single, punchy marketing sentence based on the prompt." - ), - name="writer", - ) - - reviewer = await provider.create_agent( - instructions=("You are a thoughtful reviewer. Give brief feedback on the previous assistant message."), - name="reviewer", - ) - - shared_thread = writer.get_new_thread() - # Set the message store to store messages in memory. - shared_thread.message_store = ChatMessageStore() - - workflow = ( - WorkflowBuilder(start_executor="writer") - .register_agent(factory_func=lambda: writer, name="writer", agent_thread=shared_thread) - .register_agent(factory_func=lambda: reviewer, name="reviewer", agent_thread=shared_thread) - .register_executor( - factory_func=lambda: intercept_agent_response, - name="intercept_agent_response", - ) - .add_chain(["writer", "intercept_agent_response", "reviewer"]) - .build() - ) - - result = await workflow.run( - "Write a tagline for a budget-friendly eBike.", - # Keyword arguments will be passed to each agent call. - # Setting store=False to avoid storing messages in the service for this example. - options={"store": False}, - ) - # The final state should be IDLE since the workflow no longer has messages to - # process after the reviewer agent responds. - assert result.get_final_state() == WorkflowRunState.IDLE - - # The shared thread now contains the conversation between the writer and reviewer. Print it out. - print("=== Shared Thread Conversation ===") - for message in shared_thread.message_store.messages: - print(f"{message.author_name or message.role}: {message.text}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/agents/workflow_as_agent_with_thread.py b/python/samples/getting_started/workflows/agents/workflow_as_agent_with_thread.py deleted file mode 100644 index 621d54216f..0000000000 --- a/python/samples/getting_started/workflows/agents/workflow_as_agent_with_thread.py +++ /dev/null @@ -1,168 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework import AgentThread, ChatAgent, ChatMessageStore -from agent_framework.openai import OpenAIChatClient -from agent_framework.orchestrations import SequentialBuilder - -""" -Sample: Workflow as Agent with Thread Conversation History and Checkpointing - -This sample demonstrates how to use AgentThread with a workflow wrapped as an agent -to maintain conversation history across multiple invocations. When using as_agent(), -the thread's message store history is included in each workflow run, enabling -the workflow participants to reference prior conversation context. - -It also demonstrates how to enable checkpointing for workflow execution state -persistence, allowing workflows to be paused and resumed. - -Key concepts: -- Workflows can be wrapped as agents using workflow.as_agent() -- AgentThread with ChatMessageStore preserves conversation history -- Each call to agent.run() includes thread history + new message -- Participants in the workflow see the full conversation context -- checkpoint_storage parameter enables workflow state persistence - -Use cases: -- Multi-turn conversations with workflow-based orchestrations -- Stateful workflows that need context from previous interactions -- Building conversational agents that leverage workflow patterns -- Long-running workflows that need pause/resume capability - -Prerequisites: -- OpenAI environment variables configured for OpenAIChatClient -""" - - -async def main() -> None: - # Create a chat client - chat_client = OpenAIChatClient() - - # Define factory functions for workflow participants - def create_assistant() -> ChatAgent: - return chat_client.as_agent( - name="assistant", - instructions=( - "You are a helpful assistant. Answer questions based on the conversation " - "history. If the user asks about something mentioned earlier, reference it." - ), - ) - - def create_summarizer() -> ChatAgent: - return chat_client.as_agent( - name="summarizer", - instructions=( - "You are a summarizer. After the assistant responds, provide a brief " - "one-sentence summary of the key point from the conversation so far." - ), - ) - - # Build a sequential workflow: assistant -> summarizer - workflow = SequentialBuilder(participant_factories=[create_assistant, create_summarizer]).build() - - # Wrap the workflow as an agent - agent = workflow.as_agent(name="ConversationalWorkflowAgent") - - # Create a thread with a ChatMessageStore to maintain history - message_store = ChatMessageStore() - thread = AgentThread(message_store=message_store) - - print("=" * 60) - print("Workflow as Agent with Thread - Multi-turn Conversation") - print("=" * 60) - - # First turn: Introduce a topic - query1 = "My name is Alex and I'm learning about machine learning." - print(f"\n[Turn 1] User: {query1}") - - response1 = await agent.run(query1, thread=thread) - if response1.messages: - for msg in response1.messages: - speaker = msg.author_name or msg.role - print(f"[{speaker}]: {msg.text}") - - # Second turn: Reference the previous topic - query2 = "What was my name again, and what am I learning about?" - print(f"\n[Turn 2] User: {query2}") - - response2 = await agent.run(query2, thread=thread) - if response2.messages: - for msg in response2.messages: - speaker = msg.author_name or msg.role - print(f"[{speaker}]: {msg.text}") - - # Third turn: Ask a follow-up question - query3 = "Can you suggest a good first project for me to try?" - print(f"\n[Turn 3] User: {query3}") - - response3 = await agent.run(query3, thread=thread) - if response3.messages: - for msg in response3.messages: - speaker = msg.author_name or msg.role - print(f"[{speaker}]: {msg.text}") - - # Show the accumulated conversation history - print("\n" + "=" * 60) - print("Full Thread History") - print("=" * 60) - if thread.message_store: - history = await thread.message_store.list_messages() - for i, msg in enumerate(history, start=1): - role = msg.role if hasattr(msg.role, "value") else str(msg.role) - speaker = msg.author_name or role - text_preview = msg.text[:80] + "..." if len(msg.text) > 80 else msg.text - print(f"{i:02d}. [{speaker}]: {text_preview}") - - -async def demonstrate_thread_serialization() -> None: - """ - Demonstrates serializing and resuming a thread with a workflow agent. - - This shows how conversation history can be persisted and restored, - enabling long-running conversational workflows. - """ - chat_client = OpenAIChatClient() - - def create_assistant() -> ChatAgent: - return chat_client.as_agent( - name="memory_assistant", - instructions="You are a helpful assistant with good memory. Remember details from our conversation.", - ) - - workflow = SequentialBuilder(participant_factories=[create_assistant]).build() - agent = workflow.as_agent(name="MemoryWorkflowAgent") - - # Create initial thread and have a conversation - thread = AgentThread(message_store=ChatMessageStore()) - - print("\n" + "=" * 60) - print("Thread Serialization Demo") - print("=" * 60) - - # First interaction - query = "Remember this: the secret code is ALPHA-7." - print(f"\n[Session 1] User: {query}") - response = await agent.run(query, thread=thread) - if response.messages: - print(f"[assistant]: {response.messages[0].text}") - - # Serialize thread state (could be saved to database/file) - serialized_state = await thread.serialize() - print("\n[Serialized thread state for persistence]") - - # Simulate a new session by creating a new thread from serialized state - restored_thread = AgentThread(message_store=ChatMessageStore()) - await restored_thread.update_from_thread_state(serialized_state) - - # Continue conversation with restored thread - query = "What was the secret code I told you?" - print(f"\n[Session 2 - Restored] User: {query}") - response = await agent.run(query, thread=restored_thread) - if response.messages: - print(f"[assistant]: {response.messages[0].text}") - - -if __name__ == "__main__": - asyncio.run(main()) - asyncio.run(demonstrate_thread_serialization()) 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 99875c94c6..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 ( - AgentResponse, - ChatAgent, - ChatMessage, - Content, - FileCheckpointStorage, - 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[ChatAgent, ChatAgent, ChatAgent]: - """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, ChatAgent, ChatAgent, ChatAgent]: - """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, ChatMessage) for msg in event.data): # type: ignore - # Now safe to cast event.data to list[ChatMessage] - conversation = cast(list[ChatMessage], 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/parallelism/fan_out_fan_in_edges.py b/python/samples/getting_started/workflows/parallelism/fan_out_fan_in_edges.py deleted file mode 100644 index bb359262db..0000000000 --- a/python/samples/getting_started/workflows/parallelism/fan_out_fan_in_edges.py +++ /dev/null @@ -1,152 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from dataclasses import dataclass - -from agent_framework import ( - AgentExecutorRequest, # The message bundle sent to an AgentExecutor - AgentExecutorResponse, # The structured result returned by an AgentExecutor - ChatAgent, # Tracing event for agent execution steps - ChatMessage, # Chat message structure - Executor, # Base class for custom Python executors - WorkflowBuilder, # Fluent builder for wiring the workflow graph - WorkflowContext, # Per run context and event bus - handler, # Decorator to mark an Executor method as invokable -) -from agent_framework.azure import AzureOpenAIChatClient -from azure.identity import AzureCliCredential # Uses your az CLI login for credentials -from typing_extensions import Never - -""" -Sample: Concurrent fan out and fan in with three domain agents - -A dispatcher fans out the same user prompt to research, marketing, and legal AgentExecutor nodes. -An aggregator then fans in their responses and produces a single consolidated report. - -Purpose: -Show how to construct a parallel branch pattern in workflows. Demonstrate: -- Fan out by targeting multiple AgentExecutor nodes from one dispatcher. -- Fan in by collecting a list of AgentExecutorResponse objects and reducing them to a single result. - -Prerequisites: -- Familiarity with WorkflowBuilder, executors, edges, events, and streaming runs. -- Azure OpenAI access configured for AzureOpenAIChatClient. Log in with Azure CLI and set any required environment variables. -- Comfort reading AgentExecutorResponse.agent_response.text for assistant output aggregation. -""" - - -class DispatchToExperts(Executor): - """Dispatches the incoming prompt to all expert agent executors for parallel processing (fan out).""" - - @handler - async def dispatch(self, prompt: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None: - # Wrap the incoming prompt as a user message for each expert and request a response. - initial_message = ChatMessage("user", text=prompt) - await ctx.send_message(AgentExecutorRequest(messages=[initial_message], should_respond=True)) - - -@dataclass -class AggregatedInsights: - """Typed container for the aggregator to hold per domain strings before formatting.""" - - research: str - marketing: str - legal: str - - -class AggregateInsights(Executor): - """Aggregates expert agent responses into a single consolidated result (fan in).""" - - @handler - async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None: - # Map responses to text by executor id for a simple, predictable demo. - by_id: dict[str, str] = {} - for r in results: - # AgentExecutorResponse.agent_response.text is the assistant text produced by the agent. - by_id[r.executor_id] = r.agent_response.text - - research_text = by_id.get("researcher", "") - marketing_text = by_id.get("marketer", "") - legal_text = by_id.get("legal", "") - - aggregated = AggregatedInsights( - research=research_text, - marketing=marketing_text, - legal=legal_text, - ) - - # Provide a readable, consolidated string as the final workflow result. - consolidated = ( - "Consolidated Insights\n" - "====================\n\n" - f"Research Findings:\n{aggregated.research}\n\n" - f"Marketing Angle:\n{aggregated.marketing}\n\n" - f"Legal/Compliance Notes:\n{aggregated.legal}\n" - ) - - await ctx.yield_output(consolidated) - - -def create_researcher_agent() -> ChatAgent: - """Creates a research domain expert agent.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( - instructions=( - "You're an expert market and product researcher. Given a prompt, provide concise, factual insights," - " opportunities, and risks." - ), - name="researcher", - ) - - -def create_marketer_agent() -> ChatAgent: - """Creates a marketing domain expert agent.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( - instructions=( - "You're a creative marketing strategist. Craft compelling value propositions and target messaging" - " aligned to the prompt." - ), - name="marketer", - ) - - -def create_legal_agent() -> ChatAgent: - """Creates a legal/compliance domain expert agent.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( - instructions=( - "You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns" - " based on the prompt." - ), - name="legal", - ) - - -async def main() -> None: - # 1) Build a simple fan out and fan in workflow - workflow = ( - WorkflowBuilder(start_executor="dispatcher") - .register_agent(create_researcher_agent, name="researcher") - .register_agent(create_marketer_agent, name="marketer") - .register_agent(create_legal_agent, name="legal") - .register_executor(lambda: DispatchToExperts(id="dispatcher"), name="dispatcher") - .register_executor(lambda: AggregateInsights(id="aggregator"), name="aggregator") - .add_fan_out_edges("dispatcher", ["researcher", "marketer", "legal"]) # Parallel branches - .add_fan_in_edges(["researcher", "marketer", "legal"], "aggregator") # Join at the aggregator - .build() - ) - - # 3) Run with a single prompt and print progress plus the final consolidated output - async for event in workflow.run( - "We are launching a new budget-friendly electric bike for urban commuters.", stream=True - ): - if event.type == "executor_invoked": - # Show when executors are invoked and completed for lightweight observability. - print(f"{event.executor_id} invoked") - elif event.type == "executor_completed": - print(f"{event.executor_id} completed") - elif event.type == "output": - print("===== Final Aggregated Output =====") - print(event.data) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/semantic-kernel-migration/README.md b/python/samples/semantic-kernel-migration/README.md index c1fa894a4c..3a298fcf3d 100644 --- a/python/samples/semantic-kernel-migration/README.md +++ b/python/samples/semantic-kernel-migration/README.md @@ -7,14 +7,14 @@ This gallery helps Semantic Kernel (SK) developers move to the Microsoft Agent F ## What’s Included ### Chat completion parity -- [01_basic_chat_completion.py](chat_completion/01_basic_chat_completion.py) — Minimal SK `ChatCompletionAgent` and AF `ChatAgent` conversation. +- [01_basic_chat_completion.py](chat_completion/01_basic_chat_completion.py) — Minimal SK `ChatCompletionAgent` and AF `Agent` conversation. - [02_chat_completion_with_tool.py](chat_completion/02_chat_completion_with_tool.py) — Adds a simple tool/function call in both SDKs. -- [03_chat_completion_thread_and_stream.py](chat_completion/03_chat_completion_thread_and_stream.py) — Demonstrates thread reuse and streaming prompts. +- [03_chat_completion_thread_and_stream.py](chat_completion/03_chat_completion_thread_and_stream.py) — Demonstrates session reuse and streaming prompts. ### Azure AI agent parity - [01_basic_azure_ai_agent.py](azure_ai_agent/01_basic_azure_ai_agent.py) — Create and run an Azure AI agent end to end. - [02_azure_ai_agent_with_code_interpreter.py](azure_ai_agent/02_azure_ai_agent_with_code_interpreter.py) — Enable hosted code interpreter/tool execution. -- [03_azure_ai_agent_threads_and_followups.py](azure_ai_agent/03_azure_ai_agent_threads_and_followups.py) — Persist threads and follow-ups across invocations. +- [03_azure_ai_agent_threads_and_followups.py](azure_ai_agent/03_azure_ai_agent_threads_and_followups.py) — Persist sessions and follow-ups across invocations. ### OpenAI Assistants API parity - [01_basic_openai_assistant.py](openai_assistant/01_basic_openai_assistant.py) — Baseline assistant comparison. @@ -70,6 +70,6 @@ Swap the script path for any other workflow or process sample. Deactivate the sa ## Tips for Migration - Keep the original SK sample open while iterating on the AF equivalent; the code is intentionally formatted so you can copy/paste across SDKs. -- Threads/conversation state are explicit in AF. When porting SK code that relies on implicit thread reuse, call `agent.get_new_thread()` and pass it into each `run` call. +- Sessions/conversation state are explicit in AF. When porting SK code that relies on implicit session reuse, call `agent.create_session()` and pass it into each `run` call. - Tools map cleanly: SK `@kernel_function` plugins translate to AF `@tool` callables. Hosted tools (code interpreter, web search, MCP) are available only in AF—introduce them once parity is achieved. - For multi-agent orchestration, AF workflows expose checkpoints and resume capabilities that SK Process/Team abstractions do not. Use the workflow samples as a blueprint when modernizing complex agent graphs. diff --git a/python/samples/semantic-kernel-migration/azure_ai_agent/02_azure_ai_agent_with_code_interpreter.py b/python/samples/semantic-kernel-migration/azure_ai_agent/02_azure_ai_agent_with_code_interpreter.py index 81c059fc90..93074bd856 100644 --- a/python/samples/semantic-kernel-migration/azure_ai_agent/02_azure_ai_agent_with_code_interpreter.py +++ b/python/samples/semantic-kernel-migration/azure_ai_agent/02_azure_ai_agent_with_code_interpreter.py @@ -39,18 +39,24 @@ async def run_semantic_kernel() -> None: async def run_agent_framework() -> None: - from agent_framework.azure import AzureAIAgentClient, HostedCodeInterpreterTool + from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider from azure.identity.aio import AzureCliCredential async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + AzureAIAgentsProvider(credential=credential) as provider, + ): + # Create a client to access hosted tool factory methods + client = AzureAIAgentClient(agents_client=provider._agents_client) + code_interpreter_tool = client.get_code_interpreter_tool() + + agent = await provider.create_agent( name="Analyst", instructions="Use the code interpreter for numeric work.", - tools=[HostedCodeInterpreterTool()], - ) as agent, - ): - # HostedCodeInterpreterTool mirrors the built-in Azure AI capability. + tools=[code_interpreter_tool], + ) + + # Code interpreter tool mirrors the built-in Azure AI capability. reply = await agent.run( "Use Python to compute 42 ** 2 and explain the result.", tool_choice="auto", diff --git a/python/samples/semantic-kernel-migration/azure_ai_agent/03_azure_ai_agent_threads_and_followups.py b/python/samples/semantic-kernel-migration/azure_ai_agent/03_azure_ai_agent_threads_and_followups.py index ae0b28e37d..ecd4a2b0b4 100644 --- a/python/samples/semantic-kernel-migration/azure_ai_agent/03_azure_ai_agent_threads_and_followups.py +++ b/python/samples/semantic-kernel-migration/azure_ai_agent/03_azure_ai_agent_threads_and_followups.py @@ -52,19 +52,19 @@ async def run_agent_framework() -> None: instructions="Track follow-up questions within the same thread.", ) as agent, ): - thread = agent.get_new_thread() - # AF threads are explicit and can be serialized for external storage. - first = await agent.run("Outline the onboarding checklist.", thread=thread) + session = agent.create_session() + # AF sessions are explicit and can be serialized for external storage. + first = await agent.run("Outline the onboarding checklist.", session=session) print("[AF][turn1]", first.text) second = await agent.run( "Highlight the items that require legal review.", - thread=thread, + session=session, ) print("[AF][turn2]", second.text) - serialized = await thread.serialize() - print("[AF][thread-json]", serialized) + serialized = session.to_dict() + print("[AF][session-json]", serialized) async def main() -> None: diff --git a/python/samples/semantic-kernel-migration/chat_completion/01_basic_chat_completion.py b/python/samples/semantic-kernel-migration/chat_completion/01_basic_chat_completion.py index 74ecd1ecf5..63db51fb43 100644 --- a/python/samples/semantic-kernel-migration/chat_completion/01_basic_chat_completion.py +++ b/python/samples/semantic-kernel-migration/chat_completion/01_basic_chat_completion.py @@ -8,7 +8,7 @@ # uv run samples/semantic-kernel-migration/chat_completion/01_basic_chat_completion.py # Copyright (c) Microsoft. All rights reserved. -"""Basic SK ChatCompletionAgent vs Agent Framework ChatAgent. +"""Basic SK ChatCompletionAgent vs Agent Framework Agent. Both samples expect OpenAI-compatible environment variables (OPENAI_API_KEY or Azure OpenAI configuration). Update the prompts or client wiring to match your @@ -34,10 +34,10 @@ async def run_semantic_kernel() -> None: async def run_agent_framework() -> None: - """Call Agent Framework's ChatAgent created from OpenAIChatClient.""" + """Call Agent Framework's Agent created from OpenAIChatClient.""" from agent_framework.openai import OpenAIChatClient - # AF constructs a lightweight ChatAgent backed by OpenAIChatClient. + # AF constructs a lightweight Agent backed by OpenAIChatClient. chat_agent = OpenAIChatClient().as_agent( name="Support", instructions="Answer in one sentence.", diff --git a/python/samples/semantic-kernel-migration/chat_completion/02_chat_completion_with_tool.py b/python/samples/semantic-kernel-migration/chat_completion/02_chat_completion_with_tool.py index 2bf7266018..1267027364 100644 --- a/python/samples/semantic-kernel-migration/chat_completion/02_chat_completion_with_tool.py +++ b/python/samples/semantic-kernel-migration/chat_completion/02_chat_completion_with_tool.py @@ -56,10 +56,10 @@ async def run_agent_framework() -> None: instructions="Answer menu questions accurately.", tools=[specials], ) - thread = chat_agent.get_new_thread() + session = chat_agent.create_session() reply = await chat_agent.run( "What soup can I order today?", - thread=thread, + session=session, tool_choice="auto", ) print("[AF]", reply.text) diff --git a/python/samples/semantic-kernel-migration/chat_completion/03_chat_completion_thread_and_stream.py b/python/samples/semantic-kernel-migration/chat_completion/03_chat_completion_thread_and_stream.py index d357c2f957..78021a81ac 100644 --- a/python/samples/semantic-kernel-migration/chat_completion/03_chat_completion_thread_and_stream.py +++ b/python/samples/semantic-kernel-migration/chat_completion/03_chat_completion_thread_and_stream.py @@ -48,23 +48,23 @@ async def run_semantic_kernel() -> None: async def run_agent_framework() -> None: from agent_framework.openai import OpenAIChatClient - # AF thread objects are requested explicitly from the agent. + # AF session objects are requested explicitly from the agent. chat_agent = OpenAIChatClient().as_agent( name="Writer", instructions="Keep answers short and friendly.", ) - thread = chat_agent.get_new_thread() + session = chat_agent.create_session() first = await chat_agent.run( "Suggest a catchy headline for our product launch.", - thread=thread, + session=session, ) print("[AF]", first.text) print("[AF][stream]", end=" ") async for chunk in chat_agent.run( "Draft a 2 sentence blurb.", - thread=thread, + session=session, stream=True, ): if chunk.text: diff --git a/python/samples/semantic-kernel-migration/openai_assistant/01_basic_openai_assistant.py b/python/samples/semantic-kernel-migration/openai_assistant/01_basic_openai_assistant.py index 34709fbaf1..fbdd163fa7 100644 --- a/python/samples/semantic-kernel-migration/openai_assistant/01_basic_openai_assistant.py +++ b/python/samples/semantic-kernel-migration/openai_assistant/01_basic_openai_assistant.py @@ -50,7 +50,7 @@ async def run_agent_framework() -> None: print("[AF]", reply.text) follow_up = await assistant_agent.run( "How many residents live there?", - thread=assistant_agent.get_new_thread(), + session=assistant_agent.create_session(), ) print("[AF][follow-up]", follow_up.text) diff --git a/python/samples/semantic-kernel-migration/openai_assistant/02_openai_assistant_with_code_interpreter.py b/python/samples/semantic-kernel-migration/openai_assistant/02_openai_assistant_with_code_interpreter.py index 034404990d..b5bf4c35d3 100644 --- a/python/samples/semantic-kernel-migration/openai_assistant/02_openai_assistant_with_code_interpreter.py +++ b/python/samples/semantic-kernel-migration/openai_assistant/02_openai_assistant_with_code_interpreter.py @@ -37,16 +37,19 @@ async def run_semantic_kernel() -> None: async def run_agent_framework() -> None: - from agent_framework import HostedCodeInterpreterTool from agent_framework.openai import OpenAIAssistantsClient assistants_client = OpenAIAssistantsClient() + + # Create code interpreter tool using static method + code_interpreter_tool = OpenAIAssistantsClient.get_code_interpreter_tool() + # AF exposes the same tool configuration via create_agent. async with assistants_client.as_agent( name="CodeRunner", instructions="Use the code interpreter when calculations are required.", model="gpt-4.1", - tools=[HostedCodeInterpreterTool()], + tools=[code_interpreter_tool], ) as assistant_agent: response = await assistant_agent.run( "Use Python to calculate the mean of [41, 42, 45] and explain the steps.", diff --git a/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py b/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py index 3402a2e1e3..fce6ecb6ad 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py +++ b/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py @@ -35,12 +35,12 @@ async def run_semantic_kernel() -> None: async def run_agent_framework() -> None: - from agent_framework import ChatAgent + from agent_framework import Agent from agent_framework.openai import OpenAIResponsesClient - # AF ChatAgent can swap in an OpenAIResponsesClient directly. - chat_agent = ChatAgent( - chat_client=OpenAIResponsesClient(), + # AF Agent can swap in an OpenAIResponsesClient directly. + chat_agent = Agent( + client=OpenAIResponsesClient(), instructions="Answer in one concise sentence.", name="Expert", ) diff --git a/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py b/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py index c770763bce..599367f9c5 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py +++ b/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py @@ -42,7 +42,7 @@ async def run_semantic_kernel() -> None: async def run_agent_framework() -> None: - from agent_framework import ChatAgent + from agent_framework import Agent from agent_framework._tools import tool from agent_framework.openai import OpenAIResponsesClient @@ -50,8 +50,8 @@ async def run_agent_framework() -> None: async def add(a: float, b: float) -> float: return a + b - chat_agent = ChatAgent( - chat_client=OpenAIResponsesClient(), + chat_agent = Agent( + client=OpenAIResponsesClient(), instructions="Use the add tool when math is required.", name="MathExpert", # AF registers the async function as a tool at construction. diff --git a/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py b/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py index bd37c3b33c..07d9d0b4c7 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py +++ b/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py @@ -47,11 +47,11 @@ async def run_semantic_kernel() -> None: async def run_agent_framework() -> None: - from agent_framework import ChatAgent + from agent_framework import Agent from agent_framework.openai import OpenAIResponsesClient - chat_agent = ChatAgent( - chat_client=OpenAIResponsesClient(), + chat_agent = Agent( + client=OpenAIResponsesClient(), instructions="Return launch briefs as structured JSON.", name="ProductMarketer", ) diff --git a/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py b/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py index 72f0c24252..7a107d31ec 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py +++ b/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py @@ -15,10 +15,11 @@ import asyncio from collections.abc import Sequence from typing import cast -from agent_framework import ChatMessage +from agent_framework import Message from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.orchestrations import ConcurrentBuilder from azure.identity import AzureCliCredential -from semantic_kernel.agents import Agent, ChatCompletionAgent, ConcurrentOrchestration +from semantic_kernel.agents import ChatCompletionAgent, ConcurrentOrchestration from semantic_kernel.agents.runtime import InProcessRuntime from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion from semantic_kernel.contents import ChatMessageContent @@ -83,30 +84,30 @@ def _print_semantic_kernel_outputs(outputs: Sequence[ChatMessageContent]) -> Non ###################################################################### -async def run_agent_framework_example(prompt: str) -> Sequence[list[ChatMessage]]: - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) +async def run_agent_framework_example(prompt: str) -> Sequence[list[Message]]: + client = AzureOpenAIChatClient(credential=AzureCliCredential()) - physics = chat_client.as_agent( + physics = client.as_agent( instructions=("You are an expert in physics. Answer questions from a physics perspective."), name="physics", ) - chemistry = chat_client.as_agent( + chemistry = client.as_agent( instructions=("You are an expert in chemistry. Answer questions from a chemistry perspective."), name="chemistry", ) workflow = ConcurrentBuilder(participants=[physics, chemistry]).build() - outputs: list[list[ChatMessage]] = [] + outputs: list[list[Message]] = [] async for event in workflow.run(prompt, stream=True): if event.type == "output": - outputs.append(cast(list[ChatMessage], event.data)) + outputs.append(cast(list[Message], event.data)) return outputs -def _print_agent_framework_outputs(conversations: Sequence[Sequence[ChatMessage]]) -> None: +def _print_agent_framework_outputs(conversations: Sequence[Sequence[Message]]) -> None: if not conversations: print("No Agent Framework output.") return diff --git a/python/samples/semantic-kernel-migration/orchestrations/group_chat.py b/python/samples/semantic-kernel-migration/orchestrations/group_chat.py index 539041a537..e244bd0c01 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/group_chat.py +++ b/python/samples/semantic-kernel-migration/orchestrations/group_chat.py @@ -16,7 +16,7 @@ import sys from collections.abc import Sequence from typing import Any, cast -from agent_framework import ChatAgent, ChatMessage +from agent_framework import Agent, Message from agent_framework.azure import AzureOpenAIChatClient, AzureOpenAIResponsesClient from agent_framework.orchestrations import GroupChatBuilder from azure.identity import AzureCliCredential @@ -224,21 +224,21 @@ async def run_semantic_kernel_example(task: str) -> str: async def run_agent_framework_example(task: str) -> str: credential = AzureCliCredential() - researcher = ChatAgent( + researcher = Agent( name="Researcher", description="Collects background information and potential resources.", instructions=( "Gather concise facts or considerations that help plan a community hackathon. " "Keep your responses factual and scannable." ), - chat_client=AzureOpenAIChatClient(credential=credential), + client=AzureOpenAIChatClient(credential=credential), ) - planner = ChatAgent( + planner = Agent( name="Planner", description="Turns the collected notes into a concrete action plan.", instructions=("Propose a structured action plan that accounts for logistics, roles, and timeline."), - chat_client=AzureOpenAIResponsesClient(credential=credential), + client=AzureOpenAIResponsesClient(credential=credential), ) workflow = GroupChatBuilder( @@ -253,7 +253,7 @@ async def run_agent_framework_example(task: str) -> str: if isinstance(data, list) and len(data) > 0: # Get the final message from the conversation final_message = data[-1] - final_response = final_message.text or "" if isinstance(final_message, ChatMessage) else str(data) + final_response = final_message.text or "" if isinstance(final_message, Message) else str(data) else: final_response = str(data) return final_response diff --git a/python/samples/semantic-kernel-migration/orchestrations/handoff.py b/python/samples/semantic-kernel-migration/orchestrations/handoff.py index 3fe024a9f4..9891442369 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/handoff.py +++ b/python/samples/semantic-kernel-migration/orchestrations/handoff.py @@ -16,11 +16,11 @@ from collections.abc import AsyncIterable, Iterator, Sequence from typing import cast from agent_framework import ( - ChatMessage, + Message, WorkflowEvent, ) -from agent_framework.orchestrations import HandoffBuilder, HandoffUserInputRequest from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.orchestrations import HandoffBuilder, HandoffUserInputRequest from azure.identity import AzureCliCredential from semantic_kernel.agents import Agent, ChatCompletionAgent, HandoffOrchestration, OrchestrationHandoffs from semantic_kernel.agents.runtime import InProcessRuntime @@ -228,10 +228,10 @@ def _collect_handoff_requests(events: list[WorkflowEvent]) -> list[WorkflowEvent return requests -def _extract_final_conversation(events: list[WorkflowEvent]) -> list[ChatMessage]: +def _extract_final_conversation(events: list[WorkflowEvent]) -> list[Message]: for event in events: if event.type == "output": - data = cast(list[ChatMessage], event.data) + data = cast(list[Message], event.data) return data return [] diff --git a/python/samples/semantic-kernel-migration/orchestrations/magentic.py b/python/samples/semantic-kernel-migration/orchestrations/magentic.py index d6509fb4d7..44a8efc832 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/magentic.py +++ b/python/samples/semantic-kernel-migration/orchestrations/magentic.py @@ -15,7 +15,7 @@ import asyncio from collections.abc import Sequence from typing import cast -from agent_framework import ChatAgent, HostedCodeInterpreterTool +from agent_framework import Agent from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient from agent_framework.orchestrations import MagenticBuilder from semantic_kernel.agents import ( @@ -129,29 +129,33 @@ def _print_semantic_kernel_outputs(outputs: Sequence[ChatMessageContent]) -> Non async def run_agent_framework_example(prompt: str) -> str | None: - researcher = ChatAgent( + researcher = Agent( name="ResearcherAgent", description="Specialist in research and information gathering", instructions=( "You are a Researcher. You find information without additional computation or quantitative analysis." ), - chat_client=OpenAIChatClient(ai_model_id="gpt-4o-search-preview"), + client=OpenAIChatClient(ai_model_id="gpt-4o-search-preview"), ) - coder = ChatAgent( + # Create code interpreter tool using instance method + coder_client = OpenAIResponsesClient() + code_interpreter_tool = coder_client.get_code_interpreter_tool() + + coder = Agent( name="CoderAgent", description="A helpful assistant that writes and executes code to process and analyze data.", instructions="You solve questions using code. Please provide detailed analysis and computation process.", - chat_client=OpenAIResponsesClient(), - tools=HostedCodeInterpreterTool(), + client=coder_client, + tools=code_interpreter_tool, ) # Create a manager agent for orchestration - manager_agent = ChatAgent( + manager_agent = Agent( name="MagenticManager", description="Orchestrator that coordinates the research and coding workflow", instructions="You coordinate a team to complete complex tasks efficiently.", - chat_client=OpenAIChatClient(), + client=OpenAIChatClient(), ) workflow = MagenticBuilder(participants=[researcher, coder], manager_agent=manager_agent).build() diff --git a/python/samples/semantic-kernel-migration/orchestrations/sequential.py b/python/samples/semantic-kernel-migration/orchestrations/sequential.py index 13bfdf82a0..c678bc22b8 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/sequential.py +++ b/python/samples/semantic-kernel-migration/orchestrations/sequential.py @@ -15,7 +15,7 @@ import asyncio from collections.abc import Sequence from typing import cast -from agent_framework import ChatMessage +from agent_framework import Message from agent_framework.azure import AzureOpenAIChatClient from agent_framework.orchestrations import SequentialBuilder from azure.identity import AzureCliCredential @@ -70,25 +70,25 @@ async def sk_agent_response_callback( ###################################################################### -async def run_agent_framework_example(prompt: str) -> list[ChatMessage]: - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) +async def run_agent_framework_example(prompt: str) -> list[Message]: + client = AzureOpenAIChatClient(credential=AzureCliCredential()) - writer = chat_client.as_agent( + writer = client.as_agent( instructions=("You are a concise copywriter. Provide a single, punchy marketing sentence based on the prompt."), name="writer", ) - reviewer = chat_client.as_agent( + reviewer = client.as_agent( instructions=("You are a thoughtful reviewer. Give brief feedback on the previous assistant message."), name="reviewer", ) workflow = SequentialBuilder(participants=[writer, reviewer]).build() - conversation_outputs: list[list[ChatMessage]] = [] + conversation_outputs: list[list[Message]] = [] async for event in workflow.run(prompt, stream=True): if event.type == "output": - conversation_outputs.append(cast(list[ChatMessage], event.data)) + conversation_outputs.append(cast(list[Message], event.data)) return conversation_outputs[-1] if conversation_outputs else [] @@ -112,7 +112,7 @@ async def run_semantic_kernel_example(prompt: str) -> str: await runtime.stop_when_idle() -def _format_conversation(conversation: list[ChatMessage]) -> None: +def _format_conversation(conversation: list[Message]) -> None: if not conversation: print("No Agent Framework output.") return diff --git a/python/samples/semantic-kernel-migration/processes/fan_out_fan_in_process.py b/python/samples/semantic-kernel-migration/processes/fan_out_fan_in_process.py index afca864ea7..62325d3c7b 100644 --- a/python/samples/semantic-kernel-migration/processes/fan_out_fan_in_process.py +++ b/python/samples/semantic-kernel-migration/processes/fan_out_fan_in_process.py @@ -20,7 +20,7 @@ from typing import TYPE_CHECKING, ClassVar, cast ###################################################################### # region Agent Framework imports ###################################################################### -from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler +from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler from pydantic import BaseModel, Field ###################################################################### diff --git a/python/samples/semantic-kernel-migration/processes/nested_process.py b/python/samples/semantic-kernel-migration/processes/nested_process.py index 775647d992..8fbe66acf3 100644 --- a/python/samples/semantic-kernel-migration/processes/nested_process.py +++ b/python/samples/semantic-kernel-migration/processes/nested_process.py @@ -26,7 +26,6 @@ from agent_framework import ( WorkflowBuilder, WorkflowContext, WorkflowExecutor, - handler, ) from pydantic import BaseModel, Field diff --git a/python/samples/getting_started/agents/resources/countries.json b/python/samples/shared/resources/countries.json similarity index 100% rename from python/samples/getting_started/agents/resources/countries.json rename to python/samples/shared/resources/countries.json diff --git a/python/samples/getting_started/agents/resources/employees.pdf b/python/samples/shared/resources/employees.pdf similarity index 100% rename from python/samples/getting_started/agents/resources/employees.pdf rename to python/samples/shared/resources/employees.pdf diff --git a/python/samples/getting_started/agents/resources/weather.json b/python/samples/shared/resources/weather.json similarity index 100% rename from python/samples/getting_started/agents/resources/weather.json rename to python/samples/shared/resources/weather.json diff --git a/python/samples/getting_started/sample_assets/sample.pdf b/python/samples/shared/sample_assets/sample.pdf similarity index 100% rename from python/samples/getting_started/sample_assets/sample.pdf rename to python/samples/shared/sample_assets/sample.pdf diff --git a/python/tests/samples/getting_started/test_chat_client_samples.py b/python/tests/samples/getting_started/test_chat_client_samples.py index 0a699c5908..df3c18b6d5 100644 --- a/python/tests/samples/getting_started/test_chat_client_samples.py +++ b/python/tests/samples/getting_started/test_chat_client_samples.py @@ -8,28 +8,28 @@ from typing import Any import pytest from pytest import MonkeyPatch, mark, param -from samples.getting_started.chat_client.azure_ai_chat_client import ( +from samples.getting_started.client.azure_ai_chat_client import ( main as azure_ai_chat_client, ) -from samples.getting_started.chat_client.azure_assistants_client import ( +from samples.getting_started.client.azure_assistants_client import ( main as azure_assistants_client, ) -from samples.getting_started.chat_client.azure_chat_client import ( +from samples.getting_started.client.azure_chat_client import ( main as azure_chat_client, ) -from samples.getting_started.chat_client.azure_responses_client import ( +from samples.getting_started.client.azure_responses_client import ( main as azure_responses_client, ) -from samples.getting_started.chat_client.chat_response_cancellation import ( +from samples.getting_started.client.chat_response_cancellation import ( main as chat_response_cancellation, ) -from samples.getting_started.chat_client.openai_assistants_client import ( +from samples.getting_started.client.openai_assistants_client import ( main as openai_assistants_client, ) -from samples.getting_started.chat_client.openai_chat_client import ( +from samples.getting_started.client.openai_chat_client import ( main as openai_chat_client, ) -from samples.getting_started.chat_client.openai_responses_client import ( +from samples.getting_started.client.openai_responses_client import ( main as openai_responses_client, ) diff --git a/python/uv.lock b/python/uv.lock index 0a6de3f940..40c021172b 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -84,19 +84,19 @@ wheels = [ [[package]] name = "ag-ui-protocol" -version = "0.1.10" +version = "0.1.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/bb/5a5ec893eea5805fb9a3db76a9888c3429710dfb6f24bbb37568f2cf7320/ag_ui_protocol-0.1.10.tar.gz", hash = "sha256:3213991c6b2eb24bb1a8c362ee270c16705a07a4c5962267a083d0959ed894f4", size = 6945, upload-time = "2025-11-06T15:17:17.068Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/c1/33ab11dc829c6c28d0d346988b2f394aa632d3ad63d1d2eb5f16eccd769b/ag_ui_protocol-0.1.11.tar.gz", hash = "sha256:b336dfebb5751e9cc2c676a3008a4bce4819004e6f6f8cba73169823564472ae", size = 6249, upload-time = "2026-02-11T12:41:36.085Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/78/eb55fabaab41abc53f52c0918a9a8c0f747807e5306273f51120fd695957/ag_ui_protocol-0.1.10-py3-none-any.whl", hash = "sha256:c81e6981f30aabdf97a7ee312bfd4df0cd38e718d9fc10019c7d438128b93ab5", size = 7889, upload-time = "2025-11-06T15:17:15.325Z" }, + { url = "https://files.pythonhosted.org/packages/14/83/5c6f4cb24d27d9cbe0c31ba2f3b4d1ff42bc6f87ba9facfa9e9d44046c6b/ag_ui_protocol-0.1.11-py3-none-any.whl", hash = "sha256:b0cc25570462a8eba8e57a098e0a2d6892a1f571a7bea7da2d4b60efd5d66789", size = 8392, upload-time = "2026-02-11T12:41:35.303Z" }, ] [[package]] name = "agent-framework" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { virtual = "." } dependencies = [ { name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -145,7 +145,7 @@ dev = [ [[package]] name = "agent-framework-a2a" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/a2a" } dependencies = [ { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -160,7 +160,7 @@ requires-dist = [ [[package]] name = "agent-framework-ag-ui" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/ag-ui" } dependencies = [ { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -188,7 +188,7 @@ provides-extras = ["dev"] [[package]] name = "agent-framework-anthropic" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/anthropic" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -203,13 +203,12 @@ requires-dist = [ [[package]] name = "agent-framework-azure-ai" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/azure-ai" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "azure-ai-agents", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-ai-projects", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -217,12 +216,11 @@ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, { name = "aiohttp" }, { name = "azure-ai-agents", specifier = "==1.2.0b5" }, - { name = "azure-ai-projects", specifier = ">=2.0.0b3" }, ] [[package]] name = "agent-framework-azure-ai-search" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/azure-ai-search" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -237,7 +235,7 @@ requires-dist = [ [[package]] name = "agent-framework-azurefunctions" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/azurefunctions" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -259,7 +257,7 @@ dev = [] [[package]] name = "agent-framework-bedrock" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/bedrock" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -276,7 +274,7 @@ requires-dist = [ [[package]] name = "agent-framework-chatkit" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/chatkit" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -291,7 +289,7 @@ requires-dist = [ [[package]] name = "agent-framework-claude" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/claude" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -306,7 +304,7 @@ requires-dist = [ [[package]] name = "agent-framework-copilotstudio" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/copilotstudio" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -321,9 +319,10 @@ requires-dist = [ [[package]] name = "agent-framework-core" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/core" } dependencies = [ + { name = "azure-ai-projects", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -332,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'" }, ] @@ -378,6 +377,7 @@ requires-dist = [ { name = "agent-framework-orchestrations", marker = "extra == 'all'", editable = "packages/orchestrations" }, { name = "agent-framework-purview", marker = "extra == 'all'", editable = "packages/purview" }, { name = "agent-framework-redis", marker = "extra == 'all'", editable = "packages/redis" }, + { name = "azure-ai-projects", specifier = ">=2.0.0b3" }, { name = "azure-identity", specifier = ">=1,<2" }, { name = "mcp", extras = ["ws"], specifier = ">=1.24.0,<2" }, { name = "openai", specifier = ">=1.99.0" }, @@ -386,14 +386,14 @@ 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"] [[package]] name = "agent-framework-declarative" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/declarative" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -418,7 +418,7 @@ dev = [{ name = "types-pyyaml" }] [[package]] name = "agent-framework-devui" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/devui" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -454,7 +454,7 @@ provides-extras = ["dev", "all"] [[package]] name = "agent-framework-durabletask" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/durabletask" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -481,7 +481,7 @@ dev = [{ name = "types-python-dateutil", specifier = ">=2.9.0" }] [[package]] name = "agent-framework-foundry-local" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/foundry_local" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -496,7 +496,7 @@ requires-dist = [ [[package]] name = "agent-framework-github-copilot" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/github_copilot" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -511,7 +511,7 @@ requires-dist = [ [[package]] name = "agent-framework-lab" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/lab" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -590,7 +590,7 @@ dev = [ [[package]] name = "agent-framework-mem0" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/mem0" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -605,7 +605,7 @@ requires-dist = [ [[package]] name = "agent-framework-ollama" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/ollama" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -620,7 +620,7 @@ requires-dist = [ [[package]] name = "agent-framework-orchestrations" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/orchestrations" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -631,7 +631,7 @@ requires-dist = [{ name = "agent-framework-core", editable = "packages/core" }] [[package]] name = "agent-framework-purview" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/purview" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -648,7 +648,7 @@ requires-dist = [ [[package]] name = "agent-framework-redis" -version = "1.0.0b260130" +version = "1.0.0b260212" source = { editable = "packages/redis" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -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]] @@ -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]] @@ -1853,7 +1853,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.128.6" +version = "0.129.0" 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/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" } 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/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, ] [[package]] @@ -1947,11 +1947,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.20.3" +version = "3.21.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/37/2e3b4e1765432856623f330889e467cbba7b4e04c44301d69b3efa454f40/filelock-3.21.1.tar.gz", hash = "sha256:fd13d64b92f79605f30ffaa0a2accb793f178b8aebcf56be8f1cad922fd278ad", size = 31476, upload-time = "2026-02-12T22:29:28.557Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, + { url = "https://files.pythonhosted.org/packages/b4/39/61981f3c5d123f6ceff73479e3ace1de81d90af95632e2fe3d094b0ac8a9/filelock-3.21.1-py3-none-any.whl", hash = "sha256:f3aa1887cdf3514b13a379b5835ff35327d7aa24a96db4453b97531c00476760", size = 21472, upload-time = "2026-02-12T22:29:27.518Z" }, ] [[package]] @@ -2980,80 +2980,92 @@ wheels = [ [[package]] name = "librt" -version = "0.7.8" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/3f/4ca7dd7819bf8ff303aca39c3c60e5320e46e766ab7f7dd627d3b9c11bdf/librt-0.8.0.tar.gz", hash = "sha256:cb74cdcbc0103fc988e04e5c58b0b31e8e5dd2babb9182b6f9490488eb36324b", size = 177306, upload-time = "2026-02-12T14:53:54.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/13/57b06758a13550c5f09563893b004f98e9537ee6ec67b7df85c3571c8832/librt-0.7.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b45306a1fc5f53c9330fbee134d8b3227fe5da2ab09813b892790400aa49352d", size = 56521, upload-time = "2026-01-14T12:54:40.066Z" }, - { url = "https://files.pythonhosted.org/packages/c2/24/bbea34d1452a10612fb45ac8356f95351ba40c2517e429602160a49d1fd0/librt-0.7.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:864c4b7083eeee250ed55135d2127b260d7eb4b5e953a9e5df09c852e327961b", size = 58456, upload-time = "2026-01-14T12:54:41.471Z" }, - { url = "https://files.pythonhosted.org/packages/04/72/a168808f92253ec3a810beb1eceebc465701197dbc7e865a1c9ceb3c22c7/librt-0.7.8-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6938cc2de153bc927ed8d71c7d2f2ae01b4e96359126c602721340eb7ce1a92d", size = 164392, upload-time = "2026-01-14T12:54:42.843Z" }, - { url = "https://files.pythonhosted.org/packages/14/5c/4c0d406f1b02735c2e7af8ff1ff03a6577b1369b91aa934a9fa2cc42c7ce/librt-0.7.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66daa6ac5de4288a5bbfbe55b4caa7bf0cd26b3269c7a476ffe8ce45f837f87d", size = 172959, upload-time = "2026-01-14T12:54:44.602Z" }, - { url = "https://files.pythonhosted.org/packages/82/5f/3e85351c523f73ad8d938989e9a58c7f59fb9c17f761b9981b43f0025ce7/librt-0.7.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4864045f49dc9c974dadb942ac56a74cd0479a2aafa51ce272c490a82322ea3c", size = 186717, upload-time = "2026-01-14T12:54:45.986Z" }, - { url = "https://files.pythonhosted.org/packages/08/f8/18bfe092e402d00fe00d33aa1e01dda1bd583ca100b393b4373847eade6d/librt-0.7.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a36515b1328dc5b3ffce79fe204985ca8572525452eacabee2166f44bb387b2c", size = 184585, upload-time = "2026-01-14T12:54:47.139Z" }, - { url = "https://files.pythonhosted.org/packages/4e/fc/f43972ff56fd790a9fa55028a52ccea1875100edbb856b705bd393b601e3/librt-0.7.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b7e7f140c5169798f90b80d6e607ed2ba5059784968a004107c88ad61fb3641d", size = 180497, upload-time = "2026-01-14T12:54:48.946Z" }, - { url = "https://files.pythonhosted.org/packages/e1/3a/25e36030315a410d3ad0b7d0f19f5f188e88d1613d7d3fd8150523ea1093/librt-0.7.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff71447cb778a4f772ddc4ce360e6ba9c95527ed84a52096bd1bbf9fee2ec7c0", size = 200052, upload-time = "2026-01-14T12:54:50.382Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b8/f3a5a1931ae2a6ad92bf6893b9ef44325b88641d58723529e2c2935e8abe/librt-0.7.8-cp310-cp310-win32.whl", hash = "sha256:047164e5f68b7a8ebdf9fae91a3c2161d3192418aadd61ddd3a86a56cbe3dc85", size = 43477, upload-time = "2026-01-14T12:54:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/fe/91/c4202779366bc19f871b4ad25db10fcfa1e313c7893feb942f32668e8597/librt-0.7.8-cp310-cp310-win_amd64.whl", hash = "sha256:d6f254d096d84156a46a84861183c183d30734e52383602443292644d895047c", size = 49806, upload-time = "2026-01-14T12:54:53.149Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, - { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, - { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, - { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" }, - { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" }, - { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" }, - { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" }, - { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" }, - { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" }, - { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, - { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, - { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, - { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, - { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, - { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, - { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, - { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, - { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, - { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, - { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, - { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, - { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, - { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, - { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, - { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, - { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, - { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, - { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, - { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, - { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, - { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, - { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, - { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, - { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, - { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, - { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, - { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, - { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, - { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, - { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, - { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, - { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, - { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/018cfd60629e0404e6917943789800aa2231defbea540a17b90cc4547b97/librt-0.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:db63cf3586a24241e89ca1ce0b56baaec9d371a328bd186c529b27c914c9a1ef", size = 65690, upload-time = "2026-02-12T14:51:57.761Z" }, + { url = "https://files.pythonhosted.org/packages/b5/80/8d39980860e4d1c9497ee50e5cd7c4766d8cfd90d105578eae418e8ffcbc/librt-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ba9d9e60651615bc614be5e21a82cdb7b1769a029369cf4b4d861e4f19686fb6", size = 68373, upload-time = "2026-02-12T14:51:59.013Z" }, + { url = "https://files.pythonhosted.org/packages/2d/76/6e6f7a443af63977e421bd542551fec4072d9eaba02e671b05b238fe73bc/librt-0.8.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb4b3ad543084ed79f186741470b251b9d269cd8b03556f15a8d1a99a64b7de5", size = 197091, upload-time = "2026-02-12T14:52:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/fa064181c231334c9f4cb69eb338132d39510c8928e84beba34b861d0a71/librt-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d2720335020219197380ccfa5c895f079ac364b4c429e96952cd6509934d8eb", size = 207350, upload-time = "2026-02-12T14:52:02.32Z" }, + { url = "https://files.pythonhosted.org/packages/50/49/e7f8438dd226305e3e5955d495114ad01448e6a6ffc0303289b4153b5fc5/librt-0.8.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726305d3e53419d27fc8cdfcd3f9571f0ceae22fa6b5ea1b3662c2e538f833e", size = 219962, upload-time = "2026-02-12T14:52:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/1f/2c/74086fc5d52e77107a3cc80a9a3209be6ad1c9b6bc99969d8d9bbf9fdfe4/librt-0.8.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3d107f603b5ee7a79b6aa6f166551b99b32fb4a5303c4dfcb4222fc6a0335e", size = 212939, upload-time = "2026-02-12T14:52:05.537Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ae/d6917c0ebec9bc2e0293903d6a5ccc7cdb64c228e529e96520b277318f25/librt-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41064a0c07b4cc7a81355ccc305cb097d6027002209ffca51306e65ee8293630", size = 221393, upload-time = "2026-02-12T14:52:07.164Z" }, + { url = "https://files.pythonhosted.org/packages/04/97/15df8270f524ce09ad5c19cbbe0e8f95067582507149a6c90594e7795370/librt-0.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c6e4c10761ddbc0d67d2f6e2753daf99908db85d8b901729bf2bf5eaa60e0567", size = 216721, upload-time = "2026-02-12T14:52:08.857Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/17cbcf9b7a1bae5016d9d3561bc7169b32c3bd216c47d934d3f270602c0c/librt-0.8.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ba581acad5ac8f33e2ff1746e8a57e001b47c6721873121bf8bbcf7ba8bd3aa4", size = 214790, upload-time = "2026-02-12T14:52:10.033Z" }, + { url = "https://files.pythonhosted.org/packages/2a/2d/010a236e8dc4d717dd545c46fd036dcced2c7ede71ef85cf55325809ff92/librt-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bdab762e2c0b48bab76f1a08acb3f4c77afd2123bedac59446aeaaeed3d086cf", size = 237384, upload-time = "2026-02-12T14:52:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/f1c0eff3df8760dee761029efb72991c554d9f3282f1048e8c3d0eb60997/librt-0.8.0-cp310-cp310-win32.whl", hash = "sha256:6a3146c63220d814c4a2c7d6a1eacc8d5c14aed0ff85115c1dfea868080cd18f", size = 54289, upload-time = "2026-02-12T14:52:12.798Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0b/2684d473e64890882729f91866ed97ccc0a751a0afc3b4bf1a7b57094dbb/librt-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:bbebd2bba5c6ae02907df49150e55870fdd7440d727b6192c46b6f754723dde9", size = 61347, upload-time = "2026-02-12T14:52:13.793Z" }, + { url = "https://files.pythonhosted.org/packages/51/e9/42af181c89b65abfd557c1b017cba5b82098eef7bf26d1649d82ce93ccc7/librt-0.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ce33a9778e294507f3a0e3468eccb6a698b5166df7db85661543eca1cfc5369", size = 65314, upload-time = "2026-02-12T14:52:14.778Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4a/15a847fca119dc0334a4b8012b1e15fdc5fc19d505b71e227eaf1bcdba09/librt-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8070aa3368559de81061ef752770d03ca1f5fc9467d4d512d405bd0483bfffe6", size = 68015, upload-time = "2026-02-12T14:52:15.797Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/ffc8dbd6ab68dd91b736c88529411a6729649d2b74b887f91f3aaff8d992/librt-0.8.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:20f73d4fecba969efc15cdefd030e382502d56bb6f1fc66b580cce582836c9fa", size = 194508, upload-time = "2026-02-12T14:52:16.835Z" }, + { url = "https://files.pythonhosted.org/packages/89/92/a7355cea28d6c48ff6ff5083ac4a2a866fb9b07b786aa70d1f1116680cd5/librt-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a512c88900bdb1d448882f5623a0b1ad27ba81a9bd75dacfe17080b72272ca1f", size = 205630, upload-time = "2026-02-12T14:52:18.58Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5e/54509038d7ac527828db95b8ba1c8f5d2649bc32fd8f39b1718ec9957dce/librt-0.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:015e2dde6e096d27c10238bf9f6492ba6c65822dfb69d2bf74c41a8e88b7ddef", size = 218289, upload-time = "2026-02-12T14:52:20.134Z" }, + { url = "https://files.pythonhosted.org/packages/6d/17/0ee0d13685cefee6d6f2d47bb643ddad3c62387e2882139794e6a5f1288a/librt-0.8.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c25a131013eadd3c600686a0c0333eb2896483cbc7f65baa6a7ee761017aef9", size = 211508, upload-time = "2026-02-12T14:52:21.413Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a8/1714ef6e9325582e3727de3be27e4c1b2f428ea411d09f1396374180f130/librt-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:21b14464bee0b604d80a638cf1ee3148d84ca4cc163dcdcecb46060c1b3605e4", size = 219129, upload-time = "2026-02-12T14:52:22.61Z" }, + { url = "https://files.pythonhosted.org/packages/89/d3/2d9fe353edff91cdc0ece179348054a6fa61f3de992c44b9477cb973509b/librt-0.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:05a3dd3f116747f7e1a2b475ccdc6fb637fd4987126d109e03013a79d40bf9e6", size = 213126, upload-time = "2026-02-12T14:52:23.819Z" }, + { url = "https://files.pythonhosted.org/packages/ad/8e/9f5c60444880f6ad50e3ff7475e5529e787797e7f3ad5432241633733b92/librt-0.8.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fa37f99bff354ff191c6bcdffbc9d7cdd4fc37faccfc9be0ef3a4fd5613977da", size = 212279, upload-time = "2026-02-12T14:52:25.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/eb/d4a2cfa647da3022ae977f50d7eda1d91f70d7d1883cf958a4b6ef689eab/librt-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1566dbb9d1eb0987264c9b9460d212e809ba908d2f4a3999383a84d765f2f3f1", size = 234654, upload-time = "2026-02-12T14:52:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/6a/31/26b978861c7983b036a3aea08bdbb2ec32bbaab1ad1d57c5e022be59afc1/librt-0.8.0-cp311-cp311-win32.whl", hash = "sha256:70defb797c4d5402166787a6b3c66dfb3fa7f93d118c0509ffafa35a392f4258", size = 54603, upload-time = "2026-02-12T14:52:27.342Z" }, + { url = "https://files.pythonhosted.org/packages/d0/78/f194ed7c48dacf875677e749c5d0d1d69a9daa7c994314a39466237fb1be/librt-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:db953b675079884ffda33d1dca7189fb961b6d372153750beb81880384300817", size = 61730, upload-time = "2026-02-12T14:52:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/97/ee/ad71095478d02137b6f49469dc808c595cfe89b50985f6b39c5345f0faab/librt-0.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:75d1a8cab20b2043f03f7aab730551e9e440adc034d776f15f6f8d582b0a5ad4", size = 52274, upload-time = "2026-02-12T14:52:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fb/53/f3bc0c4921adb0d4a5afa0656f2c0fbe20e18e3e0295e12985b9a5dc3f55/librt-0.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:17269dd2745dbe8e42475acb28e419ad92dfa38214224b1b01020b8cac70b645", size = 66511, upload-time = "2026-02-12T14:52:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/89/4b/4c96357432007c25a1b5e363045373a6c39481e49f6ba05234bb59a839c1/librt-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f4617cef654fca552f00ce5ffdf4f4b68770f18950e4246ce94629b789b92467", size = 68628, upload-time = "2026-02-12T14:52:31.491Z" }, + { url = "https://files.pythonhosted.org/packages/47/16/52d75374d1012e8fc709216b5eaa25f471370e2a2331b8be00f18670a6c7/librt-0.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5cb11061a736a9db45e3c1293cfcb1e3caf205912dfa085734ba750f2197ff9a", size = 198941, upload-time = "2026-02-12T14:52:32.489Z" }, + { url = "https://files.pythonhosted.org/packages/fc/11/d5dd89e5a2228567b1228d8602d896736247424484db086eea6b8010bcba/librt-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4bb00bd71b448f16749909b08a0ff16f58b079e2261c2e1000f2bbb2a4f0a45", size = 210009, upload-time = "2026-02-12T14:52:33.634Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/fc1a92a77c3020ee08ce2dc48aed4b42ab7c30fb43ce488d388673b0f164/librt-0.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95a719a049f0eefaf1952673223cf00d442952273cbd20cf2ed7ec423a0ef58d", size = 224461, upload-time = "2026-02-12T14:52:34.868Z" }, + { url = "https://files.pythonhosted.org/packages/7f/98/eb923e8b028cece924c246104aa800cf72e02d023a8ad4ca87135b05a2fe/librt-0.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bd32add59b58fba3439d48d6f36ac695830388e3da3e92e4fc26d2d02670d19c", size = 217538, upload-time = "2026-02-12T14:52:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/fd/67/24e80ab170674a1d8ee9f9a83081dca4635519dbd0473b8321deecddb5be/librt-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f764b2424cb04524ff7a486b9c391e93f93dc1bd8305b2136d25e582e99aa2f", size = 225110, upload-time = "2026-02-12T14:52:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c7/6fbdcbd1a6e5243c7989c21d68ab967c153b391351174b4729e359d9977f/librt-0.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f04ca50e847abc486fa8f4107250566441e693779a5374ba211e96e238f298b9", size = 217758, upload-time = "2026-02-12T14:52:38.89Z" }, + { url = "https://files.pythonhosted.org/packages/4b/bd/4d6b36669db086e3d747434430073e14def032dd58ad97959bf7e2d06c67/librt-0.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9ab3a3475a55b89b87ffd7e6665838e8458e0b596c22e0177e0f961434ec474a", size = 218384, upload-time = "2026-02-12T14:52:40.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/2d/afe966beb0a8f179b132f3e95c8dd90738a23e9ebdba10f89a3f192f9366/librt-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e36a8da17134ffc29373775d88c04832f9ecfab1880470661813e6c7991ef79", size = 241187, upload-time = "2026-02-12T14:52:43.55Z" }, + { url = "https://files.pythonhosted.org/packages/02/d0/6172ea4af2b538462785ab1a68e52d5c99cfb9866a7caf00fdf388299734/librt-0.8.0-cp312-cp312-win32.whl", hash = "sha256:4eb5e06ebcc668677ed6389164f52f13f71737fc8be471101fa8b4ce77baeb0c", size = 54914, upload-time = "2026-02-12T14:52:44.676Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cb/ceb6ed6175612a4337ad49fb01ef594712b934b4bc88ce8a63554832eb44/librt-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:0a33335eb59921e77c9acc05d0e654e4e32e45b014a4d61517897c11591094f8", size = 62020, upload-time = "2026-02-12T14:52:45.676Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7e/61701acbc67da74ce06ddc7ba9483e81c70f44236b2d00f6a4bfee1aacbf/librt-0.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:24a01c13a2a9bdad20997a4443ebe6e329df063d1978bbe2ebbf637878a46d1e", size = 52443, upload-time = "2026-02-12T14:52:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/6d/32/3edb0bcb4113a9c8bdcd1750663a54565d255027657a5df9d90f13ee07fa/librt-0.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7f820210e21e3a8bf8fde2ae3c3d10106d4de9ead28cbfdf6d0f0f41f5b12fa1", size = 66522, upload-time = "2026-02-12T14:52:48.219Z" }, + { url = "https://files.pythonhosted.org/packages/30/ab/e8c3d05e281f5d405ebdcc5bc8ab36df23e1a4b40ac9da8c3eb9928b72b9/librt-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4831c44b8919e75ca0dfb52052897c1ef59fdae19d3589893fbd068f1e41afbf", size = 68658, upload-time = "2026-02-12T14:52:50.351Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d3/74a206c47b7748bbc8c43942de3ed67de4c231156e148b4f9250869593df/librt-0.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:88c6e75540f1f10f5e0fc5e87b4b6c290f0e90d1db8c6734f670840494764af8", size = 199287, upload-time = "2026-02-12T14:52:51.938Z" }, + { url = "https://files.pythonhosted.org/packages/fa/29/ef98a9131cf12cb95771d24e4c411fda96c89dc78b09c2de4704877ebee4/librt-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9646178cd794704d722306c2c920c221abbf080fede3ba539d5afdec16c46dad", size = 210293, upload-time = "2026-02-12T14:52:53.128Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3e/89b4968cb08c53d4c2d8b02517081dfe4b9e07a959ec143d333d76899f6c/librt-0.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e1af31a710e17891d9adf0dbd9a5fcd94901a3922a96499abdbf7ce658f4e01", size = 224801, upload-time = "2026-02-12T14:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/6d/28/f38526d501f9513f8b48d78e6be4a241e15dd4b000056dc8b3f06ee9ce5d/librt-0.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:507e94f4bec00b2f590fbe55f48cd518a208e2474a3b90a60aa8f29136ddbada", size = 218090, upload-time = "2026-02-12T14:52:55.758Z" }, + { url = "https://files.pythonhosted.org/packages/02/ec/64e29887c5009c24dc9c397116c680caffc50286f62bd99c39e3875a2854/librt-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f1178e0de0c271231a660fbef9be6acdfa1d596803464706862bef6644cc1cae", size = 225483, upload-time = "2026-02-12T14:52:57.375Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/7850bdbc9f1a32d3feff2708d90c56fc0490b13f1012e438532781aa598c/librt-0.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:71fc517efc14f75c2f74b1f0a5d5eb4a8e06aa135c34d18eaf3522f4a53cd62d", size = 218226, upload-time = "2026-02-12T14:52:58.534Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4a/166bffc992d65ddefa7c47052010a87c059b44a458ebaf8f5eba384b0533/librt-0.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0583aef7e9a720dd40f26a2ad5a1bf2ccbb90059dac2b32ac516df232c701db3", size = 218755, upload-time = "2026-02-12T14:52:59.701Z" }, + { url = "https://files.pythonhosted.org/packages/da/5d/9aeee038bcc72a9cfaaee934463fe9280a73c5440d36bd3175069d2cb97b/librt-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d0f76fc73480d42285c609c0ea74d79856c160fa828ff9aceab574ea4ecfd7b", size = 241617, upload-time = "2026-02-12T14:53:00.966Z" }, + { url = "https://files.pythonhosted.org/packages/64/ff/2bec6b0296b9d0402aa6ec8540aa19ebcb875d669c37800cb43d10d9c3a3/librt-0.8.0-cp313-cp313-win32.whl", hash = "sha256:e79dbc8f57de360f0ed987dc7de7be814b4803ef0e8fc6d3ff86e16798c99935", size = 54966, upload-time = "2026-02-12T14:53:02.042Z" }, + { url = "https://files.pythonhosted.org/packages/08/8d/bf44633b0182996b2c7ea69a03a5c529683fa1f6b8e45c03fe874ff40d56/librt-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:25b3e667cbfc9000c4740b282df599ebd91dbdcc1aa6785050e4c1d6be5329ab", size = 62000, upload-time = "2026-02-12T14:53:03.822Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fd/c6472b8e0eac0925001f75e366cf5500bcb975357a65ef1f6b5749389d3a/librt-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:e9a3a38eb4134ad33122a6d575e6324831f930a771d951a15ce232e0237412c2", size = 52496, upload-time = "2026-02-12T14:53:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/13/79ebfe30cd273d7c0ce37a5f14dc489c5fb8b722a008983db2cfd57270bb/librt-0.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:421765e8c6b18e64d21c8ead315708a56fc24f44075059702e421d164575fdda", size = 66078, upload-time = "2026-02-12T14:53:06.085Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8f/d11eca40b62a8d5e759239a80636386ef88adecb10d1a050b38cc0da9f9e/librt-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:48f84830a8f8ad7918afd743fd7c4eb558728bceab7b0e38fd5a5cf78206a556", size = 68309, upload-time = "2026-02-12T14:53:07.121Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b4/f12ee70a3596db40ff3c88ec9eaa4e323f3b92f77505b4d900746706ec6a/librt-0.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f09d4884f882baa39a7e36bbf3eae124c4ca2a223efb91e567381d1c55c6b06", size = 196804, upload-time = "2026-02-12T14:53:08.164Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7e/70dbbdc0271fd626abe1671ad117bcd61a9a88cdc6a10ccfbfc703db1873/librt-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:693697133c3b32aa9b27f040e3691be210e9ac4d905061859a9ed519b1d5a376", size = 206915, upload-time = "2026-02-12T14:53:09.333Z" }, + { url = "https://files.pythonhosted.org/packages/79/13/6b9e05a635d4327608d06b3c1702166e3b3e78315846373446cf90d7b0bf/librt-0.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5512aae4648152abaf4d48b59890503fcbe86e85abc12fb9b096fe948bdd816", size = 221200, upload-time = "2026-02-12T14:53:10.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/6c/e19a3ac53e9414de43a73d7507d2d766cd22d8ca763d29a4e072d628db42/librt-0.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:995d24caa6bbb34bcdd4a41df98ac6d1af637cfa8975cb0790e47d6623e70e3e", size = 214640, upload-time = "2026-02-12T14:53:12.342Z" }, + { url = "https://files.pythonhosted.org/packages/30/f0/23a78464788619e8c70f090cfd099cce4973eed142c4dccb99fc322283fd/librt-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b9aef96d7593584e31ef6ac1eb9775355b0099fee7651fae3a15bc8657b67b52", size = 221980, upload-time = "2026-02-12T14:53:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/03/32/38e21420c5d7aa8a8bd2c7a7d5252ab174a5a8aaec8b5551968979b747bf/librt-0.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4f6e975377fbc4c9567cb33ea9ab826031b6c7ec0515bfae66a4fb110d40d6da", size = 215146, upload-time = "2026-02-12T14:53:14.8Z" }, + { url = "https://files.pythonhosted.org/packages/bb/00/bd9ecf38b1824c25240b3ad982fb62c80f0a969e6679091ba2b3afb2b510/librt-0.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:daae5e955764be8fd70a93e9e5133c75297f8bce1e802e1d3683b98f77e1c5ab", size = 215203, upload-time = "2026-02-12T14:53:16.087Z" }, + { url = "https://files.pythonhosted.org/packages/b9/60/7559bcc5279d37810b98d4a52616febd7b8eef04391714fd6bdf629598b1/librt-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7bd68cebf3131bb920d5984f75fe302d758db33264e44b45ad139385662d7bc3", size = 237937, upload-time = "2026-02-12T14:53:17.236Z" }, + { url = "https://files.pythonhosted.org/packages/41/cc/be3e7da88f1abbe2642672af1dc00a0bccece11ca60241b1883f3018d8d5/librt-0.8.0-cp314-cp314-win32.whl", hash = "sha256:1e6811cac1dcb27ca4c74e0ca4a5917a8e06db0d8408d30daee3a41724bfde7a", size = 50685, upload-time = "2026-02-12T14:53:18.888Z" }, + { url = "https://files.pythonhosted.org/packages/38/27/e381d0df182a8f61ef1f6025d8b138b3318cc9d18ad4d5f47c3bf7492523/librt-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:178707cda89d910c3b28bf5aa5f69d3d4734e0f6ae102f753ad79edef83a83c7", size = 57872, upload-time = "2026-02-12T14:53:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c5/0c/ca9dfdf00554a44dea7d555001248269a4bab569e1590a91391feb863fa4/librt-0.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3e8b77b5f54d0937b26512774916041756c9eb3e66f1031971e626eea49d0bf4", size = 48056, upload-time = "2026-02-12T14:53:21.473Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ed/6cc9c4ad24f90c8e782193c7b4a857408fd49540800613d1356c63567d7b/librt-0.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:789911e8fa40a2e82f41120c936b1965f3213c67f5a483fc5a41f5839a05dcbb", size = 68307, upload-time = "2026-02-12T14:53:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/84/d8/0e94292c6b3e00b6eeea39dd44d5703d1ec29b6dafce7eea19dc8f1aedbd/librt-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2b37437e7e4ef5e15a297b36ba9e577f73e29564131d86dd75875705e97402b5", size = 70999, upload-time = "2026-02-12T14:53:23.603Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f4/6be1afcbdeedbdbbf54a7c9d73ad43e1bf36897cebf3978308cd64922e02/librt-0.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:671a6152edf3b924d98a5ed5e6982ec9cb30894085482acadce0975f031d4c5c", size = 220782, upload-time = "2026-02-12T14:53:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8d/f306e8caa93cfaf5c6c9e0d940908d75dc6af4fd856baa5535c922ee02b1/librt-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8992ca186a1678107b0af3d0c9303d8c7305981b9914989b9788319ed4d89546", size = 235420, upload-time = "2026-02-12T14:53:27.047Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f2/65d86bd462e9c351326564ca805e8457442149f348496e25ccd94583ffa2/librt-0.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:001e5330093d887b8b9165823eca6c5c4db183fe4edea4fdc0680bbac5f46944", size = 246452, upload-time = "2026-02-12T14:53:28.341Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/39c88b503b4cb3fcbdeb3caa29672b6b44ebee8dcc8a54d49839ac280f3f/librt-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d920789eca7ef71df7f31fd547ec0d3002e04d77f30ba6881e08a630e7b2c30e", size = 238891, upload-time = "2026-02-12T14:53:29.625Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c6/6c0d68190893d01b71b9569b07a1c811e280c0065a791249921c83dc0290/librt-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:82fb4602d1b3e303a58bfe6165992b5a78d823ec646445356c332cd5f5bbaa61", size = 250249, upload-time = "2026-02-12T14:53:30.93Z" }, + { url = "https://files.pythonhosted.org/packages/52/7a/f715ed9e039035d0ea637579c3c0155ab3709a7046bc408c0fb05d337121/librt-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4d3e38797eb482485b486898f89415a6ab163bc291476bd95712e42cf4383c05", size = 240642, upload-time = "2026-02-12T14:53:32.174Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3c/609000a333debf5992efe087edc6467c1fdbdddca5b610355569bbea9589/librt-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a905091a13e0884701226860836d0386b88c72ce5c2fdfba6618e14c72be9f25", size = 239621, upload-time = "2026-02-12T14:53:33.39Z" }, + { url = "https://files.pythonhosted.org/packages/b9/df/87b0673d5c395a8f34f38569c116c93142d4dc7e04af2510620772d6bd4f/librt-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:375eda7acfce1f15f5ed56cfc960669eefa1ec8732e3e9087c3c4c3f2066759c", size = 262986, upload-time = "2026-02-12T14:53:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/09/7f/6bbbe9dcda649684773aaea78b87fff4d7e59550fbc2877faa83612087a3/librt-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:2ccdd20d9a72c562ffb73098ac411de351b53a6fbb3390903b2d33078ef90447", size = 51328, upload-time = "2026-02-12T14:53:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f3/e1981ab6fa9b41be0396648b5850267888a752d025313a9e929c4856208e/librt-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:25e82d920d4d62ad741592fcf8d0f3bda0e3fc388a184cb7d2f566c681c5f7b9", size = 58719, upload-time = "2026-02-12T14:53:37.183Z" }, + { url = "https://files.pythonhosted.org/packages/94/d1/433b3c06e78f23486fe4fdd19bc134657eb30997d2054b0dbf52bbf3382e/librt-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:92249938ab744a5890580d3cb2b22042f0dce71cdaa7c1369823df62bedf7cbc", size = 48753, upload-time = "2026-02-12T14:53:38.539Z" }, ] [[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'" }, @@ -3069,9 +3081,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] @@ -3113,11 +3125,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]] @@ -3878,7 +3890,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'" }, @@ -3890,14 +3902,14 @@ 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]] name = "openai-agents" -version = "0.8.3" +version = "0.8.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3908,9 +3920,9 @@ dependencies = [ { name = "types-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/6b/f86002a00f16b387b0570860e461475660d81eb00e2817391926d3947933/openai_agents-0.8.3.tar.gz", hash = "sha256:07a6e900b0fe4b7fd8f91a06ed9ab4fec9df335ed676f1c9e1125f60cb57919b", size = 2378346, upload-time = "2026-02-10T00:11:07.048Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/e0/9fa9eac9baf2816bc63cee28967d35a7ed9dc2f25e9fd2004f48ed6c8820/openai_agents-0.8.4.tar.gz", hash = "sha256:5d4c4861aedd56a82b15c6ddf6c53031a39859a222f08bbd5645d5967efa05e8", size = 2389744, upload-time = "2026-02-11T19:14:30.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/38/d77602daf5308395ee067954ffa7e96cb9ecf9292ad3b5f398f1c77e0b36/openai_agents-0.8.3-py3-none-any.whl", hash = "sha256:e562ec1a70177abaa34ca6f0428241a9dbeb6b3d73f88a7f4ba3ee3d72b3b98d", size = 378042, upload-time = "2026-02-10T00:11:04.967Z" }, + { url = "https://files.pythonhosted.org/packages/55/dc/10df015aebb0797a8367aab65200ac4f5221df20bbae76930f5b6ac8e001/openai_agents-0.8.4-py3-none-any.whl", hash = "sha256:2383c6e8e59ed4146b89d1b6f53e34e55caf94bc14ae3fd704e7aad5021f4ff1", size = 380662, upload-time = "2026-02-11T19:14:28.864Z" }, ] [[package]] @@ -4347,100 +4359,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]] @@ -4539,7 +4551,7 @@ wheels = [ [[package]] name = "posthog" -version = "7.8.5" +version = "7.8.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -4549,9 +4561,9 @@ dependencies = [ { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b0/10/8e74a5e997c8286f0b63c69da522e503b1ab11627217ab76a06c7b62d647/posthog-7.8.5.tar.gz", hash = "sha256:e4f3796ce18323d8e05139bf419a04d318ccc4ad77b210f4d9d7c7546aea4f35", size = 169117, upload-time = "2026-02-09T22:59:49.207Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/c9/a7c67c039f23f16a0b87d17561ba2a1c863b01f054a226c92437c539a7b6/posthog-7.8.6.tar.gz", hash = "sha256:6f67e18b5f19bf20d7ef2e1a80fa1ad879a5cd309ca13cfb300f45a8105968c4", size = 169304, upload-time = "2026-02-11T13:59:42.558Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/b3/59b61d4b90e2efd138abaa34d98c7a89a4a352850cc3a079a60a46780655/posthog-7.8.5-py3-none-any.whl", hash = "sha256:979d306f07e61a8e837746e5dc432aafc49827fecac91bd6c624dcf3a1967448", size = 194647, upload-time = "2026-02-09T22:59:47.744Z" }, + { url = "https://files.pythonhosted.org/packages/56/c7/41664398a838f52ddfc89141e4c38b88eaa01b9e9a269c5ac184bd8586c6/posthog-7.8.6-py3-none-any.whl", hash = "sha256:21809f73e8e8f09d2bc273b09582f1a9f997b66f51fc626ef5bd3c5bdffd8bcd", size = 194801, upload-time = "2026-02-11T13:59:41.26Z" }, ] [[package]] @@ -6464,16 +6476,30 @@ wheels = [ ] [[package]] -name = "typer-slim" -version = "0.21.1" +name = "typer" +version = "0.23.0" 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'" }, + { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "shellingham", 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/7e/e6/44e073787aa57cd71c151f44855232feb0f748428fd5242d7366e3c4ae8b/typer-0.23.0.tar.gz", hash = "sha256:d8378833e47ada5d3d093fa20c4c63427cc4e27127f6b349a6c359463087d8cc", size = 120181, upload-time = "2026-02-11T15:22:18.637Z" } 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/7a/ed/d6fca788b51d0d4640c4bc82d0e85bad4b49809bca36bf4af01b4dcb66a7/typer-0.23.0-py3-none-any.whl", hash = "sha256:79f4bc262b6c37872091072a3cb7cb6d7d79ee98c0c658b4364bdcde3c42c913", size = 56668, upload-time = "2026-02-11T15:22:21.075Z" }, +] + +[[package]] +name = "typer-slim" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typer", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/8a/881cfd399a119db89619dc1b93d36e2fb6720ddb112bceff41203f1abd72/typer_slim-0.23.0.tar.gz", hash = "sha256:be8b60243df27cfee444c6db1b10a85f4f3e54d940574f31a996f78aa35a8254", size = 4773, upload-time = "2026-02-11T15:22:19.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/3e/ba3a222c80ee070d9497ece3e1fe77253c142925dd4c90f04278aac0a9eb/typer_slim-0.23.0-py3-none-any.whl", hash = "sha256:1d693daf22d998a7b1edab8413cdcb8af07254154ce3956c1664dc11b01e2f8b", size = 3399, upload-time = "2026-02-11T15:22:17.792Z" }, ] [[package]] @@ -6559,27 +6585,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]]