diff --git a/.github/workflows/python-test-coverage-report.yml b/.github/workflows/python-test-coverage-report.yml index 9ea5b8022d..81a506f277 100644 --- a/.github/workflows/python-test-coverage-report.yml +++ b/.github/workflows/python-test-coverage-report.yml @@ -39,7 +39,7 @@ jobs: echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV - name: Pytest coverage comment id: coverageComment - uses: MishaKav/pytest-coverage-comment@v1.1.57 + uses: MishaKav/pytest-coverage-comment@v1.1.59 with: github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} issue-number: ${{ env.PR_NUMBER }} diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 1907443a5a..67397efff4 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -7,15 +7,15 @@ - 9.5.2 + 13.0.0 - + - + @@ -23,18 +23,18 @@ - + - - - + + + - - - - - - + + + + + + @@ -45,39 +45,39 @@ - + - - + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + @@ -102,7 +102,7 @@ - + @@ -114,7 +114,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -134,7 +134,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 7049dbf466..9835bfe830 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -65,6 +65,7 @@ + @@ -123,6 +124,7 @@ + @@ -175,7 +177,6 @@ - @@ -313,6 +314,7 @@ + diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index baaa579bf1..500a0a8eb0 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -2,9 +2,9 @@ 1.0.0 - $(VersionPrefix)-$(VersionSuffix).251110.2 - $(VersionPrefix)-preview.251110.2 - 1.0.0-preview.251110.2 + $(VersionPrefix)-$(VersionSuffix).251111.1 + $(VersionPrefix)-preview.251111.1 + 1.0.0-preview.251111.1 Debug;Release;Publish true diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Agent_Step18_DeepResearch.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Agent_Step18_DeepResearch.csproj new file mode 100644 index 0000000000..11c7beb3bf --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Agent_Step18_DeepResearch.csproj @@ -0,0 +1,20 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Program.cs new file mode 100644 index 0000000000..f6aa825a54 --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Program.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create an Azure AI Foundry Agent with the Deep Research Tool. + +using Azure.AI.Agents.Persistent; +using Azure.Identity; +using Microsoft.Agents.AI; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +var deepResearchDeploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEEP_RESEARCH_DEPLOYMENT_NAME") ?? "o3-deep-research"; +var modelDeploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o"; +var bingConnectionId = Environment.GetEnvironmentVariable("BING_CONNECTION_ID") ?? throw new InvalidOperationException("BING_CONNECTION_ID is not set."); + +// Configure extended network timeout for long-running Deep Research tasks. +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); + +// Define and configure the Deep Research tool. +DeepResearchToolDefinition deepResearchTool = new(new DeepResearchDetails( + bingGroundingConnections: [new(bingConnectionId)], + model: deepResearchDeploymentName) + ); + +// Create an agent with the Deep Research tool on the Azure AI agent service. +AIAgent agent = await persistentAgentsClient.CreateAIAgentAsync( + model: modelDeploymentName, + name: "DeepResearchAgent", + instructions: "You are a helpful Agent that assists in researching scientific topics.", + tools: [deepResearchTool]); + +const string Task = "Research the current state of studies on orca intelligence and orca language, " + + "including what is currently known about orcas' cognitive capabilities and communication systems."; + +Console.WriteLine($"# User: '{Task}'"); +Console.WriteLine(); + +try +{ + AgentThread thread = agent.GetNewThread(); + + await foreach (var response in agent.RunStreamingAsync(Task, thread)) + { + Console.Write(response.Text); + } +} +finally +{ + await persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id); +} diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/README.md b/dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/README.md new file mode 100644 index 0000000000..0404054306 --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/README.md @@ -0,0 +1,47 @@ +# What this sample demonstrates + +This sample demonstrates how to create an Azure AI Agent with the Deep Research Tool, which leverages the o3-deep-research reasoning model to perform comprehensive research on complex topics. + +Key features: +- Configuring and using the Deep Research Tool with Bing grounding +- Creating a persistent AI agent with deep research capabilities +- Executing deep research queries and retrieving results + +## Prerequisites + +Before running this sample, ensure you have: + +1. An Azure AI Foundry project set up +2. A deep research model deployment (e.g., o3-deep-research) +3. A model deployment (e.g., gpt-4o) +4. A Bing Connection configured in your Azure AI Foundry project +5. Azure CLI installed and authenticated + +**Important**: Please visit the following documentation for detailed setup instructions: +- [Deep Research Tool Documentation](https://aka.ms/agents-deep-research) +- [Research Tool Setup](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/deep-research#research-tool-setup) + +Pay special attention to the purple `Note` boxes in the Azure documentation. + +**Note**: The Bing Connection ID must be from the **project**, not the resource. It has the following format: + +``` +/subscriptions//resourceGroups//providers//accounts//projects//connections/ +``` + +## Environment Variables + +Set the following environment variables: + +```powershell +# Replace with your Azure AI Foundry project endpoint +$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/" + +# Replace with your Bing connection ID from the project +$env:BING_CONNECTION_ID="/subscriptions/.../connections/your-bing-connection" + +# Optional, defaults to o3-deep-research +$env:AZURE_FOUNDRY_PROJECT_DEEP_RESEARCH_DEPLOYMENT_NAME="o3-deep-research" + +# Optional, defaults to gpt-4o +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o" diff --git a/dotnet/samples/GettingStarted/Agents/README.md b/dotnet/samples/GettingStarted/Agents/README.md index b93e9ceb72..f510b03faf 100644 --- a/dotnet/samples/GettingStarted/Agents/README.md +++ b/dotnet/samples/GettingStarted/Agents/README.md @@ -44,6 +44,7 @@ Before you begin, ensure you have the following prerequisites: |[Using plugins with an agent](./Agent_Step15_Plugins/)|This sample demonstrates how to use plugins with an agent| |[Reducing chat history size](./Agent_Step16_ChatReduction/)|This sample demonstrates how to reduce the chat history to constrain its size, where chat history is maintained locally| |[Background responses](./Agent_Step17_BackgroundResponses/)|This sample demonstrates how to use background responses for long-running operations with polling and resumption support| +|[Deep research with an agent](./Agent_Step18_DeepResearch/)|This sample demonstrates how to use the Deep Research Tool to perform comprehensive research on complex topics| ## Running the samples from the console diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs index 653ebdf4c2..e418ca7131 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs @@ -16,7 +16,7 @@ internal static class WorkflowFactory internal static Workflow BuildWorkflow(IChatClient chatClient) { // Create executors - var startExecutor = new ConcurrentStartExecutor(); + var startExecutor = new ChatForwardingExecutor("Start"); var aggregationExecutor = new ConcurrentAggregationExecutor(); AIAgent frenchAgent = GetLanguageAgent("French", chatClient); AIAgent englishAgent = GetLanguageAgent("English", chatClient); @@ -38,33 +38,11 @@ internal static class WorkflowFactory private static ChatClientAgent GetLanguageAgent(string targetLanguage, IChatClient chatClient) => new(chatClient, instructions: $"You're a helpful assistant who always responds in {targetLanguage}.", name: $"{targetLanguage}Agent"); - /// - /// Executor that starts the concurrent processing by sending messages to the agents. - /// - private sealed class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor") - { - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) - { - return routeBuilder - .AddHandler>(this.RouteMessages) - .AddHandler(this.RouteTurnTokenAsync); - } - - private ValueTask RouteMessages(List messages, IWorkflowContext context, CancellationToken cancellationToken) - { - return context.SendMessageAsync(messages, cancellationToken: cancellationToken); - } - - private ValueTask RouteTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken) - { - return context.SendMessageAsync(token, cancellationToken: cancellationToken); - } - } - /// /// Executor that aggregates the results from the concurrent agents. /// - private sealed class ConcurrentAggregationExecutor() : Executor>("ConcurrentAggregationExecutor") + private sealed class ConcurrentAggregationExecutor() : + Executor>("ConcurrentAggregationExecutor"), IResettableExecutor { private readonly List _messages = []; @@ -85,5 +63,12 @@ internal static class WorkflowFactory await context.YieldOutputAsync(formattedMessages, cancellationToken); } } + + /// + public ValueTask ResetAsync() + { + this._messages.Clear(); + return default; + } } } diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/ConfirmInput.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/ConfirmInput.csproj index a78fbddbd7..b7c6379101 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/ConfirmInput.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/ConfirmInput.csproj @@ -28,6 +28,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/Program.cs index 5ecda10049..2117bf8b07 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ConfirmInput/Program.cs @@ -10,7 +10,7 @@ namespace Demo.Workflows.Declarative.ConfirmInput; /// and confirm it matches the original input. /// /// -/// See the README.md file in the parent folder (../Declarative/README.md) for detailed +/// See the README.md file in the parent folder (../README.md) for detailed /// information the configuration required to run this sample. /// internal sealed class Program diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/DeepResearch.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/DeepResearch.csproj index c4d542056d..619c727b1b 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/DeepResearch.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/DeepResearch.csproj @@ -28,6 +28,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/Program.cs index 0d1368d7ac..b3a7be9171 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/Program.cs @@ -14,7 +14,7 @@ namespace Demo.Workflows.Declarative.DeepResearch; /// using the Magentic orchestration pattern developed by AutoGen. /// /// -/// See the README.md file in the parent folder (../Declarative/README.md) for detailed +/// See the README.md file in the parent folder (../README.md) for detailed /// information the configuration required to run this sample. /// internal sealed class Program diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/ExecuteCode.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/ExecuteCode.csproj index 838f0f1e4f..ca7c10cde3 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/ExecuteCode.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/ExecuteCode.csproj @@ -29,6 +29,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj index f5475b66f4..1fb6abe55d 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj @@ -28,6 +28,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/FunctionTools.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/FunctionTools.csproj index 239df72098..888a48f5df 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/FunctionTools.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/FunctionTools.csproj @@ -28,6 +28,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/Program.cs index 170f9b08a0..2549312b95 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/Program.cs @@ -15,7 +15,7 @@ namespace Demo.Workflows.Declarative.FunctionTools; /// with function tools assigned. Exits the loop when the user enters "exit". /// /// -/// See the README.md file in the parent folder (../Declarative/README.md) for detailed +/// See the README.md file in the parent folder (../README.md) for detailed /// information the configuration required to run this sample. /// internal sealed class Program diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/GenerateCode/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/GenerateCode/Program.cs index 859b74b194..54c77d4077 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/GenerateCode/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/GenerateCode/Program.cs @@ -42,7 +42,7 @@ internal sealed class Program Console.WriteLine(code); } - private const string DefaultWorkflow = "HelloWorld.yaml"; + private const string DefaultWorkflow = "Marketing.yaml"; private string WorkflowFile { get; } diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/InputArguments.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/InputArguments.csproj new file mode 100644 index 0000000000..51582438eb --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/InputArguments.csproj @@ -0,0 +1,40 @@ + + + + Exe + net9.0 + net9.0 + $(ProjectsDebugTargetFrameworks) + enable + enable + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/InputArguments.yaml b/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/InputArguments.yaml new file mode 100644 index 0000000000..3f602d0e7b --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/InputArguments.yaml @@ -0,0 +1,97 @@ +# +# This workflow demonstrates providing input arguments to an agent. +# +# Example input: +# I'd like to go on vacation. +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_demo + actions: + + # Capture the original user message for input to the location-aware agent + - kind: SetVariable + id: set_count_increment + variable: Local.InputMessage + value: =System.LastMessage + + # Invoke the triage agent to determine location requirements + - kind: InvokeAzureAgent + id: solicit_input + conversationId: =System.ConversationId + agent: + name: LocationTriageAgent + input: + messages: =Local.ActionMessage + output: + messages: Local.TriageResponse + + # Request input from the user based on the triage response + - kind: RequestExternalInput + id: request_requirements + variable: Local.NextInput + + # Capture the most recent interaction for evaluation + - kind: SetTextVariable + id: set_status_message + variable: Local.LocationStatusInput + value: |- + AGENT - {MessageText(Local.TriageResponse)} + + USER - {MessageText(Local.NextInput)} + + # Evaluate the status of the location triage + - kind: InvokeAzureAgent + id: evaluate_location + agent: + name: LocationCaptureAgent + input: + messages: =UserMessage(Local.LocationStatusInput) + output: + responseObject: Local.LocationResponse + + # Determine if the location information is complete + - kind: ConditionGroup + id: check_completion + conditions: + + - condition: |- + =Local.LocationResponse.is_location_defined = false Or + Local.LocationResponse.is_location_confirmed = false + id: check_done + actions: + + # Capture the action message for input to the triage agent + - kind: SetVariable + id: set_next_message + variable: Local.ActionMessage + value: =AgentMessage(Local.LocationResponse.action) + + - kind: GotoAction + id: goto_solicit_input + actionId: solicit_input + + elseActions: + + # Create a new conversation so the prior context does not interfere + - kind: CreateConversation + id: conversation_location + conversationId: Local.LocationConversationId + + # Invoke the location-aware agent with the location argument + # and loop until the user types "EXIT" + - kind: InvokeAzureAgent + id: location_response + conversationId: =Local.LocationConversationId + agent: + name: LocationAwareAgent + input: + messages: =Local.InputMessage + arguments: + location: =Local.LocationResponse.place + externalLoop: + when: =Upper(System.LastMessage.Text) <> "EXIT" + output: + autoSend: true diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/Program.cs new file mode 100644 index 0000000000..ff12ccd874 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InputArguments/Program.cs @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Agents; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using OpenAI.Responses; +using Shared.Foundry; +using Shared.Workflows; + +namespace Demo.Workflows.Declarative.InputArguments; + +/// +/// Demonstrate a workflow that consumes input arguments to dynamically enhance the agent +/// instructions. Exits the loop when the user enters "exit". +/// +/// +/// See the README.md file in the parent folder (../README.md) for detailed +/// information the configuration required to run this sample. +/// +internal sealed class Program +{ + public static async Task Main(string[] args) + { + // Initialize configuration + IConfiguration configuration = Application.InitializeConfig(); + Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint)); + + // Ensure sample agents exist in Foundry. + await CreateAgentAsync(foundryEndpoint, configuration); + + // Get input from command line or console + string workflowInput = Application.GetInput(args); + + // Create the workflow factory. This class demonstrates how to initialize a + // declarative workflow from a YAML file. Once the workflow is created, it + // can be executed just like any regular workflow. + WorkflowFactory workflowFactory = new("InputArguments.yaml", foundryEndpoint); + + // Execute the workflow: The WorkflowRunner demonstrates how to execute + // a workflow, handle the workflow events, and providing external input. + // This also includes the ability to checkpoint workflow state and how to + // resume execution. + WorkflowRunner runner = new(); + await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); + } + + private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration) + { + AgentClient agentsClient = new(foundryEndpoint, new AzureCliCredential()); + + await agentsClient.CreateAgentAsync( + agentName: "LocationTriageAgent", + agentDefinition: DefineLocationTriageAgent(configuration), + agentDescription: "Chats with the user to solicit a location of interest."); + + await agentsClient.CreateAgentAsync( + agentName: "LocationCaptureAgent", + agentDefinition: DefineLocationCaptureAgent(configuration), + agentDescription: "Evaluate the status of soliciting the location."); + + await agentsClient.CreateAgentAsync( + agentName: "LocationAwareAgent", + agentDefinition: DefineLocationAwareAgent(configuration), + agentDescription: "Chats with the user with location awareness."); + } + + private static PromptAgentDefinition DefineLocationTriageAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + Your only job is to solicit a location from the user. + + Always repeat back the location when addressing the user, except when it is not known. + """ + }; + + private static PromptAgentDefinition DefineLocationCaptureAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + Request a location from the user. This location could be their own location + or perhaps a location they are interested in. + + City level precision is sufficient. + + If extrapolating region and country, confirm you have it right. + """, + TextOptions = + new ResponseTextOptions + { + TextFormat = + ResponseTextFormat.CreateJsonSchemaFormat( + "TaskEvaluation", + BinaryData.FromString( + """ + { + "type": "object", + "properties": { + "place": { + "type": "string", + "description": "Captures only your understanding of the location specified by the user without explanation, or 'unknown' if not yet defined." + }, + "action": { + "type": "string", + "description": "The instruction for the next action to take regarding the need for additional detail or confirmation." + }, + "is_location_defined": { + "type": "boolean", + "description": "True if the user location is understood." + }, + "is_location_confirmed": { + "type": "boolean", + "description": "True if the user location is confirmed. An unambiguous location may be implicitly confirmed without explicit user confirmation." + } + }, + "required": ["place", "action", "is_location_defined", "is_location_confirmed"], + "additionalProperties": false + } + """), + jsonSchemaFormatDescription: null, + jsonSchemaIsStrict: true), + } + }; + + private static PromptAgentDefinition DefineLocationAwareAgent(IConfiguration configuration) => + new(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + // Parameterized instructions reference the "location" input argument. + Instructions = + """ + Talk to the user about their request. + Their request is related to a specific location: {{location}}. + """, + StructuredInputs = + { + ["location"] = + new StructuredInputDefinition + { + IsRequired = false, + DefaultValue = BinaryData.FromString(@"""unknown"""), + Description = "The user's location", + } + } + }; +} diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Marketing.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Marketing.csproj index 76792809a9..12599a1b79 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Marketing.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Marketing.csproj @@ -28,6 +28,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Program.cs index 20987c6bfe..edeb82419b 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/Marketing/Program.cs @@ -13,7 +13,7 @@ namespace Demo.Workflows.Declarative.Marketing; /// sequentially engaging in a task. /// /// -/// See the README.md file in the parent folder (../Declarative/README.md) for detailed +/// See the README.md file in the parent folder (../README.md) for detailed /// information the configuration required to run this sample. /// internal sealed class Program diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/README.md b/dotnet/samples/GettingStarted/Workflows/Declarative/README.md index 0418cfe8da..665c37101e 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/README.md +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/README.md @@ -86,12 +86,14 @@ To run the sampes from the command line: 1. From the root of the repository, navigate the console to the project folder: ```sh - cd dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher + cd dotnet/samples/GettingStarted/Workflows/Declarative/Marketing + dotnet run Marketing ``` 2. Run the demo and optionally provided input: ```sh - dotnet run "How would you compute the value of PI?" + dotnet run "An eco-friendly stainless steel water bottle that keeps drinks cold for 24 hours." + dotnet run c:/myworkflows/Marketing.yaml ``` > The sample will allow for interactive input in the absence of an input argument. \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs index d2299add5f..bbe1c526d5 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs @@ -13,7 +13,7 @@ namespace Demo.Workflows.Declarative.StudentTeacher; /// in an iterative conversation. /// /// -/// See the README.md file in the parent folder (../Declarative/README.md) for detailed +/// See the README.md file in the parent folder (../README.md) for detailed /// information the configuration required to run this sample. /// internal sealed class Program diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/StudentTeacher.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/StudentTeacher.csproj index dae5bbef1f..7c210d6f96 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/StudentTeacher.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/StudentTeacher.csproj @@ -28,6 +28,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/Program.cs index 01be1abfd5..736edadf8d 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/Program.cs @@ -14,7 +14,7 @@ namespace Demo.Workflows.Declarative.ToolApproval; /// has an MCP tool that requires approval. Exits the loop when the user enters "exit". /// /// -/// See the README.md file in the parent folder (../Declarative/README.md) for detailed +/// See the README.md file in the parent folder (../README.md) for detailed /// information the configuration required to run this sample. /// internal sealed class Program diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.csproj index 5d5a013a33..6fa1cf12d9 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.csproj +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.csproj @@ -28,6 +28,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs index c6a64915cf..7262979207 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -32,6 +33,7 @@ public class AgentRunOptions _ = Throw.IfNull(options); this.ContinuationToken = options.ContinuationToken; this.AllowBackgroundResponses = options.AllowBackgroundResponses; + this.AdditionalProperties = options.AdditionalProperties?.Clone(); } /// @@ -74,4 +76,18 @@ public class AgentRunOptions /// /// public bool? AllowBackgroundResponses { get; set; } + + /// + /// Gets or sets additional properties associated with these options. + /// + /// + /// An containing custom properties, + /// or if no additional properties are present. + /// + /// + /// Additional properties provide a way to include custom metadata or provider-specific + /// information that doesn't fit into the standard options schema. This is useful for + /// preserving implementation-specific details or extending the options with custom data. + /// + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AgentsClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AgentsClientExtensions.cs index 6261cac4ef..38f33bf230 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AgentsClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AgentsClientExtensions.cs @@ -136,7 +136,7 @@ public static class AgentClientExtensions /// /// The client used to interact with Azure AI Agents. Cannot be . /// The agent version to be converted. Cannot be . - /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. + /// In-process invocable tools to be provided. If no tools are provided manual handling will be necessary to invoke in-process tools. /// Provides a way to customize the creation of the underlying used by the agent. /// An optional for configuring the underlying OpenAI client. /// An optional to use for resolving services required by the instances being invoked. @@ -156,7 +156,7 @@ public static class AgentClientExtensions Throw.IfNull(agentClient); Throw.IfNull(agentVersion); - ValidateUsingToolsParameter(agentVersion, tools); + var allowDeclarativeMode = tools is not { Count: > 0 }; return CreateChatClientAgent( agentClient, @@ -164,7 +164,7 @@ public static class AgentClientExtensions tools, clientFactory, openAIClientOptions, - requireInvocableTools: true, + !allowDeclarativeMode, services); } @@ -293,7 +293,6 @@ public static class AgentClientExtensions new AgentVersionCreationOptions(new PromptAgentDefinition(model) { Instructions = instructions }) { Description = description }, clientFactory, openAIClientOptions, - requireInvocableTools: true, services, cancellationToken); } @@ -339,7 +338,6 @@ public static class AgentClientExtensions new AgentVersionCreationOptions(new PromptAgentDefinition(model) { Instructions = instructions }) { Description = description }, clientFactory, openAIClientOptions, - requireInvocableTools: true, services, cancellationToken); } @@ -381,7 +379,7 @@ public static class AgentClientExtensions Instructions = options.Instructions, }; - ApplyToolsToAgentDefinition(agentDefinition, options.ChatOptions?.Tools, RequireInvocableTools); + ApplyToolsToAgentDefinition(agentDefinition, options.ChatOptions?.Tools); AgentVersionCreationOptions? creationOptions = new(agentDefinition); if (!string.IsNullOrWhiteSpace(options.Description)) @@ -440,7 +438,7 @@ public static class AgentClientExtensions Instructions = options.Instructions, }; - ApplyToolsToAgentDefinition(agentDefinition, options.ChatOptions?.Tools, RequireInvocableTools); + ApplyToolsToAgentDefinition(agentDefinition, options.ChatOptions?.Tools); AgentVersionCreationOptions? creationOptions = new(agentDefinition); if (!string.IsNullOrWhiteSpace(options.Description)) @@ -489,16 +487,13 @@ public static class AgentClientExtensions Throw.IfNullOrWhitespace(name); Throw.IfNull(creationOptions); - var tools = (creationOptions.Definition as PromptAgentDefinition)?.Tools.Select(t => t.AsAITool()).ToList(); - return CreateAIAgent( agentClient, name, - tools, + tools: null, creationOptions, clientFactory, openAIClientOptions, - requireInvocableTools: false, services: null, cancellationToken); } @@ -531,16 +526,13 @@ public static class AgentClientExtensions Throw.IfNull(agentClient); Throw.IfNull(creationOptions); - var tools = (creationOptions.Definition as PromptAgentDefinition)?.Tools.Select(t => t.AsAITool()).ToList(); - return CreateAIAgentAsync( agentClient, name, - tools, + tools: null, creationOptions, clientFactory, openAIClientOptions, - requireInvocableTools: false, services: null, cancellationToken); } @@ -594,7 +586,6 @@ public static class AgentClientExtensions AgentVersionCreationOptions creationOptions, Func? clientFactory, OpenAIClientOptions? openAIClientOptions, - bool requireInvocableTools, IServiceProvider? services, CancellationToken cancellationToken) { @@ -602,9 +593,12 @@ public static class AgentClientExtensions Throw.IfNullOrWhitespace(name); Throw.IfNull(creationOptions); - tools ??= (creationOptions.Definition as PromptAgentDefinition)?.Tools.Select(t => t.AsAITool()).ToList(); + var allowDeclarativeMode = tools is not { Count: > 0 }; - ApplyToolsToAgentDefinition(creationOptions.Definition, tools, requireInvocableTools); + if (!allowDeclarativeMode) + { + ApplyToolsToAgentDefinition(creationOptions.Definition, tools); + } AgentVersion agentVersion = CreateAgentVersionWithProtocol(agentClient, name, creationOptions, cancellationToken); @@ -614,7 +608,7 @@ public static class AgentClientExtensions tools, clientFactory, openAIClientOptions, - requireInvocableTools, + !allowDeclarativeMode, services); } @@ -625,7 +619,6 @@ public static class AgentClientExtensions AgentVersionCreationOptions creationOptions, Func? clientFactory, OpenAIClientOptions? openAIClientOptions, - bool requireInvocableTools, IServiceProvider? services, CancellationToken cancellationToken) { @@ -633,9 +626,12 @@ public static class AgentClientExtensions Throw.IfNull(agentClient); Throw.IfNull(creationOptions); - tools ??= (creationOptions.Definition as PromptAgentDefinition)?.Tools.Select(t => t.AsAITool()).ToList(); + var allowDeclarativeMode = tools is not { Count: > 0 }; - ApplyToolsToAgentDefinition(creationOptions.Definition, tools, requireInvocableTools); + if (!allowDeclarativeMode) + { + ApplyToolsToAgentDefinition(creationOptions.Definition, tools); + } AgentVersion agentVersion = await CreateAgentVersionWithProtocolAsync(agentClient, name, creationOptions, cancellationToken).ConfigureAwait(false); @@ -645,7 +641,7 @@ public static class AgentClientExtensions tools, clientFactory, openAIClientOptions, - requireInvocableTools, + !allowDeclarativeMode, services); } @@ -719,14 +715,11 @@ public static class AgentClientExtensions // Check function tools foreach (ResponseTool responseTool in definitionTools) { - if (responseTool is FunctionTool functionTool) + if (requireInvocableTools && responseTool is FunctionTool functionTool) { // Check if a tool with the same type and name exists in the provided tools. - var matchingTool = chatOptions?.Tools?.FirstOrDefault(t => - requireInvocableTools - ? t is AIFunction tf && functionTool.FunctionName == tf.Name // When invocable tools are required, match only AIFunction. - : (t is AIFunctionDeclaration tfd && functionTool.FunctionName == tfd.Name) ? true // When not required, match AIFunctionDeclaration OR - : (t.GetService() is FunctionTool ft && functionTool.FunctionName == ft.FunctionName)); // Match a FunctionTool converted AsAITool. + // When invocable tools are required, match only AIFunction. + var matchingTool = chatOptions?.Tools?.FirstOrDefault(t => t is AIFunction tf && functionTool.FunctionName == tf.Name); if (matchingTool is null) { @@ -742,7 +735,7 @@ public static class AgentClientExtensions (agentTools ??= []).Add(responseTool.AsAITool()); } - if (missingTools is { Count: > 0 }) + if (requireInvocableTools && missingTools is { Count: > 0 }) { throw new InvalidOperationException($"The following prompt agent definition required tools were not provided: {string.Join(", ", missingTools)}"); } @@ -796,35 +789,14 @@ public static class AgentClientExtensions return agentOptions; } - /// For already created agent versions, retrieve the definition and validate the tools parameter. - /// cannot be used. The parameter should be used instead. - /// - /// Because doesn't support in-proc tools (only declarative/definitions), - /// the parameter needs to be the single source of truth for tools, and must be provided when using tools. - /// - private static void ValidateUsingToolsParameter(AgentVersion agentVersion, IList? tools) - { - if (agentVersion.Definition is PromptAgentDefinition { Tools.Count: > 0 } && tools is null or { Count: 0 }) - { - throw new ArgumentException("When retrieving prompt agents with tools the tools parameter needs to be provided with the necessary tools.", nameof(tools)); - } - } - /// - /// Adds the specified AI tools to a prompt agent definition, ensuring that all tools are compatible and, if required, invocable. + /// Adds the specified AI tools to a prompt agent definition, while also ensuring that all invocable tools are provided. /// - /// This method ensures that only compatible and properly constructed tools are added to the agent definition. - /// When is , all tools must be - /// invocable AIFunctions, which can be created using AIFunctionFactory.Create. Tools are converted to ResponseTool - /// instances before being added. /// The agent definition to which the tools will be applied. Must be a PromptAgentDefinition to support tools. /// A list of AI tools to add to the agent definition. If null or empty, no tools are added. - /// Indicates whether all provided tools must be invocable AI functions. If set to , only - /// invocable AIFunctions are accepted. - /// Thrown if is not a . - /// Thrown if is and a tool is an - /// that is not invocable, or if a tool cannot be converted to a . - private static void ApplyToolsToAgentDefinition(AgentDefinition agentDefinition, IList? tools, bool requireInvocableTools) + /// Thrown if tools were provided but is not a . + /// When providing functions, they need to be invokable AIFunctions. + private static void ApplyToolsToAgentDefinition(AgentDefinition agentDefinition, IList? tools) { if (tools is { Count: > 0 }) { @@ -833,10 +805,14 @@ public static class AgentClientExtensions throw new ArgumentException("Only prompt agent definitions support tools.", nameof(agentDefinition)); } + // When tools are provided, those should represent the complete set of tools for the agent definition. + // This is particularly important for existing agents so no duplication happens for what was already defined. + promptAgentDefinition.Tools.Clear(); + foreach (var tool in tools) { // Ensure that any AIFunctions provided are In-Proc, not just the declarations. - if (requireInvocableTools && tool is not AIFunction && ( + if (tool is not AIFunction && ( tool.GetService() is not null // Declarative FunctionTool converted as AsAITool() || tool is AIFunctionDeclaration)) // AIFunctionDeclaration type { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/Tool.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/Tool.cs index 87b0637b9b..412494eeaa 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/Tool.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/Tool.cs @@ -160,5 +160,5 @@ internal sealed record CustomToolFormat /// Additional format properties (schema definition). /// [JsonExtensionData] - public Dictionary? AdditionalProperties { get; init; } + public Dictionary? AdditionalProperties { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs similarity index 88% rename from dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs index d2f19aeb27..6bc72f45c8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs @@ -7,6 +7,7 @@ using System.Collections.ObjectModel; using System.Linq; using System.Net.Http; using System.Runtime.CompilerServices; +using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using Azure.AI.Agents; @@ -32,6 +33,11 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj private AgentClient? _agentClient; private ConversationClient? _conversationClient; + /// + /// Optional options used when creating the . + /// + public AgentClientOptions? ClientOptions { get; init; } + /// public override async Task CreateConversationAsync(CancellationToken cancellationToken = default) { @@ -78,10 +84,11 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj string? agentVersion, string? conversationId, IEnumerable? messages, + IDictionary? inputArguments, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - AgentVersion agentDefinition = await this.QueryAgentAsync(agentId, agentVersion, cancellationToken).ConfigureAwait(false); - AIAgent agent = await this.GetAgentAsync(agentDefinition, cancellationToken).ConfigureAwait(false); + AgentVersion agentVersionResult = await this.QueryAgentAsync(agentId, agentVersion, cancellationToken).ConfigureAwait(false); + AIAgent agent = await this.GetAgentAsync(agentVersionResult, cancellationToken).ConfigureAwait(false); ChatOptions chatOptions = new() @@ -90,6 +97,14 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj AllowMultipleToolCalls = this.AllowMultipleToolCalls, }; + if (inputArguments is not null) + { + JsonNode jsonNode = ConvertDictionaryToJson(inputArguments); + ResponseCreationOptions responseCreationOptions = new(); + responseCreationOptions.SetStructuredInputs(BinaryData.FromString(jsonNode.ToJsonString())); + chatOptions.RawRepresentationFactory = (_) => responseCreationOptions; + } + ChatClientAgentRunOptions runOptions = new(chatOptions); IAsyncEnumerable agentResponse = @@ -99,7 +114,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj await foreach (AgentRunResponseUpdate update in agentResponse.ConfigureAwait(false)) { - update.AuthorName = agentDefinition.Name; + update.AuthorName = agentVersionResult.Name; yield return update; } } @@ -146,16 +161,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj AgentClient client = this.GetAgentClient(); - IList? tools = null; - if (agentVersion.Definition is PromptAgentDefinition promptAgent) - { - tools = - promptAgent.Tools - .Select(tool => tool.AsAITool()) - .ToArray(); - } - - agent = client.GetAIAgent(agentVersion, tools, clientFactory: null, openAIClientOptions: null, services: null, cancellationToken); + agent = client.GetAIAgent(agentVersion, tools: null, clientFactory: null, openAIClientOptions: null, services: null, cancellationToken); FunctionInvokingChatClient? functionInvokingClient = agent.GetService(); if (functionInvokingClient is not null) @@ -215,7 +221,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj { if (this._agentClient is null) { - AgentClientOptions clientOptions = new(); + AgentClientOptions clientOptions = this.ClientOptions ?? new(); if (httpClient is not null) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj new file mode 100644 index 0000000000..c43c28aaf4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj @@ -0,0 +1,41 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + preview + $(NoWarn);MEAI001;OPENAI001 + + + + true + true + true + + + + + + + Microsoft Agent Framework Declarative Workflows Azure AI + Provides Microsoft Agent Framework support for declarative workflows for Azure AI Agents. + + + + + + + + + + + + + + + + + + + + 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 170d5a52a9..037665e8b8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs @@ -17,9 +17,10 @@ internal static class AgentProviderExtensions string? conversationId, bool autoSend, IEnumerable? inputMessages = null, + IDictionary? inputArguments = null, CancellationToken cancellationToken = default) { - IAsyncEnumerable agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, cancellationToken); + IAsyncEnumerable agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, inputArguments, cancellationToken); // Enable "autoSend" behavior if this is the workflow conversation. bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? workflowConversationId); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs index 108dca7682..3e397f3e87 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -163,6 +163,24 @@ internal static class FormulaValueExtensions } } } + + public static JsonNode ToJson(this FormulaValue value) => + value switch + { + BooleanValue booleanValue => JsonValue.Create(booleanValue.Value), + DecimalValue decimalValue => JsonValue.Create(decimalValue.Value), + NumberValue numberValue => JsonValue.Create(numberValue.Value), + DateValue dateValue => JsonValue.Create(dateValue.GetConvertedValue(TimeZoneInfo.Utc)), + DateTimeValue datetimeValue => JsonValue.Create(datetimeValue.GetConvertedValue(TimeZoneInfo.Utc)), + TimeValue timeValue => JsonValue.Create($"{timeValue.Value}"), + StringValue stringValue => JsonValue.Create(stringValue.Value), + GuidValue guidValue => JsonValue.Create(guidValue.Value), + RecordValue recordValue => recordValue.ToJson(), + TableValue tableValue => tableValue.ToJson(), + BlankValue => JsonValue.Create(string.Empty), + _ => $"[{value.GetType().Name}]", + }; + public static RecordValue ToRecord(this Dictionary value) => FormulaValue.NewRecordFromFields( value.Select( @@ -256,23 +274,6 @@ internal static class FormulaValueExtensions private static KeyValuePair GetKeyValuePair(this NamedValue value) => new(value.Name, value.Value.ToDataValue()); - private static JsonNode ToJson(this FormulaValue value) => - value switch - { - BooleanValue booleanValue => JsonValue.Create(booleanValue.Value), - DecimalValue decimalValue => JsonValue.Create(decimalValue.Value), - NumberValue numberValue => JsonValue.Create(numberValue.Value), - DateValue dateValue => JsonValue.Create(dateValue.GetConvertedValue(TimeZoneInfo.Utc)), - DateTimeValue datetimeValue => JsonValue.Create(datetimeValue.GetConvertedValue(TimeZoneInfo.Utc)), - TimeValue timeValue => JsonValue.Create($"{timeValue.Value}"), - StringValue stringValue => JsonValue.Create(stringValue.Value), - GuidValue guidValue => JsonValue.Create(guidValue.Value), - RecordValue recordValue => recordValue.ToJson(), - TableValue tableValue => tableValue.ToJson(), - BlankValue => JsonValue.Create(string.Empty), - _ => $"[{value.GetType().Name}]", - }; - private static JsonArray ToJson(this TableValue value) { return new([.. GetJsonElements()]); 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 9e6a6884b6..45a5b47bd8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/AgentExecutor.cs @@ -33,5 +33,5 @@ public abstract class AgentExecutor(string id, FormulaSession session, WorkflowA bool autoSend, IEnumerable? inputMessages = null, CancellationToken cancellationToken = default) - => agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, inputMessages, cancellationToken); + => agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, inputMessages, inputArguments: null, cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj index 17ce9f6c4f..1f466aac4e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj @@ -22,7 +22,6 @@ - @@ -34,7 +33,6 @@ - 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 cafece9a57..ed069a9b78 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -60,8 +60,8 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA string? conversationId = this.GetConversationId(); string agentName = this.GetAgentName(); bool autoSend = this.GetAutoSendValue(); - - AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, messages, cancellationToken).ConfigureAwait(false); + Dictionary? inputParameters = this.GetStructuredInputs(); + AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, messages, inputParameters, cancellationToken).ConfigureAwait(false); ChatMessage[] actionableMessages = FilterActionableContent(agentResponse).ToArray(); if (actionableMessages.Length > 0) @@ -107,6 +107,23 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false); } + private Dictionary? GetStructuredInputs() + { + Dictionary? inputs = null; + + if (this.AgentInput?.Arguments is not null) + { + inputs = []; + + foreach (KeyValuePair argument in this.AgentInput.Arguments) + { + inputs[argument.Key] = this.Evaluator.GetValue(argument.Value).Value.ToObject(); + } + } + + return inputs; + } + private IEnumerable? GetInputMessages() { DataValue? userInput = null; 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 449e982982..81ed6e30d3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs @@ -8,7 +8,6 @@ using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.PowerFx.Types; -using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; @@ -17,17 +16,15 @@ internal sealed class SetVariableExecutor(SetVariable model, WorkflowFormulaStat { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { - PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); - if (this.Model.Value is null) { - await this.AssignAsync(variablePath, FormulaValue.NewBlank(), context).ConfigureAwait(false); + await this.AssignAsync(this.Model.Variable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); } else { EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Value); - await this.AssignAsync(variablePath, expressionResult.Value.ToFormula(), context).ConfigureAwait(false); + 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/PowerFx/Functions/AgentMessage.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/AgentMessage.cs new file mode 100644 index 0000000000..927a842478 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/AgentMessage.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions; + +internal sealed class AgentMessage : MessageFunction +{ + public const string FunctionName = nameof(AgentMessage); + + public AgentMessage() : base(FunctionName) { } + + public static FormulaValue Execute(StringValue input) => Create(ChatRole.Assistant, input); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/MessageFunction.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/MessageFunction.cs new file mode 100644 index 0000000000..e9d52c2e15 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/MessageFunction.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions; + +internal abstract class MessageFunction : ReflectionFunction +{ + protected MessageFunction(string functionName) + : base(functionName, FormulaType.String, FormulaType.String) + { } + + protected static FormulaValue Create(ChatRole role, StringValue input) => + string.IsNullOrEmpty(input.Value) ? + FormulaValue.NewBlank(RecordType.Empty()) : + FormulaValue.NewRecordFromFields( + new NamedValue(TypeSchema.Discriminator, nameof(ChatMessage).ToFormula()), + new NamedValue(TypeSchema.Message.Fields.Role, FormulaValue.New(role.Value)), + new NamedValue( + TypeSchema.Message.Fields.Content, + FormulaValue.NewTable( + RecordType.Empty() + .Add(TypeSchema.Message.Fields.ContentType, FormulaType.String) + .Add(TypeSchema.Message.Fields.ContentValue, FormulaType.String), + [ + FormulaValue.NewRecordFromFields( + new NamedValue(TypeSchema.Message.Fields.ContentType, FormulaValue.New(TypeSchema.Message.ContentTypes.Text)), + new NamedValue(TypeSchema.Message.Fields.ContentValue, input)) + ] + ) + ) + ); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/MessageText.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/MessageText.cs new file mode 100644 index 0000000000..ff9f7d499e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/MessageText.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions; + +internal static class MessageText +{ + public const string FunctionName = nameof(MessageText); + + public sealed class StringInput() + : ReflectionFunction(FunctionName, FormulaType.String, FormulaType.String) + { + public static FormulaValue Execute(StringValue input) => input; + } + + public sealed class RecordInput() : ReflectionFunction(FunctionName, FormulaType.String, RecordType.Empty()) + { + public static FormulaValue Execute(RecordValue input) => FormulaValue.New(GetTextFromRecord(input)); + } + + public sealed class TableInput() : ReflectionFunction(FunctionName, FormulaType.String, TableType.Empty()) + { + public static FormulaValue Execute(TableValue tableValue) + { + return FormulaValue.New(string.Join("\n", GetText())); + + IEnumerable GetText() + { + foreach (DValue row in tableValue.Rows) + { + string text = GetTextFromRecord(row.Value); + if (!string.IsNullOrWhiteSpace(text)) + { + yield return text; + } + } + } + } + } + + private static string GetTextFromRecord(RecordValue recordValue) + { + FormulaValue textValue = recordValue.GetField(TypeSchema.Message.Fields.Text); + + return textValue switch + { + StringValue stringValue => stringValue.Value.Trim(), + _ => string.Empty, + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/UserMessage.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/UserMessage.cs index 1d4510b11d..968431623a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/UserMessage.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/Functions/UserMessage.cs @@ -1,38 +1,15 @@ // Copyright (c) Microsoft. All rights reserved. -using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Extensions.AI; -using Microsoft.PowerFx; using Microsoft.PowerFx.Types; namespace Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions; -internal sealed class UserMessage : ReflectionFunction +internal sealed class UserMessage : MessageFunction { public const string FunctionName = nameof(UserMessage); - public UserMessage() - : base(FunctionName, FormulaType.String, FormulaType.String) - { } + public UserMessage() : base(FunctionName) { } - public static FormulaValue Execute(StringValue input) => - string.IsNullOrEmpty(input.Value) ? - FormulaValue.NewBlank(RecordType.Empty()) : - FormulaValue.NewRecordFromFields( - new NamedValue(TypeSchema.Discriminator, nameof(ChatMessage).ToFormula()), - new NamedValue(TypeSchema.Message.Fields.Role, FormulaValue.New(ChatRole.User.Value)), - new NamedValue( - TypeSchema.Message.Fields.Content, - FormulaValue.NewTable( - RecordType.Empty() - .Add(TypeSchema.Message.Fields.ContentType, FormulaType.String) - .Add(TypeSchema.Message.Fields.ContentValue, FormulaType.String), - [ - FormulaValue.NewRecordFromFields( - new NamedValue(TypeSchema.Message.Fields.ContentType, FormulaValue.New(TypeSchema.Message.ContentTypes.Text)), - new NamedValue(TypeSchema.Message.Fields.ContentValue, input)) - ] - ) - ) - ); + public static FormulaValue Execute(StringValue input) => Create(ChatRole.User, input); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs index 6d0364603c..2087307504 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs @@ -38,7 +38,11 @@ internal static class RecalcEngineFactory } config.EnableSetFunction(); + config.AddFunction(new AgentMessage()); config.AddFunction(new UserMessage()); + config.AddFunction(new MessageText.StringInput()); + config.AddFunction(new MessageText.RecordInput()); + config.AddFunction(new MessageText.TableInput()); return config; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs index 7afaf4fa04..e0967ab376 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs @@ -1,9 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Declarative; @@ -87,9 +89,16 @@ public abstract class WorkflowAgentProvider /// An optional agent version. /// Optional identifier of the target conversation. /// The messages to include in the invocation. + /// Optional input arguments for agents that provide support. /// A token that propagates notification when operation should be canceled. /// Asynchronous set of . - public abstract IAsyncEnumerable InvokeAgentAsync(string agentId, string? agentVersion, string? conversationId, IEnumerable? messages, CancellationToken cancellationToken = default); + public abstract IAsyncEnumerable InvokeAgentAsync( + string agentId, + string? agentVersion, + string? conversationId, + IEnumerable? messages, + IDictionary? inputArguments, + CancellationToken cancellationToken = default); /// /// Retrieves a set of messages from a conversation. @@ -108,4 +117,14 @@ public abstract class WorkflowAgentProvider string? before = null, bool newestFirst = false, CancellationToken cancellationToken = default); + + /// + /// Utility method to convert a dictionary of input arguments to a JsonNode. + /// + /// The dictionary of input arguments. + /// A JsonNode representing the input arguments. + protected static JsonNode ConvertDictionaryToJson(IDictionary inputArguments) + { + return inputArguments.ToFormula().ToJson(); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ChatForwardingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ChatForwardingExecutor.cs new file mode 100644 index 0000000000..5bb2f5e237 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ChatForwardingExecutor.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Provides configuration options for . +/// +public class ChatForwardingExecutorOptions +{ + /// + /// Gets or sets the chat role to use when converting string messages to instances. + /// If set, the executor will accept string messages and convert them to chat messages with this role. + /// + public ChatRole? StringMessageChatRole { get; set; } +} + +/// +/// A ChatProtocol executor that forwards all messages it receives. Useful for splitting inputs into parallel +/// processing paths. +/// +/// This executor is designed to be cross-run shareable and can be reset to its initial state. It handles +/// multiple chat-related types, enabling flexible message forwarding scenarios. Thread safety and reusability are +/// ensured by its design. +/// The unique identifier for the executor instance. Used to distinguish this executor within the system. +/// Optional configuration settings for the executor. If null, default options are used. +public sealed class ChatForwardingExecutor(string id, ChatForwardingExecutorOptions? options = null) : Executor(id, declareCrossRunShareable: true), IResettableExecutor +{ + private readonly ChatRole? _stringMessageChatRole = options?.StringMessageChatRole; + + /// + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + if (this._stringMessageChatRole.HasValue) + { + routeBuilder = routeBuilder.AddHandler( + (message, context) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message))); + } + + return routeBuilder.AddHandler(ForwardMessageAsync) + .AddHandler>(ForwardMessagesAsync) + .AddHandler(ForwardMessagesAsync) + .AddHandler>(ForwardMessagesAsync) + .AddHandler(ForwardTurnTokenAsync); + } + + private static ValueTask ForwardMessageAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => context.SendMessageAsync(message, cancellationToken); + + // Note that this can be used to split a turn into multiple parallel turns taken, which will cause streaming ChatMessages + // to overlap. + private static ValueTask ForwardTurnTokenAsync(TurnToken message, IWorkflowContext context, CancellationToken cancellationToken) + => context.SendMessageAsync(message, cancellationToken); + + // TODO: This is not ideal, but until we have a way of guaranteeing correct routing of interfaces across serialization + // boundaries, we need to do type unification. It behaves better when used as a handler in ChatProtocolExecutor because + // it is a strictly contravariant use, whereas this forces invariance on the type because it is directly forwarded. + private static ValueTask ForwardMessagesAsync(IEnumerable messages, IWorkflowContext context, CancellationToken cancellationToken) + => context.SendMessageAsync(messages is List messageList ? messageList : messages.ToList(), cancellationToken); + + private static ValueTask ForwardMessagesAsync(ChatMessage[] messages, IWorkflowContext context, CancellationToken cancellationToken) + => context.SendMessageAsync(messages, cancellationToken); + + /// + public ValueTask ResetAsync() => default; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ChatForwardingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ChatForwardingExecutor.cs deleted file mode 100644 index b395dd4216..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ChatForwardingExecutor.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.Workflows.Specialized; - -/// Executor that forwards all messages. -internal sealed class ChatForwardingExecutor(string id) : Executor(id, declareCrossRunShareable: true), IResettableExecutor -{ - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder - .AddHandler((message, context, cancellationToken) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message), cancellationToken: cancellationToken)) - .AddHandler((message, context, cancellationToken) => context.SendMessageAsync(message, cancellationToken: cancellationToken)) - .AddHandler>((messages, context, cancellationToken) => context.SendMessageAsync(messages, cancellationToken: cancellationToken)) - .AddHandler((turnToken, context, cancellationToken) => context.SendMessageAsync(turnToken, cancellationToken: cancellationToken)); - - public ValueTask ResetAsync() => default; -} diff --git a/dotnet/tests/.gitignore b/dotnet/tests/.gitignore new file mode 100644 index 0000000000..8392c905c6 --- /dev/null +++ b/dotnet/tests/.gitignore @@ -0,0 +1 @@ +launchSettings.json \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs index 40901a4969..32560949fb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs @@ -18,7 +18,12 @@ public class AgentRunOptionsTests var options = new AgentRunOptions { ContinuationToken = new object(), - AllowBackgroundResponses = true + AllowBackgroundResponses = true, + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["key1"] = "value1", + ["key2"] = 42 + } }; // Act @@ -28,6 +33,10 @@ public class AgentRunOptionsTests Assert.NotNull(clone); Assert.Same(options.ContinuationToken, clone.ContinuationToken); Assert.Equal(options.AllowBackgroundResponses, clone.AllowBackgroundResponses); + Assert.NotNull(clone.AdditionalProperties); + Assert.NotSame(options.AdditionalProperties, clone.AdditionalProperties); + Assert.Equal("value1", clone.AdditionalProperties["key1"]); + Assert.Equal(42, clone.AdditionalProperties["key2"]); } [Fact] @@ -42,7 +51,12 @@ public class AgentRunOptionsTests var options = new AgentRunOptions { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), - AllowBackgroundResponses = true + AllowBackgroundResponses = true, + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["key1"] = "value1", + ["key2"] = 42 + } }; // Act @@ -54,5 +68,13 @@ public class AgentRunOptionsTests Assert.NotNull(deserialized); Assert.Equivalent(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), deserialized!.ContinuationToken); Assert.Equal(options.AllowBackgroundResponses, deserialized.AllowBackgroundResponses); + Assert.NotNull(deserialized.AdditionalProperties); + Assert.Equal(2, deserialized.AdditionalProperties.Count); + Assert.True(deserialized.AdditionalProperties.TryGetValue("key1", out object? value1)); + Assert.IsType(value1); + Assert.Equal("value1", ((JsonElement)value1!).GetString()); + Assert.True(deserialized.AdditionalProperties.TryGetValue("key2", out object? value2)); + Assert.IsType(value2); + Assert.Equal(42, ((JsonElement)value2!).GetInt32()); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AgentsClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AgentsClientExtensionsTests.cs index e3afef7d8a..d989bdc166 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AgentsClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AgentsClientExtensionsTests.cs @@ -504,10 +504,10 @@ public sealed class AgentClientExtensionsTests #region GetAIAgent(AgentClient, AgentRecord) with tools Tests /// - /// Verify that GetAIAgent with tools parameter passes tools to the agent. + /// Verify that GetAIAgent with additional tools when the definition has no tools does not throw and results in an agent with no tools. /// [Fact] - public void GetAIAgent_WithAgentRecordAndTools_PassesToolsToAgent() + public void GetAIAgent_WithAgentRecordAndAdditionalTools_WhenDefinitionHasNoTools_ShouldNotThrow() { // Arrange AgentClient client = this.CreateTestAgentClient(); @@ -527,6 +527,8 @@ public sealed class AgentClientExtensionsTests Assert.NotNull(chatClient); var agentVersion = chatClient.GetService(); Assert.NotNull(agentVersion); + var definition = Assert.IsType(agentVersion.Definition); + Assert.Empty(definition.Tools); } /// @@ -974,6 +976,88 @@ public sealed class AgentClientExtensionsTests } } + /// + /// Verify that CreateAIAgentAsync when AI Tools are provided, uses them for the definition via http request. + /// + [Fact] + public async Task CreateAIAgentAsync_WithNameAndAITools_SendsToolDefinitionViaHttpAsync() + { + // Arrange + using var httpHandler = new HttpHandlerAssert(async (request) => + { + if (request.Content is not null) + { + var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + + Assert.Contains("required_tool", requestBody); + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AgentVersionTestJsonObject, Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + var client = new AgentClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + // Act + var agent = await client.CreateAIAgentAsync( + name: "test-agent", + model: "test-model", + instructions: "Test", + tools: [AIFunctionFactory.Create(() => true, "required_tool")]); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var agentVersion = agent.GetService(); + Assert.NotNull(agentVersion); + Assert.IsType(agentVersion.Definition); + } + + /// + /// Verify that CreateAIAgent when AI Tools are provided, uses them for the definition via http request. + /// + [Fact] + public void CreateAIAgent_WithNameAndAITools_SendsToolDefinitionViaHttp() + { + // Arrange + using var httpHandler = new HttpHandlerAssert((request) => + { + if (request.Content is not null) + { +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + var requestBody = request.Content.ReadAsStringAsync().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + + Assert.Contains("required_tool", requestBody); + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AgentVersionTestJsonObject, Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + var client = new AgentClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + // Act + var agent = client.CreateAIAgent( + name: "test-agent", + model: "test-model", + instructions: "Test", + tools: [AIFunctionFactory.Create(() => true, "required_tool")]); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + var agentVersion = agent.GetService(); + Assert.NotNull(agentVersion); + Assert.IsType(agentVersion.Definition); + } + /// /// Verify that CreateAIAgent without tools creates an agent successfully. /// @@ -997,10 +1081,10 @@ public sealed class AgentClientExtensionsTests } /// - /// Verify that GetAIAgent with inline tools in agent definition throws ArgumentException. + /// Verify that when providing AITools with GetAIAgent, any additional tool that doesn't match the tools in agent definition are ignored. /// [Fact] - public void GetAIAgent_WithInlineToolsInDefinition_ThrowsArgumentException() + public void GetAIAgent_AdditionalAITools_WhenNotInTheDefinitionAreIgnored() { // Arrange AgentClient client = this.CreateTestAgentClient(); @@ -1012,11 +1096,20 @@ public sealed class AgentClientExtensionsTests promptDef.Tools.Add(ResponseTool.CreateFunctionTool("inline_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); } - // Act & Assert - var exception = Assert.Throws(() => - client.GetAIAgent(agentVersion)); + var invocableInlineAITool = AIFunctionFactory.Create(() => "test", "inline_tool", "An invocable AIFunction for the inline function"); + var shouldBeIgnoredTool = AIFunctionFactory.Create(() => "test", "additional_tool", "An additional test function that should be ignored"); - Assert.Contains("tools parameter", exception.Message); + // Act & Assert + var agent = client.GetAIAgent(agentVersion, tools: [invocableInlineAITool, shouldBeIgnoredTool]); + Assert.NotNull(agent); + var version = agent.GetService(); + Assert.NotNull(version); + var definition = Assert.IsType(version.Definition); + Assert.NotEmpty(definition.Tools); + Assert.NotNull(GetAgentChatOptions(agent)); + Assert.NotNull(GetAgentChatOptions(agent)!.Tools); + Assert.Single(GetAgentChatOptions(agent)!.Tools!); + Assert.Equal("inline_tool", (definition.Tools.First() as FunctionTool)?.FunctionName); } #endregion @@ -2174,19 +2267,54 @@ public sealed class AgentClientExtensionsTests #endregion - private sealed class HttpHandlerAssert(Func assertion) : HttpClientHandler + private sealed class HttpHandlerAssert : HttpClientHandler { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + private readonly Func? _assertion; + private readonly Func>? _assertionAsync; + + public HttpHandlerAssert(Func assertion) { - var response = assertion(request); - return Task.FromResult(response); + this._assertion = assertion; + } + public HttpHandlerAssert(Func> assertionAsync) + { + this._assertionAsync = assertionAsync; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (this._assertionAsync is not null) + { + return await this._assertionAsync.Invoke(request); + } + + return this._assertion!.Invoke(request); } #if NET protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) { - return assertion(request); + return this._assertion!(request); } #endif } + + /// + /// Helper method to access internal ChatOptions property via reflection. + /// + private static ChatOptions? GetAgentChatOptions(ChatClientAgent agent) + { + if (agent is null) + { + return null; + } + + var chatOptionsProperty = typeof(ChatClientAgent).GetProperty( + "ChatOptions", + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance); + + return chatOptionsProperty?.GetValue(agent) as ChatOptions; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/AgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/AgentProvider.cs index f90e6fbbd5..1ea1690458 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/AgentProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/AgentProvider.cs @@ -15,6 +15,7 @@ internal abstract class AgentProvider(IConfiguration configuration) public const string FunctionTool = "FUNCTIONTOOL"; public const string Marketing = "MARKETING"; public const string MathChat = "MATHCHAT"; + public const string InputArguments = "INPUTARGUMENTS"; } public static class Settings @@ -31,6 +32,7 @@ internal abstract class AgentProvider(IConfiguration configuration) Names.FunctionTool => new FunctionToolAgentProvider(configuration), Names.Marketing => new MarketingAgentProvider(configuration), Names.MathChat => new MathChatAgentProvider(configuration), + Names.InputArguments => new PoemAgentProvider(configuration), _ => new TestAgentProvider(configuration), }; @@ -40,7 +42,7 @@ internal abstract class AgentProvider(IConfiguration configuration) await foreach (AgentVersion agent in this.CreateAgentsAsync(foundryEndpoint)) { - Console.WriteLine($"Created agent: {agent.Name}:{agent.Version})"); + Console.WriteLine($"Created agent: {agent.Name}:{agent.Version}"); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/PoemAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/PoemAgentProvider.cs new file mode 100644 index 0000000000..9ee7797edf --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/PoemAgentProvider.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Azure.AI.Agents; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Shared.Foundry; + +namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; + +internal sealed class PoemAgentProvider(IConfiguration configuration) : AgentProvider(configuration) +{ + protected override async IAsyncEnumerable CreateAgentsAsync(Uri foundryEndpoint) + { + AgentClient agentClient = new(foundryEndpoint, new AzureCliCredential()); + + yield return + await agentClient.CreateAgentAsync( + agentName: "PoemAgent", + agentDefinition: this.DefinePoemAgent(), + agentDescription: "Authors original poems"); + } + + private PromptAgentDefinition DefinePoemAgent() => + new(this.GetSetting(Settings.FoundryModelMini)) + { + Instructions = + """ + Write a one verse poem on the requested topic in the style of: {{style}}. + """, + StructuredInputs = + { + ["style"] = + new StructuredInputDefinition + { + IsRequired = false, + DefaultValue = BinaryData.FromString(@"""haiku"""), + Description = "The style of poem to write", + } + } + }; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs index 567683971b..b2de034da6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs @@ -17,11 +17,12 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow { [Theory] [InlineData("CheckSystem.yaml", "CheckSystem.json")] - [InlineData("SendActivity.yaml", "SendActivity.json")] - [InlineData("InvokeAgent.yaml", "InvokeAgent.json")] - [InlineData("InvokeAgent.yaml", "InvokeAgent.json", true)] [InlineData("ConversationMessages.yaml", "ConversationMessages.json")] [InlineData("ConversationMessages.yaml", "ConversationMessages.json", true)] + [InlineData("InputArguments.yaml", "InputArguments.json")] + [InlineData("InvokeAgent.yaml", "InvokeAgent.json")] + [InlineData("InvokeAgent.yaml", "InvokeAgent.json", true)] + [InlineData("SendActivity.yaml", "SendActivity.json")] public Task ValidateCaseAsync(string workflowFileName, string testcaseFileName, bool externalConveration = false) => this.RunWorkflowAsync(GetWorkflowPath(workflowFileName, isSample: false), testcaseFileName, externalConveration); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj index 1563b88719..9e86f4250a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj @@ -5,6 +5,7 @@ + true true true true @@ -12,6 +13,7 @@ + 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 new file mode 100644 index 0000000000..8eb876eb60 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/InputArguments.json @@ -0,0 +1,23 @@ +{ + "description": "Authors a poem in the style specified by the input argument.", + "setup": { + "input": { + "type": "String", + "value": "Why is the sky blue?" + } + }, + "validation": { + "conversation_count": 1, + "min_action_count": 1, + "min_response_count": 1, + "min_message_count": 3, + "actions": { + "start": [ + "invoke_poem" + ], + "final": [ + "invoke_poem" + ] + } + } +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/MathChat.json b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/MathChat.json index e3a9b85836..ea5337263a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/MathChat.json +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/MathChat.json @@ -13,7 +13,7 @@ "min_response_count": 2, "max_response_count": 8, "min_message_count": 4, - "max_message_count": 17, + "max_message_count": -1, "actions": { "start": [ ], diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InputArguments.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InputArguments.yaml new file mode 100644 index 0000000000..c963de31e3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InputArguments.yaml @@ -0,0 +1,15 @@ +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_test + actions: + + - kind: InvokeAzureAgent + id: invoke_poem + conversationId: =System.ConversationId + agent: + name: PoemAgent + input: + arguments: + style: "ee cummings" diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj index 4eca981e33..491ec95778 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj @@ -14,6 +14,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/AgentMessageTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/AgentMessageTests.cs new file mode 100644 index 0000000000..7ca7041549 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/AgentMessageTests.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx.Functions; + +public sealed class AgentMessageTests +{ + [Fact] + public void Construct_Function() + { + AgentMessage function = new(); + Assert.NotNull(function); + } + + [Fact] + public void Execute_ReturnsBlank_ForEmptyInput() + { + // Arrange + StringValue sourceValue = FormulaValue.New(string.Empty); + + // Act + FormulaValue result = AgentMessage.Execute(sourceValue); + + // Assert + Assert.IsType(result); + } + + [Fact] + public void Execute_ReturnsExpectedRecord_ForNonEmptyInput() + { + const string Text = "Hello"; + FormulaValue sourceValue = FormulaValue.New(Text); + StringValue stringValue = Assert.IsType(sourceValue); + + FormulaValue result = AgentMessage.Execute(stringValue); + + RecordValue recordResult = Assert.IsType(result, exactMatch: false); + + // Discriminator + FormulaValue discriminator = recordResult.GetField(TypeSchema.Discriminator); + StringValue discriminatorValue = Assert.IsType(discriminator); + Assert.Equal(nameof(ChatMessage), discriminatorValue.Value); + + // Role + FormulaValue role = recordResult.GetField(TypeSchema.Message.Fields.Role); + StringValue roleValue = Assert.IsType(role); + Assert.Equal(ChatRole.Assistant.Value, roleValue.Value); + + // Content table + FormulaValue content = recordResult.GetField(TypeSchema.Message.Fields.Content); + TableValue table = Assert.IsType(content, exactMatch: false); + + List rows = table.Rows.Select(value => value.Value).ToList(); + Assert.Single(rows); + + StringValue contentType = Assert.IsType(rows[0].GetField(TypeSchema.Message.Fields.ContentType)); + Assert.Equal(TypeSchema.Message.ContentTypes.Text, contentType.Value); + + StringValue contentValue = Assert.IsType(rows[0].GetField(TypeSchema.Message.Fields.ContentValue)); + Assert.Equal(Text, contentValue.Value); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/MessageTextTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/MessageTextTests.cs new file mode 100644 index 0000000000..5cbc374990 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/MessageTextTests.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx.Functions; + +public sealed class MessageTextTests +{ + [Fact] + public void Construct_Function() + { + MessageText.StringInput function1 = new(); + Assert.NotNull(function1); + + MessageText.RecordInput function2 = new(); + Assert.NotNull(function2); + + MessageText.TableInput function3 = new(); + Assert.NotNull(function3); + } + + [Fact] + public void Execute_ReturnsEmpty_ForEmptyInput() + { + // Arrange + StringValue sourceValue = FormulaValue.New(string.Empty); + + // Act + FormulaValue result = MessageText.StringInput.Execute(sourceValue); + + // Assert + StringValue stringResult = Assert.IsType(result); + Assert.Empty(stringResult.Value); + } + + [Fact] + public void Execute_ReturnsText_ForStringInput() + { + // Arrange + StringValue sourceValue = FormulaValue.New("wowsie"); + + // Act + FormulaValue result = MessageText.StringInput.Execute(sourceValue); + + // Assert + StringValue stringResult = Assert.IsType(result); + Assert.Equal(sourceValue.Value, stringResult.Value); + } + + [Fact] + public void Execute_ReturnsText_ForMessageInput() + { + // Arrange + RecordValue sourceValue = new ChatMessage(ChatRole.User, "test message").ToRecord(); + + // Act + FormulaValue result = MessageText.RecordInput.Execute(sourceValue); + + // Assert + StringValue stringResult = Assert.IsType(result); + Assert.Equal("test message", stringResult.Value); + } + + [Fact] + public void Execute_ReturnsEmpty_ForUnknownInput() + { + // Arrange + RecordValue sourceValue = FormulaValue.NewRecordFromFields(new NamedValue("Anything", FormulaValue.New(333))); + + // Act + FormulaValue result = MessageText.RecordInput.Execute(sourceValue); + + // Assert + StringValue stringResult = Assert.IsType(result); + Assert.Empty(stringResult.Value); + } + + [Fact] + public void Execute_ReturnsText_ForMessagesInput() + { + // Arrange + TableValue sourceValue = new ChatMessage[] + { + new(ChatRole.User, "test message 1"), + new(ChatRole.User, "test message 2"), + }.ToTable(); + + // Act + FormulaValue result = MessageText.TableInput.Execute(sourceValue); + + // Assert + StringValue stringResult = Assert.IsType(result); + Assert.Equal("test message 1\ntest message 2", stringResult.Value); + } + + [Fact] + public void Execute_ReturnsEmpty_ForEmptyList() + { + // Arrange + TableValue sourceValue = Array.Empty().ToTable(); + + // Act + FormulaValue result = MessageText.TableInput.Execute(sourceValue); + + // Assert + StringValue stringResult = Assert.IsType(result); + Assert.Empty(stringResult.Value); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/UserMessageTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/UserMessageTests.cs index 712fb139bf..705831473c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/UserMessageTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/Functions/UserMessageTests.cs @@ -22,11 +22,10 @@ public class UserMessageTests public void Execute_ReturnsBlank_ForEmptyInput() { // Arrange - FormulaValue sourceValue = FormulaValue.New(string.Empty); - StringValue stringValue = Assert.IsType(sourceValue); + StringValue sourceValue = FormulaValue.New(string.Empty); // Act - FormulaValue result = UserMessage.Execute(stringValue); + FormulaValue result = UserMessage.Execute(sourceValue); // Assert Assert.IsType(result); diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 90d8627fc5..500c0b45cd 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0b251111] - 2025-11-11 + +### Added + +- **agent-framework-core**: Add OpenAI Responses Image Generation Stream Support with partial images and unit tests ([#1853](https://github.com/microsoft/agent-framework/pull/1853)) +- **agent-framework-ag-ui**: Add concrete AGUIChatClient implementation ([#2072](https://github.com/microsoft/agent-framework/pull/2072)) + +### Fixed + +- **agent-framework-a2a**: Use the last entry in the task history to avoid empty responses ([#2101](https://github.com/microsoft/agent-framework/pull/2101)) +- **agent-framework-core**: Fix MCP Tool Parameter Descriptions not propagated to LLMs ([#1978](https://github.com/microsoft/agent-framework/pull/1978)) +- **agent-framework-core**: Handle agent user input request in AgentExecutor ([#2022](https://github.com/microsoft/agent-framework/pull/2022)) +- **agent-framework-core**: Fix Model ID attribute not showing up in `invoke_agent` span ([#2061](https://github.com/microsoft/agent-framework/pull/2061)) +- **agent-framework-core**: Fix underlying tool choice bug and enable return to previous Handoff subagent ([#2037](https://github.com/microsoft/agent-framework/pull/2037)) + ## [1.0.0b251108] - 2025-11-08 ### Added @@ -189,7 +204,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/). -[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251108...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251111...HEAD +[1.0.0b251111]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251108...python-1.0.0b251111 [1.0.0b251108]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251106.post1...python-1.0.0b251108 [1.0.0b251106.post1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251106...python-1.0.0b251106.post1 [1.0.0b251106]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251105...python-1.0.0b251106 diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index 68694e3db4..7fe4649a65 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -388,6 +388,17 @@ class A2AAgent(BaseAgent): if task.artifacts is not None: for artifact in task.artifacts: messages.append(self._artifact_to_chat_message(artifact)) + elif task.history is not None and len(task.history) > 0: + # Include the last history item as the agent response + history_item = task.history[-1] + contents = self._a2a_parts_to_contents(history_item.parts) + messages.append( + ChatMessage( + role=Role.ASSISTANT if history_item.role == A2ARole.agent else Role.USER, + contents=contents, + raw_representation=history_item, + ) + ) return messages diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index 96e99fa834..2780bdd481 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.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 7c17e7502c..9216a17e24 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.0b251108" +version = "1.0.0b251111" description = "AG-UI protocol integration for Agent Framework" readme = "README.md" license-files = ["LICENSE"] diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index 4941d0a221..cacd760e76 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.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml index 4c4ce24fdb..fa15e4c074 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.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index a5057247a8..8c0a5047e4 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.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index cad44cd516..9872355b4e 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.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 50f6a91c2e..873b7f04cc 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -19,7 +19,7 @@ from mcp.client.websocket import websocket_client from mcp.shared.context import RequestContext from mcp.shared.exceptions import McpError from mcp.shared.session import RequestResponder -from pydantic import BaseModel, create_model +from pydantic import BaseModel, Field, create_model from ._tools import AIFunction, HostedMCPSpecificApproval from ._types import ChatMessage, Contents, DataContent, Role, TextContent, UriContent @@ -224,13 +224,20 @@ def _get_input_model_from_mcp_tool(tool: types.Tool) -> type[BaseModel]: prop_details = json.loads(prop_details) if isinstance(prop_details, str) else prop_details python_type = resolve_type(prop_details) + description = prop_details.get("description", "") # Create field definition for create_model if prop_name in required: - field_definitions[prop_name] = (python_type, ...) + field_definitions[prop_name] = ( + (python_type, Field(description=description)) if description else (python_type, ...) + ) else: default_value = prop_details.get("default", None) - field_definitions[prop_name] = (python_type, default_value) + field_definitions[prop_name] = ( + (python_type, Field(default=default_value, description=description)) + if description + else (python_type, default_value) + ) return create_model(f"{tool.name}_input", **field_definitions) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 9f2ad10d85..8dc7c00655 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -1050,6 +1050,50 @@ class DataContent(BaseContent): def has_top_level_media_type(self, top_level_media_type: Literal["application", "audio", "image", "text"]) -> bool: return _has_top_level_media_type(self.media_type, top_level_media_type) + @staticmethod + def detect_image_format_from_base64(image_base64: str) -> str: + """Detect image format from base64 data by examining the binary header. + + Args: + image_base64: Base64 encoded image data + + Returns: + Image format as string (png, jpeg, webp, gif) with png as fallback + """ + try: + # Constants for image format detection + # ~75 bytes of binary data should be enough to detect most image formats + FORMAT_DETECTION_BASE64_CHARS = 100 + + # Decode a small portion to detect format + decoded_data = base64.b64decode(image_base64[:FORMAT_DETECTION_BASE64_CHARS]) + if decoded_data.startswith(b"\x89PNG"): + return "png" + if decoded_data.startswith(b"\xff\xd8\xff"): + return "jpeg" + if decoded_data.startswith(b"RIFF") and b"WEBP" in decoded_data[:12]: + return "webp" + if decoded_data.startswith(b"GIF87a") or decoded_data.startswith(b"GIF89a"): + return "gif" + return "png" # Default fallback + except Exception: + return "png" # Fallback if decoding fails + + @classmethod + def create_data_uri_from_base64(cls, image_base64: str) -> tuple[str, str]: + """Create a data URI and media type from base64 image data. + + Args: + image_base64: Base64 encoded image data + + Returns: + Tuple of (data_uri, media_type) + """ + format_type = cls.detect_image_format_from_base64(image_base64) + uri = f"data:image/{format_type};base64,{image_base64}" + media_type = f"image/{format_type}" + return uri, media_type + class UriContent(BaseContent): """Represents a URI content. diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 0c05abbb69..8fa85b7f84 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -2,11 +2,14 @@ import logging from dataclasses import dataclass -from typing import Any +from typing import Any, cast + +from agent_framework import FunctionApprovalRequestContent, FunctionApprovalResponseContent from .._agents import AgentProtocol, ChatAgent from .._threads import AgentThread from .._types import AgentRunResponse, AgentRunResponseUpdate, ChatMessage +from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value from ._conversation_state import encode_chat_messages from ._events import ( AgentRunEvent, @@ -14,6 +17,7 @@ from ._events import ( ) from ._executor import Executor, handler from ._message_utils import normalize_messages_input +from ._request_info_mixin import response_handler from ._workflow_context import WorkflowContext logger = logging.getLogger(__name__) @@ -83,6 +87,8 @@ class AgentExecutor(Executor): super().__init__(exec_id) self._agent = agent self._agent_thread = agent_thread or self._agent.get_new_thread() + self._pending_agent_requests: dict[str, FunctionApprovalRequestContent] = {} + self._pending_responses_to_agent: list[FunctionApprovalResponseContent] = [] self._output_response = output_response self._cache: list[ChatMessage] = [] @@ -93,50 +99,6 @@ class AgentExecutor(Executor): return [AgentRunResponse] return [] - async def _run_agent_and_emit(self, ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse]) -> None: - """Execute the underlying agent, emit events, and enqueue response. - - Checks ctx.is_streaming() to determine whether to emit incremental AgentRunUpdateEvent - events (streaming mode) or a single AgentRunEvent (non-streaming mode). - """ - if ctx.is_streaming(): - # Streaming mode: emit incremental updates - updates: list[AgentRunResponseUpdate] = [] - async for update in self._agent.run_stream( - self._cache, - thread=self._agent_thread, - ): - updates.append(update) - await ctx.add_event(AgentRunUpdateEvent(self.id, update)) - - if isinstance(self._agent, ChatAgent): - response_format = self._agent.chat_options.response_format - response = AgentRunResponse.from_agent_run_response_updates( - updates, - output_format_type=response_format, - ) - else: - response = AgentRunResponse.from_agent_run_response_updates(updates) - else: - # Non-streaming mode: use run() and emit single event - response = await self._agent.run( - self._cache, - thread=self._agent_thread, - ) - await ctx.add_event(AgentRunEvent(self.id, response)) - - if self._output_response: - await ctx.yield_output(response) - - # Always construct a full conversation snapshot from inputs (cache) - # plus agent outputs (agent_run_response.messages). Do not mutate - # response.messages so AgentRunEvent remains faithful to the raw output. - full_conversation: list[ChatMessage] = list(self._cache) + list(response.messages) - - agent_response = AgentExecutorResponse(self.id, response, full_conversation=full_conversation) - await ctx.send_message(agent_response) - self._cache.clear() - @handler async def run( self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse] @@ -192,6 +154,31 @@ class AgentExecutor(Executor): self._cache = normalize_messages_input(messages) await self._run_agent_and_emit(ctx) + @response_handler + async def handle_user_input_response( + self, + original_request: FunctionApprovalRequestContent, + response: FunctionApprovalResponseContent, + ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse], + ) -> None: + """Handle user input responses for function approvals during agent execution. + + This will hold the executor's execution until all pending user input requests are resolved. + + Args: + original_request: The original function approval request sent by the agent. + response: The user's response to the function approval request. + ctx: The workflow context for emitting events and outputs. + """ + self._pending_responses_to_agent.append(response) + self._pending_agent_requests.pop(original_request.id, None) + + if not self._pending_agent_requests: + # All pending requests have been resolved; resume agent execution + self._cache = normalize_messages_input(ChatMessage(role="user", contents=self._pending_responses_to_agent)) + self._pending_responses_to_agent.clear() + await self._run_agent_and_emit(ctx) + async def snapshot_state(self) -> dict[str, Any]: """Capture current executor state for checkpointing. @@ -226,6 +213,8 @@ class AgentExecutor(Executor): return { "cache": encode_chat_messages(self._cache), "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), } async def restore_state(self, state: dict[str, Any]) -> None: @@ -258,7 +247,109 @@ class AgentExecutor(Executor): else: self._agent_thread = self._agent.get_new_thread() + pending_requests_payload = state.get("pending_agent_requests") + if pending_requests_payload: + self._pending_agent_requests = decode_checkpoint_value(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) + def reset(self) -> None: """Reset the internal cache of the executor.""" logger.debug("AgentExecutor %s: Resetting cache", self.id) self._cache.clear() + + async def _run_agent_and_emit(self, ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse]) -> None: + """Execute the underlying agent, emit events, and enqueue response. + + Checks ctx.is_streaming() to determine whether to emit incremental AgentRunUpdateEvent + events (streaming mode) or a single AgentRunEvent (non-streaming mode). + """ + if ctx.is_streaming(): + # Streaming mode: emit incremental updates + response = await self._run_agent_streaming(cast(WorkflowContext, ctx)) + else: + # Non-streaming mode: use run() and emit single event + response = await self._run_agent(cast(WorkflowContext, ctx)) + + if response is None: + # Agent did not complete (e.g., waiting for user input); do not emit response + logger.info("AgentExecutor %s: Agent did not complete, awaiting user input", self.id) + return + + if self._output_response: + await ctx.yield_output(response) + + # Always construct a full conversation snapshot from inputs (cache) + # plus agent outputs (agent_run_response.messages). Do not mutate + # response.messages so AgentRunEvent remains faithful to the raw output. + full_conversation: list[ChatMessage] = list(self._cache) + list(response.messages) + + agent_response = AgentExecutorResponse(self.id, response, full_conversation=full_conversation) + await ctx.send_message(agent_response) + self._cache.clear() + + async def _run_agent(self, ctx: WorkflowContext) -> AgentRunResponse | None: + """Execute the underlying agent in non-streaming mode. + + Args: + ctx: The workflow context for emitting events. + + Returns: + The complete AgentRunResponse, or None if waiting for user input. + """ + response = await self._agent.run( + self._cache, + thread=self._agent_thread, + ) + await ctx.add_event(AgentRunEvent(self.id, response)) + + # Handle any user input requests + if response.user_input_requests: + for user_input_request in response.user_input_requests: + self._pending_agent_requests[user_input_request.id] = user_input_request + await ctx.request_info(user_input_request, FunctionApprovalResponseContent) + return None + + return response + + async def _run_agent_streaming(self, ctx: WorkflowContext) -> AgentRunResponse | None: + """Execute the underlying agent in streaming mode and collect the full response. + + Args: + ctx: The workflow context for emitting events. + + Returns: + The complete AgentRunResponse, or None if waiting for user input. + """ + updates: list[AgentRunResponseUpdate] = [] + user_input_requests: list[FunctionApprovalRequestContent] = [] + async for update in self._agent.run_stream( + self._cache, + thread=self._agent_thread, + ): + updates.append(update) + await ctx.add_event(AgentRunUpdateEvent(self.id, update)) + + if update.user_input_requests: + user_input_requests.extend(update.user_input_requests) + + # Build the final AgentRunResponse from the collected updates + if isinstance(self._agent, ChatAgent): + response_format = self._agent.chat_options.response_format + response = AgentRunResponse.from_agent_run_response_updates( + updates, + output_format_type=response_format, + ) + else: + response = AgentRunResponse.from_agent_run_response_updates(updates) + + # Handle any user input requests after the streaming completes + if user_input_requests: + for user_input_request in user_input_requests: + self._pending_agent_requests[user_input_request.id] = user_input_request + await ctx.request_info(user_input_request, FunctionApprovalResponseContent) + return None + + return response diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 279180e0ee..149fe4bfac 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -293,6 +293,14 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): # Map the parameter name and remove the old one mapped_tool[api_param] = mapped_tool.pop(user_param) + # Validate partial_images parameter for streaming image generation + # OpenAI API requires partial_images to be between 0-3 (inclusive) for image_generation tool + # Reference: https://platform.openai.com/docs/api-reference/responses/create#responses_create-tools-image_generation_tool-partial_images + if "partial_images" in mapped_tool: + partial_images = mapped_tool["partial_images"] + if not isinstance(partial_images, int) or partial_images < 0 or partial_images > 3: + raise ValueError("partial_images must be an integer between 0 and 3 (inclusive).") + response_tools.append(mapped_tool) else: response_tools.append(tool_dict) @@ -695,29 +703,8 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): uri = item.result media_type = None if not uri.startswith("data:"): - # Raw base64 string - convert to proper data URI format - # Detect format from base64 data - import base64 - - try: - # Decode a small portion to detect format - decoded_data = base64.b64decode(uri[:100]) # First ~75 bytes should be enough - if decoded_data.startswith(b"\x89PNG"): - format_type = "png" - elif decoded_data.startswith(b"\xff\xd8\xff"): - format_type = "jpeg" - elif decoded_data.startswith(b"RIFF") and b"WEBP" in decoded_data[:12]: - format_type = "webp" - elif decoded_data.startswith(b"GIF87a") or decoded_data.startswith(b"GIF89a"): - format_type = "gif" - else: - # Default to png if format cannot be detected - format_type = "png" - except Exception: - # Fallback to png if decoding fails - format_type = "png" - uri = f"data:image/{format_type};base64,{uri}" - media_type = f"image/{format_type}" + # Raw base64 string - convert to proper data URI format using helper + uri, media_type = DataContent.create_data_uri_from_base64(uri) else: # Parse media type from existing data URI try: @@ -933,6 +920,25 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): raw_representation=event, ) ) + case "response.image_generation_call.partial_image": + # Handle streaming partial image generation + image_base64 = event.partial_image_b64 + partial_index = event.partial_image_index + + # Use helper function to create data URI from base64 + uri, media_type = DataContent.create_data_uri_from_base64(image_base64) + + contents.append( + DataContent( + uri=uri, + media_type=media_type, + additional_properties={ + "partial_image_index": partial_index, + "is_partial_image": True, + }, + raw_representation=event, + ) + ) case _: logger.debug("Unparsed event of type: %s: %s", event.type, event) diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index ebe0e73d51..0dc26386c2 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.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 909e72a0a0..38a3fe414e 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import base64 from collections.abc import AsyncIterable from typing import Any @@ -166,6 +167,57 @@ def test_data_content_empty(): DataContent(uri="") +def test_data_content_detect_image_format_from_base64(): + """Test the detect_image_format_from_base64 static method.""" + # Test each supported format + png_data = b"\x89PNG\r\n\x1a\n" + b"fake_data" + assert DataContent.detect_image_format_from_base64(base64.b64encode(png_data).decode()) == "png" + + jpeg_data = b"\xff\xd8\xff\xe0" + b"fake_data" + assert DataContent.detect_image_format_from_base64(base64.b64encode(jpeg_data).decode()) == "jpeg" + + webp_data = b"RIFF" + b"1234" + b"WEBP" + b"fake_data" + assert DataContent.detect_image_format_from_base64(base64.b64encode(webp_data).decode()) == "webp" + + gif_data = b"GIF89a" + b"fake_data" + assert DataContent.detect_image_format_from_base64(base64.b64encode(gif_data).decode()) == "gif" + + # Test fallback behavior + unknown_data = b"UNKNOWN_FORMAT" + assert DataContent.detect_image_format_from_base64(base64.b64encode(unknown_data).decode()) == "png" + + # Test error handling + assert DataContent.detect_image_format_from_base64("invalid_base64!") == "png" + assert DataContent.detect_image_format_from_base64("") == "png" + + +def test_data_content_create_data_uri_from_base64(): + """Test the create_data_uri_from_base64 class method.""" + # Test with PNG data + png_data = b"\x89PNG\r\n\x1a\n" + b"fake_data" + png_base64 = base64.b64encode(png_data).decode() + uri, media_type = DataContent.create_data_uri_from_base64(png_base64) + + assert uri == f"data:image/png;base64,{png_base64}" + assert media_type == "image/png" + + # Test with different format + jpeg_data = b"\xff\xd8\xff\xe0" + b"fake_data" + jpeg_base64 = base64.b64encode(jpeg_data).decode() + uri, media_type = DataContent.create_data_uri_from_base64(jpeg_base64) + + assert uri == f"data:image/jpeg;base64,{jpeg_base64}" + assert media_type == "image/jpeg" + + # Test fallback for unknown format + unknown_data = b"UNKNOWN_FORMAT" + unknown_base64 = base64.b64encode(unknown_data).decode() + uri, media_type = DataContent.create_data_uri_from_base64(unknown_base64) + + assert uri == f"data:image/png;base64,{unknown_base64}" + assert media_type == "image/png" + + # region UriContent 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 cb4f0dc0d3..5ff4bb3de3 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -36,6 +36,7 @@ from agent_framework import ( HostedMCPTool, HostedVectorStoreContent, HostedWebSearchTool, + MCPStreamableHTTPTool, Role, TextContent, TextReasoningContent, @@ -946,6 +947,505 @@ def test_streaming_response_basic_structure() -> None: assert response.raw_representation is mock_event +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")] + + mock_response = MagicMock() + original_error_message = "Request rate limit exceeded" + mock_error = BadRequestError( + message=original_error_message, + response=mock_response, + body={"error": {"code": "rate_limit", "message": original_error_message}}, + ) + mock_error.code = "rate_limit" + + with ( + patch.object(client.client.responses, "parse", side_effect=mock_error), + pytest.raises(ServiceResponseException) as exc_info, + ): + asyncio.run(client.get_response(messages=messages, response_format=OutputStruct)) + + exception_message = str(exc_info.value) + assert "service failed to complete the prompt:" in exception_message + assert original_error_message in exception_message + + +def test_get_streaming_response_with_response_format() -> None: + """Test get_streaming_response with response_format.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + messages = [ChatMessage(role="user", text="Test streaming with format")] + + # It will fail due to invalid API key, but exercises the code path + with pytest.raises(ServiceResponseException): + + async def run_streaming(): + async for _ in client.get_streaming_response(messages=messages, response_format=OutputStruct): + pass + + asyncio.run(run_streaming()) + + +def test_openai_content_parser_image_content() -> None: + """Test _openai_content_parser with image content variations.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + # Test image content with detail parameter and file_id + image_content_with_detail = UriContent( + uri="https://example.com/image.jpg", + media_type="image/jpeg", + additional_properties={"detail": "high", "file_id": "file_123"}, + ) + result = client._openai_content_parser(Role.USER, image_content_with_detail, {}) # type: ignore + assert result["type"] == "input_image" + assert result["image_url"] == "https://example.com/image.jpg" + assert result["detail"] == "high" + assert result["file_id"] == "file_123" + + # Test image content without additional properties (defaults) + image_content_basic = UriContent(uri="https://example.com/basic.png", media_type="image/png") + result = client._openai_content_parser(Role.USER, image_content_basic, {}) # type: ignore + assert result["type"] == "input_image" + assert result["detail"] == "auto" + assert result["file_id"] is None + + +def test_openai_content_parser_audio_content() -> None: + """Test _openai_content_parser with audio content variations.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + # Test WAV audio content + wav_content = UriContent(uri="data:audio/wav;base64,abc123", media_type="audio/wav") + result = client._openai_content_parser(Role.USER, wav_content, {}) # type: ignore + assert result["type"] == "input_audio" + assert result["input_audio"]["data"] == "data:audio/wav;base64,abc123" + assert result["input_audio"]["format"] == "wav" + + # Test MP3 audio content + mp3_content = UriContent(uri="data:audio/mp3;base64,def456", media_type="audio/mp3") + result = client._openai_content_parser(Role.USER, mp3_content, {}) # type: ignore + assert result["type"] == "input_audio" + assert result["input_audio"]["format"] == "mp3" + + +def test_openai_content_parser_unsupported_content() -> None: + """Test _openai_content_parser with unsupported content types.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + # Test unsupported audio format + unsupported_audio = UriContent(uri="data:audio/ogg;base64,ghi789", media_type="audio/ogg") + result = client._openai_content_parser(Role.USER, unsupported_audio, {}) # type: ignore + assert result == {} + + # Test non-media content + text_uri_content = UriContent(uri="https://example.com/document.txt", media_type="text/plain") + result = client._openai_content_parser(Role.USER, text_uri_content, {}) # type: ignore + assert result == {} + + +def test_create_streaming_response_content_code_interpreter() -> None: + """Test _create_streaming_response_content with code_interpreter_call.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + + mock_event_image = MagicMock() + mock_event_image.type = "response.output_item.added" + mock_item_image = MagicMock() + mock_item_image.type = "code_interpreter_call" + mock_image_output = MagicMock() + mock_image_output.type = "image" + mock_image_output.url = "https://example.com/plot.png" + mock_item_image.outputs = [mock_image_output] + mock_item_image.code = None + mock_event_image.item = mock_item_image + + result = client._create_streaming_response_content(mock_event_image, chat_options, function_call_ids) # type: ignore + assert len(result.contents) == 1 + assert isinstance(result.contents[0], UriContent) + assert result.contents[0].uri == "https://example.com/plot.png" + assert result.contents[0].media_type == "image" + + +def test_create_streaming_response_content_reasoning() -> None: + """Test _create_streaming_response_content with reasoning content.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + + mock_event_reasoning = MagicMock() + mock_event_reasoning.type = "response.output_item.added" + mock_item_reasoning = MagicMock() + mock_item_reasoning.type = "reasoning" + mock_reasoning_content = MagicMock() + mock_reasoning_content.text = "Analyzing the problem step by step..." + mock_item_reasoning.content = [mock_reasoning_content] + mock_item_reasoning.summary = ["Problem analysis summary"] + mock_event_reasoning.item = mock_item_reasoning + + result = client._create_streaming_response_content(mock_event_reasoning, chat_options, function_call_ids) # type: ignore + assert len(result.contents) == 1 + assert isinstance(result.contents[0], TextReasoningContent) + assert result.contents[0].text == "Analyzing the problem step by step..." + if result.contents[0].additional_properties: + assert result.contents[0].additional_properties["summary"] == "Problem analysis summary" + + +def test_openai_content_parser_text_reasoning_comprehensive() -> None: + """Test _openai_content_parser with TextReasoningContent all additional properties.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + # Test TextReasoningContent with all additional properties + comprehensive_reasoning = TextReasoningContent( + text="Comprehensive reasoning summary", + additional_properties={ + "status": "in_progress", + "reasoning_text": "Step-by-step analysis", + "encrypted_content": "secure_data_456", + }, + ) + result = client._openai_content_parser(Role.ASSISTANT, comprehensive_reasoning, {}) # type: ignore + assert result["type"] == "reasoning" + assert result["summary"]["text"] == "Comprehensive reasoning summary" + assert result["status"] == "in_progress" + assert result["content"]["type"] == "reasoning_text" + assert result["content"]["text"] == "Step-by-step analysis" + assert result["encrypted_content"] == "secure_data_456" + + +def test_streaming_reasoning_text_delta_event() -> None: + """Test reasoning text delta event creates TextReasoningContent.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + + event = ResponseReasoningTextDeltaEvent( + type="response.reasoning_text.delta", + content_index=0, + item_id="reasoning_123", + output_index=0, + sequence_number=1, + delta="reasoning delta", + ) + + with patch.object(client, "_get_metadata_from_response", return_value={}) as mock_metadata: + response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore + + assert len(response.contents) == 1 + assert isinstance(response.contents[0], TextReasoningContent) + assert response.contents[0].text == "reasoning delta" + assert response.contents[0].raw_representation == event + mock_metadata.assert_called_once_with(event) + + +def test_streaming_reasoning_text_done_event() -> None: + """Test reasoning text done event creates TextReasoningContent with complete text.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + + event = ResponseReasoningTextDoneEvent( + type="response.reasoning_text.done", + content_index=0, + item_id="reasoning_456", + output_index=0, + sequence_number=2, + text="complete reasoning", + ) + + with patch.object(client, "_get_metadata_from_response", return_value={"test": "data"}) as mock_metadata: + response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore + + assert len(response.contents) == 1 + assert isinstance(response.contents[0], TextReasoningContent) + assert response.contents[0].text == "complete reasoning" + assert response.contents[0].raw_representation == event + mock_metadata.assert_called_once_with(event) + assert response.additional_properties == {"test": "data"} + + +def test_streaming_reasoning_summary_text_delta_event() -> None: + """Test reasoning summary text delta event creates TextReasoningContent.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + + event = ResponseReasoningSummaryTextDeltaEvent( + type="response.reasoning_summary_text.delta", + item_id="summary_789", + output_index=0, + sequence_number=3, + summary_index=0, + delta="summary delta", + ) + + with patch.object(client, "_get_metadata_from_response", return_value={}) as mock_metadata: + response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore + + assert len(response.contents) == 1 + assert isinstance(response.contents[0], TextReasoningContent) + assert response.contents[0].text == "summary delta" + assert response.contents[0].raw_representation == event + mock_metadata.assert_called_once_with(event) + + +def test_streaming_reasoning_summary_text_done_event() -> None: + """Test reasoning summary text done event creates TextReasoningContent with complete text.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + + event = ResponseReasoningSummaryTextDoneEvent( + type="response.reasoning_summary_text.done", + item_id="summary_012", + output_index=0, + sequence_number=4, + summary_index=0, + text="complete summary", + ) + + with patch.object(client, "_get_metadata_from_response", return_value={"custom": "meta"}) as mock_metadata: + response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore + + assert len(response.contents) == 1 + assert isinstance(response.contents[0], TextReasoningContent) + assert response.contents[0].text == "complete summary" + assert response.contents[0].raw_representation == event + mock_metadata.assert_called_once_with(event) + assert response.additional_properties == {"custom": "meta"} + + +def test_streaming_reasoning_events_preserve_metadata() -> None: + """Test that reasoning events preserve metadata like regular text events.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + + text_event = ResponseTextDeltaEvent( + type="response.output_text.delta", + content_index=0, + item_id="text_item", + output_index=0, + sequence_number=1, + logprobs=[], + delta="text", + ) + + reasoning_event = ResponseReasoningTextDeltaEvent( + type="response.reasoning_text.delta", + content_index=0, + item_id="reasoning_item", + output_index=0, + sequence_number=2, + delta="reasoning", + ) + + with patch.object(client, "_get_metadata_from_response", return_value={"test": "metadata"}): + text_response = client._create_streaming_response_content(text_event, chat_options, function_call_ids) # type: ignore + reasoning_response = client._create_streaming_response_content(reasoning_event, chat_options, function_call_ids) # type: ignore + + # Both should preserve metadata + assert text_response.additional_properties == {"test": "metadata"} + assert reasoning_response.additional_properties == {"test": "metadata"} + + # Content types should be different + assert isinstance(text_response.contents[0], TextContent) + assert isinstance(reasoning_response.contents[0], TextReasoningContent) + + +def test_create_response_content_image_generation_raw_base64(): + """Test image generation response parsing with raw base64 string.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + # Create a mock response with raw base64 image data (PNG signature) + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-response-id" + mock_response.model = "test-model" + mock_response.created_at = 1234567890 + + # Mock image generation output item with raw base64 (PNG format) + png_signature = b"\x89PNG\r\n\x1a\n" + mock_base64 = base64.b64encode(png_signature + b"fake_png_data_here").decode() + + mock_item = MagicMock() + mock_item.type = "image_generation_call" + mock_item.result = mock_base64 + + mock_response.output = [mock_item] + + with patch.object(client, "_get_metadata_from_response", return_value={}): + response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore + + # Verify the response contains DataContent with proper URI and media_type + assert len(response.messages[0].contents) == 1 + content = response.messages[0].contents[0] + assert isinstance(content, DataContent) + assert content.uri.startswith("data:image/png;base64,") + assert content.media_type == "image/png" + + +def test_create_response_content_image_generation_existing_data_uri(): + """Test image generation response parsing with existing data URI.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + # Create a mock response with existing data URI + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-response-id" + mock_response.model = "test-model" + mock_response.created_at = 1234567890 + + # Mock image generation output item with existing data URI (valid WEBP header) + webp_signature = b"RIFF" + b"\x12\x00\x00\x00" + b"WEBP" + valid_webp_base64 = base64.b64encode(webp_signature + b"VP8 fake_data").decode() + mock_item = MagicMock() + mock_item.type = "image_generation_call" + mock_item.result = f"data:image/webp;base64,{valid_webp_base64}" + + mock_response.output = [mock_item] + + with patch.object(client, "_get_metadata_from_response", return_value={}): + response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore + + # Verify the response contains DataContent with proper media_type parsed from URI + assert len(response.messages[0].contents) == 1 + content = response.messages[0].contents[0] + assert isinstance(content, DataContent) + assert content.uri == f"data:image/webp;base64,{valid_webp_base64}" + assert content.media_type == "image/webp" + + +def test_create_response_content_image_generation_format_detection(): + """Test different image format detection from base64 data.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + # Test JPEG detection + jpeg_signature = b"\xff\xd8\xff" + mock_base64_jpeg = base64.b64encode(jpeg_signature + b"fake_jpeg_data").decode() + + mock_response_jpeg = MagicMock() + mock_response_jpeg.output_parsed = None + mock_response_jpeg.metadata = {} + mock_response_jpeg.usage = None + mock_response_jpeg.id = "test-id" + mock_response_jpeg.model = "test-model" + mock_response_jpeg.created_at = 1234567890 + + mock_item_jpeg = MagicMock() + mock_item_jpeg.type = "image_generation_call" + mock_item_jpeg.result = mock_base64_jpeg + mock_response_jpeg.output = [mock_item_jpeg] + + with patch.object(client, "_get_metadata_from_response", return_value={}): + response_jpeg = client._create_response_content(mock_response_jpeg, chat_options=ChatOptions()) # type: ignore + content_jpeg = response_jpeg.messages[0].contents[0] + assert isinstance(content_jpeg, DataContent) + assert content_jpeg.media_type == "image/jpeg" + assert "data:image/jpeg;base64," in content_jpeg.uri + + # Test WEBP detection + webp_signature = b"RIFF" + b"\x00\x00\x00\x00" + b"WEBP" + mock_base64_webp = base64.b64encode(webp_signature + b"fake_webp_data").decode() + + mock_response_webp = MagicMock() + mock_response_webp.output_parsed = None + mock_response_webp.metadata = {} + mock_response_webp.usage = None + mock_response_webp.id = "test-id" + mock_response_webp.model = "test-model" + mock_response_webp.created_at = 1234567890 + + mock_item_webp = MagicMock() + mock_item_webp.type = "image_generation_call" + mock_item_webp.result = mock_base64_webp + mock_response_webp.output = [mock_item_webp] + + with patch.object(client, "_get_metadata_from_response", return_value={}): + response_webp = client._create_response_content(mock_response_webp, chat_options=ChatOptions()) # type: ignore + content_webp = response_webp.messages[0].contents[0] + assert isinstance(content_webp, DataContent) + assert content_webp.media_type == "image/webp" + assert "data:image/webp;base64," in content_webp.uri + + +def test_create_response_content_image_generation_fallback(): + """Test image generation with invalid base64 falls back to PNG.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + # Create a mock response with invalid base64 + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-response-id" + mock_response.model = "test-model" + mock_response.created_at = 1234567890 + + # Mock image generation output item with unrecognized format (should fall back to PNG) + unrecognized_data = b"UNKNOWN_FORMAT" + b"some_binary_data" + unrecognized_base64 = base64.b64encode(unrecognized_data).decode() + mock_item = MagicMock() + mock_item.type = "image_generation_call" + mock_item.result = unrecognized_base64 + + mock_response.output = [mock_item] + + with patch.object(client, "_get_metadata_from_response", return_value={}): + response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore + + # Verify it falls back to PNG format for unrecognized binary data + assert len(response.messages[0].contents) == 1 + content = response.messages[0].contents[0] + assert isinstance(content, DataContent) + assert content.media_type == "image/png" + assert f"data:image/png;base64,{unrecognized_base64}" == content.uri + + +def test_prepare_options_store_parameter_handling() -> None: + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + messages = [ChatMessage(role="user", text="Test message")] + + test_conversation_id = "test-conversation-123" + chat_options = ChatOptions(store=True, conversation_id=test_conversation_id) + options = client._prepare_options(messages, chat_options) # type: ignore + assert options["store"] is True + assert options["previous_response_id"] == test_conversation_id + + chat_options = ChatOptions(store=False, conversation_id="") + options = client._prepare_options(messages, chat_options) # type: ignore + assert options["store"] is False + + chat_options = ChatOptions(store=None, conversation_id=None) + options = client._prepare_options(messages, chat_options) # type: ignore + assert options["store"] is False + assert "previous_response_id" not in options + + chat_options = ChatOptions() + options = client._prepare_options(messages, chat_options) # type: ignore + assert options["store"] is False + assert "previous_response_id" not in options + + +def test_openai_responses_client_with_callable_api_key() -> None: + """Test OpenAIResponsesClient initialization with callable API key.""" + + async def get_api_key() -> str: + return "test-api-key-123" + + client = OpenAIResponsesClient(model_id="gpt-4o", api_key=get_api_key) + + # Verify client was created successfully + assert client.model_id == "gpt-4o" + # OpenAI SDK now manages callable API keys internally + assert client.client is not None + + @pytest.mark.flaky @skip_if_openai_integration_tests_disabled async def test_openai_responses_client_response() -> None: @@ -1615,500 +2115,28 @@ async def test_openai_responses_client_agent_hosted_mcp_tool() -> None: assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"]) -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")] +@pytest.mark.flaky +@skip_if_openai_integration_tests_disabled +async def test_openai_responses_client_agent_local_mcp_tool() -> None: + """Integration test for MCPStreamableHTTPTool with OpenAI Response Agent using Microsoft Learn MCP.""" - mock_response = MagicMock() - original_error_message = "Request rate limit exceeded" - mock_error = BadRequestError( - message=original_error_message, - response=mock_response, - body={"error": {"code": "rate_limit", "message": original_error_message}}, - ) - mock_error.code = "rate_limit" - - with ( - patch.object(client.client.responses, "parse", side_effect=mock_error), - pytest.raises(ServiceResponseException) as exc_info, - ): - asyncio.run(client.get_response(messages=messages, response_format=OutputStruct)) - - exception_message = str(exc_info.value) - assert "service failed to complete the prompt:" in exception_message - assert original_error_message in exception_message - - -def test_get_streaming_response_with_response_format() -> None: - """Test get_streaming_response with response_format.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - messages = [ChatMessage(role="user", text="Test streaming with format")] - - # It will fail due to invalid API key, but exercises the code path - with pytest.raises(ServiceResponseException): - - async def run_streaming(): - async for _ in client.get_streaming_response(messages=messages, response_format=OutputStruct): - pass - - asyncio.run(run_streaming()) - - -def test_openai_content_parser_image_content() -> None: - """Test _openai_content_parser with image content variations.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - - # Test image content with detail parameter and file_id - image_content_with_detail = UriContent( - uri="https://example.com/image.jpg", - media_type="image/jpeg", - additional_properties={"detail": "high", "file_id": "file_123"}, - ) - result = client._openai_content_parser(Role.USER, image_content_with_detail, {}) # type: ignore - assert result["type"] == "input_image" - assert result["image_url"] == "https://example.com/image.jpg" - assert result["detail"] == "high" - assert result["file_id"] == "file_123" - - # Test image content without additional properties (defaults) - image_content_basic = UriContent(uri="https://example.com/basic.png", media_type="image/png") - result = client._openai_content_parser(Role.USER, image_content_basic, {}) # type: ignore - assert result["type"] == "input_image" - assert result["detail"] == "auto" - assert result["file_id"] is None - - -def test_openai_content_parser_audio_content() -> None: - """Test _openai_content_parser with audio content variations.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - - # Test WAV audio content - wav_content = UriContent(uri="data:audio/wav;base64,abc123", media_type="audio/wav") - result = client._openai_content_parser(Role.USER, wav_content, {}) # type: ignore - assert result["type"] == "input_audio" - assert result["input_audio"]["data"] == "data:audio/wav;base64,abc123" - assert result["input_audio"]["format"] == "wav" - - # Test MP3 audio content - mp3_content = UriContent(uri="data:audio/mp3;base64,def456", media_type="audio/mp3") - result = client._openai_content_parser(Role.USER, mp3_content, {}) # type: ignore - assert result["type"] == "input_audio" - assert result["input_audio"]["format"] == "mp3" - - -def test_openai_content_parser_unsupported_content() -> None: - """Test _openai_content_parser with unsupported content types.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - - # Test unsupported audio format - unsupported_audio = UriContent(uri="data:audio/ogg;base64,ghi789", media_type="audio/ogg") - result = client._openai_content_parser(Role.USER, unsupported_audio, {}) # type: ignore - assert result == {} - - # Test non-media content - text_uri_content = UriContent(uri="https://example.com/document.txt", media_type="text/plain") - result = client._openai_content_parser(Role.USER, text_uri_content, {}) # type: ignore - assert result == {} - - -def test_create_streaming_response_content_code_interpreter() -> None: - """Test _create_streaming_response_content with code_interpreter_call.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - chat_options = ChatOptions() - function_call_ids: dict[int, tuple[str, str]] = {} - - mock_event_image = MagicMock() - mock_event_image.type = "response.output_item.added" - mock_item_image = MagicMock() - mock_item_image.type = "code_interpreter_call" - mock_image_output = MagicMock() - mock_image_output.type = "image" - mock_image_output.url = "https://example.com/plot.png" - mock_item_image.outputs = [mock_image_output] - mock_item_image.code = None - mock_event_image.item = mock_item_image - - result = client._create_streaming_response_content(mock_event_image, chat_options, function_call_ids) # type: ignore - assert len(result.contents) == 1 - assert isinstance(result.contents[0], UriContent) - assert result.contents[0].uri == "https://example.com/plot.png" - assert result.contents[0].media_type == "image" - - -def test_create_streaming_response_content_reasoning() -> None: - """Test _create_streaming_response_content with reasoning content.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - chat_options = ChatOptions() - function_call_ids: dict[int, tuple[str, str]] = {} - - mock_event_reasoning = MagicMock() - mock_event_reasoning.type = "response.output_item.added" - mock_item_reasoning = MagicMock() - mock_item_reasoning.type = "reasoning" - mock_reasoning_content = MagicMock() - mock_reasoning_content.text = "Analyzing the problem step by step..." - mock_item_reasoning.content = [mock_reasoning_content] - mock_item_reasoning.summary = ["Problem analysis summary"] - mock_event_reasoning.item = mock_item_reasoning - - result = client._create_streaming_response_content(mock_event_reasoning, chat_options, function_call_ids) # type: ignore - assert len(result.contents) == 1 - assert isinstance(result.contents[0], TextReasoningContent) - assert result.contents[0].text == "Analyzing the problem step by step..." - if result.contents[0].additional_properties: - assert result.contents[0].additional_properties["summary"] == "Problem analysis summary" - - -def test_openai_content_parser_text_reasoning_comprehensive() -> None: - """Test _openai_content_parser with TextReasoningContent all additional properties.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - - # Test TextReasoningContent with all additional properties - comprehensive_reasoning = TextReasoningContent( - text="Comprehensive reasoning summary", - additional_properties={ - "status": "in_progress", - "reasoning_text": "Step-by-step analysis", - "encrypted_content": "secure_data_456", - }, - ) - result = client._openai_content_parser(Role.ASSISTANT, comprehensive_reasoning, {}) # type: ignore - assert result["type"] == "reasoning" - assert result["summary"]["text"] == "Comprehensive reasoning summary" - assert result["status"] == "in_progress" - assert result["content"]["type"] == "reasoning_text" - assert result["content"]["text"] == "Step-by-step analysis" - assert result["encrypted_content"] == "secure_data_456" - - -def test_streaming_reasoning_text_delta_event() -> None: - """Test reasoning text delta event creates TextReasoningContent.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - chat_options = ChatOptions() - function_call_ids: dict[int, tuple[str, str]] = {} - - event = ResponseReasoningTextDeltaEvent( - type="response.reasoning_text.delta", - content_index=0, - item_id="reasoning_123", - output_index=0, - sequence_number=1, - delta="reasoning delta", + mcp_tool = MCPStreamableHTTPTool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", ) - with patch.object(client, "_get_metadata_from_response", return_value={}) as mock_metadata: - response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore - - assert len(response.contents) == 1 - assert isinstance(response.contents[0], TextReasoningContent) - assert response.contents[0].text == "reasoning delta" - assert response.contents[0].raw_representation == event - mock_metadata.assert_called_once_with(event) - - -def test_streaming_reasoning_text_done_event() -> None: - """Test reasoning text done event creates TextReasoningContent with complete text.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - chat_options = ChatOptions() - function_call_ids: dict[int, tuple[str, str]] = {} - - event = ResponseReasoningTextDoneEvent( - type="response.reasoning_text.done", - content_index=0, - item_id="reasoning_456", - output_index=0, - sequence_number=2, - text="complete reasoning", - ) - - with patch.object(client, "_get_metadata_from_response", return_value={"test": "data"}) as mock_metadata: - response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore - - assert len(response.contents) == 1 - assert isinstance(response.contents[0], TextReasoningContent) - assert response.contents[0].text == "complete reasoning" - assert response.contents[0].raw_representation == event - mock_metadata.assert_called_once_with(event) - assert response.additional_properties == {"test": "data"} - - -def test_streaming_reasoning_summary_text_delta_event() -> None: - """Test reasoning summary text delta event creates TextReasoningContent.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - chat_options = ChatOptions() - function_call_ids: dict[int, tuple[str, str]] = {} - - event = ResponseReasoningSummaryTextDeltaEvent( - type="response.reasoning_summary_text.delta", - item_id="summary_789", - output_index=0, - sequence_number=3, - summary_index=0, - delta="summary delta", - ) - - with patch.object(client, "_get_metadata_from_response", return_value={}) as mock_metadata: - response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore - - assert len(response.contents) == 1 - assert isinstance(response.contents[0], TextReasoningContent) - assert response.contents[0].text == "summary delta" - assert response.contents[0].raw_representation == event - mock_metadata.assert_called_once_with(event) - - -def test_streaming_reasoning_summary_text_done_event() -> None: - """Test reasoning summary text done event creates TextReasoningContent with complete text.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - chat_options = ChatOptions() - function_call_ids: dict[int, tuple[str, str]] = {} - - event = ResponseReasoningSummaryTextDoneEvent( - type="response.reasoning_summary_text.done", - item_id="summary_012", - output_index=0, - sequence_number=4, - summary_index=0, - text="complete summary", - ) - - with patch.object(client, "_get_metadata_from_response", return_value={"custom": "meta"}) as mock_metadata: - response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore - - assert len(response.contents) == 1 - assert isinstance(response.contents[0], TextReasoningContent) - assert response.contents[0].text == "complete summary" - assert response.contents[0].raw_representation == event - mock_metadata.assert_called_once_with(event) - assert response.additional_properties == {"custom": "meta"} - - -def test_streaming_reasoning_events_preserve_metadata() -> None: - """Test that reasoning events preserve metadata like regular text events.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - chat_options = ChatOptions() - function_call_ids: dict[int, tuple[str, str]] = {} - - text_event = ResponseTextDeltaEvent( - type="response.output_text.delta", - content_index=0, - item_id="text_item", - output_index=0, - sequence_number=1, - logprobs=[], - delta="text", - ) - - reasoning_event = ResponseReasoningTextDeltaEvent( - type="response.reasoning_text.delta", - content_index=0, - item_id="reasoning_item", - output_index=0, - sequence_number=2, - delta="reasoning", - ) - - with patch.object(client, "_get_metadata_from_response", return_value={"test": "metadata"}): - text_response = client._create_streaming_response_content(text_event, chat_options, function_call_ids) # type: ignore - reasoning_response = client._create_streaming_response_content(reasoning_event, chat_options, function_call_ids) # type: ignore - - # Both should preserve metadata - assert text_response.additional_properties == {"test": "metadata"} - assert reasoning_response.additional_properties == {"test": "metadata"} - - # Content types should be different - assert isinstance(text_response.contents[0], TextContent) - assert isinstance(reasoning_response.contents[0], TextReasoningContent) - - -def test_create_response_content_image_generation_raw_base64(): - """Test image generation response parsing with raw base64 string.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - - # Create a mock response with raw base64 image data (PNG signature) - mock_response = MagicMock() - mock_response.output_parsed = None - mock_response.metadata = {} - mock_response.usage = None - mock_response.id = "test-response-id" - mock_response.model = "test-model" - mock_response.created_at = 1234567890 - - # Mock image generation output item with raw base64 (PNG format) - png_signature = b"\x89PNG\r\n\x1a\n" - mock_base64 = base64.b64encode(png_signature + b"fake_png_data_here").decode() - - mock_item = MagicMock() - mock_item.type = "image_generation_call" - mock_item.result = mock_base64 - - mock_response.output = [mock_item] - - with patch.object(client, "_get_metadata_from_response", return_value={}): - response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore - - # Verify the response contains DataContent with proper URI and media_type - assert len(response.messages[0].contents) == 1 - content = response.messages[0].contents[0] - assert isinstance(content, DataContent) - assert content.uri.startswith("data:image/png;base64,") - assert content.media_type == "image/png" - - -def test_create_response_content_image_generation_existing_data_uri(): - """Test image generation response parsing with existing data URI.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - - # Create a mock response with existing data URI - mock_response = MagicMock() - mock_response.output_parsed = None - mock_response.metadata = {} - mock_response.usage = None - mock_response.id = "test-response-id" - mock_response.model = "test-model" - mock_response.created_at = 1234567890 - - # Mock image generation output item with existing data URI (valid WEBP header) - webp_signature = b"RIFF" + b"\x12\x00\x00\x00" + b"WEBP" - valid_webp_base64 = base64.b64encode(webp_signature + b"VP8 fake_data").decode() - mock_item = MagicMock() - mock_item.type = "image_generation_call" - mock_item.result = f"data:image/webp;base64,{valid_webp_base64}" - - mock_response.output = [mock_item] - - with patch.object(client, "_get_metadata_from_response", return_value={}): - response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore - - # Verify the response contains DataContent with proper media_type parsed from URI - assert len(response.messages[0].contents) == 1 - content = response.messages[0].contents[0] - assert isinstance(content, DataContent) - assert content.uri == f"data:image/webp;base64,{valid_webp_base64}" - assert content.media_type == "image/webp" - - -def test_create_response_content_image_generation_format_detection(): - """Test different image format detection from base64 data.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - - # Test JPEG detection - jpeg_signature = b"\xff\xd8\xff" - mock_base64_jpeg = base64.b64encode(jpeg_signature + b"fake_jpeg_data").decode() - - mock_response_jpeg = MagicMock() - mock_response_jpeg.output_parsed = None - mock_response_jpeg.metadata = {} - mock_response_jpeg.usage = None - mock_response_jpeg.id = "test-id" - mock_response_jpeg.model = "test-model" - mock_response_jpeg.created_at = 1234567890 - - mock_item_jpeg = MagicMock() - mock_item_jpeg.type = "image_generation_call" - mock_item_jpeg.result = mock_base64_jpeg - mock_response_jpeg.output = [mock_item_jpeg] - - with patch.object(client, "_get_metadata_from_response", return_value={}): - response_jpeg = client._create_response_content(mock_response_jpeg, chat_options=ChatOptions()) # type: ignore - content_jpeg = response_jpeg.messages[0].contents[0] - assert isinstance(content_jpeg, DataContent) - assert content_jpeg.media_type == "image/jpeg" - assert "data:image/jpeg;base64," in content_jpeg.uri - - # Test WEBP detection - webp_signature = b"RIFF" + b"\x00\x00\x00\x00" + b"WEBP" - mock_base64_webp = base64.b64encode(webp_signature + b"fake_webp_data").decode() - - mock_response_webp = MagicMock() - mock_response_webp.output_parsed = None - mock_response_webp.metadata = {} - mock_response_webp.usage = None - mock_response_webp.id = "test-id" - mock_response_webp.model = "test-model" - mock_response_webp.created_at = 1234567890 - - mock_item_webp = MagicMock() - mock_item_webp.type = "image_generation_call" - mock_item_webp.result = mock_base64_webp - mock_response_webp.output = [mock_item_webp] - - with patch.object(client, "_get_metadata_from_response", return_value={}): - response_webp = client._create_response_content(mock_response_webp, chat_options=ChatOptions()) # type: ignore - content_webp = response_webp.messages[0].contents[0] - assert isinstance(content_webp, DataContent) - assert content_webp.media_type == "image/webp" - assert "data:image/webp;base64," in content_webp.uri - - -def test_create_response_content_image_generation_fallback(): - """Test image generation with invalid base64 falls back to PNG.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - - # Create a mock response with invalid base64 - mock_response = MagicMock() - mock_response.output_parsed = None - mock_response.metadata = {} - mock_response.usage = None - mock_response.id = "test-response-id" - mock_response.model = "test-model" - mock_response.created_at = 1234567890 - - # Mock image generation output item with unrecognized format (should fall back to PNG) - unrecognized_data = b"UNKNOWN_FORMAT" + b"some_binary_data" - unrecognized_base64 = base64.b64encode(unrecognized_data).decode() - mock_item = MagicMock() - mock_item.type = "image_generation_call" - mock_item.result = unrecognized_base64 - - mock_response.output = [mock_item] - - with patch.object(client, "_get_metadata_from_response", return_value={}): - response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore - - # Verify it falls back to PNG format for unrecognized binary data - assert len(response.messages[0].contents) == 1 - content = response.messages[0].contents[0] - assert isinstance(content, DataContent) - assert content.media_type == "image/png" - assert f"data:image/png;base64,{unrecognized_base64}" == content.uri - - -def test_prepare_options_store_parameter_handling() -> None: - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") - messages = [ChatMessage(role="user", text="Test message")] - - test_conversation_id = "test-conversation-123" - chat_options = ChatOptions(store=True, conversation_id=test_conversation_id) - options = client._prepare_options(messages, chat_options) # type: ignore - assert options["store"] is True - assert options["previous_response_id"] == test_conversation_id - - chat_options = ChatOptions(store=False, conversation_id="") - options = client._prepare_options(messages, chat_options) # type: ignore - assert options["store"] is False - - chat_options = ChatOptions(store=None, conversation_id=None) - options = client._prepare_options(messages, chat_options) # type: ignore - assert options["store"] is False - assert "previous_response_id" not in options - - chat_options = ChatOptions() - options = client._prepare_options(messages, chat_options) # type: ignore - assert options["store"] is False - assert "previous_response_id" not in options - - -def test_openai_responses_client_with_callable_api_key() -> None: - """Test OpenAIResponsesClient initialization with callable API key.""" - - async def get_api_key() -> str: - return "test-api-key-123" - - client = OpenAIResponsesClient(model_id="gpt-4o", api_key=get_api_key) - - # Verify client was created successfully - assert client.model_id == "gpt-4o" - # OpenAI SDK now manages callable API keys internally - assert client.client is not None + async with ChatAgent( + chat_client=OpenAIResponsesClient(), + instructions="You are a helpful assistant that can help with microsoft documentation questions.", + tools=[mcp_tool], + ) as agent: + response = await agent.run( + "How to create an Azure storage account using az cli?", + max_tokens=200, + ) + + assert isinstance(response, AgentRunResponse) + assert response.text is not None + assert len(response.text) > 0 + # 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"]) diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index 3bda2fcaad..77fd969f12 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -111,6 +111,10 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None: 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 checkpoint contains pending requests from agents and responses to be sent + assert "pending_agent_requests" in executor_state + assert "pending_responses_to_agent" in executor_state + # 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") 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 8124f6253d..a7849120b0 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 @@ -5,19 +5,32 @@ from collections.abc import AsyncIterable from typing import Any +from typing_extensions import Never + from agent_framework import ( AgentExecutor, + AgentExecutorResponse, AgentRunResponse, AgentRunResponseUpdate, AgentRunUpdateEvent, AgentThread, BaseAgent, + ChatAgent, ChatMessage, + ChatResponse, + ChatResponseUpdate, + FunctionApprovalRequestContent, FunctionCallContent, FunctionResultContent, + RequestInfoEvent, Role, TextContent, WorkflowBuilder, + WorkflowContext, + WorkflowOutputEvent, + ai_function, + executor, + use_function_invocation, ) @@ -120,3 +133,235 @@ async def test_agent_executor_emits_tool_calls_in_streaming_mode() -> None: assert events[3].data is not None assert isinstance(events[3].data.contents[0], TextContent) assert "sunny" in events[3].data.contents[0].text + + +@ai_function(approval_mode="always_require") +def mock_tool_requiring_approval(query: str) -> str: + """Mock tool that requires approval before execution.""" + return f"Executed tool with query: {query}" + + +@use_function_invocation +class MockChatClient: + """Simple implementation of a chat client.""" + + def __init__(self, parallel_request: bool = False) -> None: + self.additional_properties: dict[str, Any] = {} + self._iteration: int = 0 + self._parallel_request: bool = parallel_request + + async def get_response( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage], + **kwargs: Any, + ) -> ChatResponse: + if self._iteration == 0: + if self._parallel_request: + response = ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[ + FunctionCallContent( + call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}' + ), + FunctionCallContent( + call_id="2", name="mock_tool_requiring_approval", arguments='{"query": "test"}' + ), + ], + ) + ) + else: + response = ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[ + FunctionCallContent( + call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}' + ) + ], + ) + ) + else: + response = ChatResponse(messages=ChatMessage(role="assistant", text="Tool executed successfully.")) + + self._iteration += 1 + return response + + async def get_streaming_response( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage], + **kwargs: Any, + ) -> AsyncIterable[ChatResponseUpdate]: + if self._iteration == 0: + if self._parallel_request: + yield ChatResponseUpdate( + contents=[ + FunctionCallContent( + call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}' + ), + FunctionCallContent( + call_id="2", name="mock_tool_requiring_approval", arguments='{"query": "test"}' + ), + ], + role="assistant", + ) + else: + yield ChatResponseUpdate( + contents=[ + FunctionCallContent( + call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}' + ) + ], + role="assistant", + ) + else: + yield ChatResponseUpdate(text=TextContent(text="Tool executed "), role="assistant") + yield ChatResponseUpdate(contents=[TextContent(text="successfully.")], role="assistant") + + self._iteration += 1 + + +@executor(id="test_executor") +async def test_executor(agent_executor_response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None: + await ctx.yield_output(agent_executor_response.agent_run_response.text) + + +async def test_agent_executor_tool_call_with_approval() -> None: + """Test that AgentExecutor handles tool calls requiring approval.""" + # Arrange + agent = ChatAgent( + chat_client=MockChatClient(), + name="ApprovalAgent", + tools=[mock_tool_requiring_approval], + ) + + workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build() + + # Act + events = await workflow.run("Invoke tool requiring approval") + + # Assert + assert len(events.get_request_info_events()) == 1 + approval_request = events.get_request_info_events()[0] + assert isinstance(approval_request.data, FunctionApprovalRequestContent) + assert approval_request.data.function_call.name == "mock_tool_requiring_approval" + assert approval_request.data.function_call.arguments == '{"query": "test"}' + + # Act + events = await workflow.send_responses({approval_request.request_id: approval_request.data.create_response(True)}) + + # Assert + final_response = events.get_outputs() + assert len(final_response) == 1 + assert final_response[0] == "Tool executed successfully." + + +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(), + name="ApprovalAgent", + tools=[mock_tool_requiring_approval], + ) + + workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build() + + # Act + request_info_events: list[RequestInfoEvent] = [] + async for event in workflow.run_stream("Invoke tool requiring approval"): + if isinstance(event, RequestInfoEvent): + request_info_events.append(event) + + # Assert + assert len(request_info_events) == 1 + approval_request = request_info_events[0] + assert isinstance(approval_request.data, FunctionApprovalRequestContent) + assert approval_request.data.function_call.name == "mock_tool_requiring_approval" + assert approval_request.data.function_call.arguments == '{"query": "test"}' + + # Act + output: str | None = None + async for event in workflow.send_responses_streaming({ + approval_request.request_id: approval_request.data.create_response(True) + }): + if isinstance(event, WorkflowOutputEvent): + output = event.data + + # Assert + assert output is not None + assert output == "Tool executed successfully." + + +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), + name="ApprovalAgent", + tools=[mock_tool_requiring_approval], + ) + + workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build() + + # Act + events = await workflow.run("Invoke tool requiring approval") + + # Assert + assert len(events.get_request_info_events()) == 2 + for approval_request in events.get_request_info_events(): + assert isinstance(approval_request.data, FunctionApprovalRequestContent) + assert approval_request.data.function_call.name == "mock_tool_requiring_approval" + assert approval_request.data.function_call.arguments == '{"query": "test"}' + + # Act + responses = { + approval_request.request_id: approval_request.data.create_response(True) # type: ignore + for approval_request in events.get_request_info_events() + } + events = await workflow.send_responses(responses) + + # Assert + final_response = events.get_outputs() + assert len(final_response) == 1 + assert final_response[0] == "Tool executed successfully." + + +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), + name="ApprovalAgent", + tools=[mock_tool_requiring_approval], + ) + + workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build() + + # Act + request_info_events: list[RequestInfoEvent] = [] + async for event in workflow.run_stream("Invoke tool requiring approval"): + if isinstance(event, RequestInfoEvent): + request_info_events.append(event) + + # Assert + assert len(request_info_events) == 2 + for approval_request in request_info_events: + assert isinstance(approval_request.data, FunctionApprovalRequestContent) + assert approval_request.data.function_call.name == "mock_tool_requiring_approval" + assert approval_request.data.function_call.arguments == '{"query": "test"}' + + # Act + responses = { + approval_request.request_id: approval_request.data.create_response(True) # type: ignore + for approval_request in request_info_events + } + + output: str | None = None + async for event in workflow.send_responses_streaming(responses): + if isinstance(event, WorkflowOutputEvent): + output = event.data + + # Assert + assert output is not None + assert output == "Tool executed successfully." diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index b6c2b262b4..eecf879516 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.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://github.com/microsoft/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index 82602e3d66..5b5a1eed5f 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.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index 696697511e..4e302d6446 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.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/purview/pyproject.toml b/python/packages/purview/pyproject.toml index 045e5fc717..d3abd18369 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.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://github.com/microsoft/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml index 7e14ff46c5..15307b1751 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.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/pyproject.toml b/python/pyproject.toml index 08d40fbcf2..80947c42be 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.0b251108" +version = "1.0.0b251111" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/samples/README.md b/python/samples/README.md index bb127b1349..330d6f03fc 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -281,6 +281,7 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen | File | Description | |------|-------------| | [`getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py`](./getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py) | Sample: Human in the loop guessing game | +| [`getting_started/workflows/human-in-the-loop/agents_with_approval_requests.py`](./getting_started/workflows/human-in-the-loop/agents_with_approval_requests.py) | Sample: Agents with Approval Requests in Workflows | ### Observability diff --git a/python/samples/demos/chatkit-integration/frontend/package-lock.json b/python/samples/demos/chatkit-integration/frontend/package-lock.json index 9cf6bb6b86..2a9ef09e64 100644 --- a/python/samples/demos/chatkit-integration/frontend/package-lock.json +++ b/python/samples/demos/chatkit-integration/frontend/package-lock.json @@ -17,7 +17,7 @@ "@types/react-dom": "^19.2.0", "@vitejs/plugin-react-swc": "^3.5.0", "typescript": "^5.4.0", - "vite": "^7.1.9" + "vite": "^7.1.12" }, "engines": { "node": ">=18.18", @@ -1328,9 +1328,9 @@ } }, "node_modules/vite": { - "version": "7.1.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", - "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", + "version": "7.1.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", + "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", "dependencies": { diff --git a/python/samples/demos/chatkit-integration/frontend/package.json b/python/samples/demos/chatkit-integration/frontend/package.json index 65d65d1d53..dadfc17382 100644 --- a/python/samples/demos/chatkit-integration/frontend/package.json +++ b/python/samples/demos/chatkit-integration/frontend/package.json @@ -22,6 +22,6 @@ "@types/react-dom": "^19.2.0", "@vitejs/plugin-react-swc": "^3.5.0", "typescript": "^5.4.0", - "vite": "^7.1.9" + "vite": "^7.1.12" } } \ No newline at end of file diff --git a/python/samples/getting_started/agents/openai/README.md b/python/samples/getting_started/agents/openai/README.md index ff4f46a84b..db71816558 100644 --- a/python/samples/getting_started/agents/openai/README.md +++ b/python/samples/getting_started/agents/openai/README.md @@ -23,6 +23,7 @@ This folder contains examples demonstrating different ways to create and use age | [`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_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_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_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. | diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_streaming_image_generation.py b/python/samples/getting_started/agents/openai/openai_responses_client_streaming_image_generation.py new file mode 100644 index 0000000000..2d74429917 --- /dev/null +++ b/python/samples/getting_started/agents/openai/openai_responses_client_streaming_image_generation.py @@ -0,0 +1,96 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import base64 + +import anyio +from agent_framework import DataContent +from agent_framework.openai import OpenAIResponsesClient + +"""OpenAI Responses Client Streaming Image Generation Example + +Demonstrates streaming partial image generation using OpenAI's image generation tool. +Shows progressive image rendering with partial images for improved user experience. + +Note: The number of partial images received depends on generation speed: +- High quality/complex images: More partials (generation takes longer) +- Low quality/simple images: Fewer partials (generation completes quickly) +- You may receive fewer partial images than requested if generation is fast + +Important: The final partial image IS the complete, full-quality image. Each partial +represents a progressive refinement, with the last one being the finished result. +""" + + +async def save_image_from_data_uri(data_uri: str, filename: str) -> None: + """Save an image from a data URI to a file.""" + try: + if data_uri.startswith("data:image/"): + # Extract base64 data + base64_data = data_uri.split(",", 1)[1] + image_bytes = base64.b64decode(base64_data) + + # Save to file + await anyio.Path(filename).write_bytes(image_bytes) + print(f" Saved: {filename} ({len(image_bytes) / 1024:.1f} KB)") + except Exception as e: + print(f" Error saving {filename}: {e}") + + +async def main(): + """Demonstrate streaming image generation with partial images.""" + print("=== OpenAI Streaming Image Generation Example ===\n") + + # Create agent with streaming image generation enabled + agent = OpenAIResponsesClient().create_agent( + instructions="You are a helpful agent that can generate images.", + tools=[ + { + "type": "image_generation", + "size": "1024x1024", + "quality": "high", + "partial_images": 3, + } + ], + ) + + query = "Draw a beautiful sunset over a calm ocean with sailboats" + print(f" User: {query}") + print() + + # Track partial images + image_count = 0 + + # Create output directory + output_dir = anyio.Path("generated_images") + await output_dir.mkdir(exist_ok=True) + + print(" Streaming response:") + async for update in agent.run_stream(query): + for content in update.contents: + # 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 isinstance(content, DataContent) 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") + extension = "png" # Default fallback + if content.media_type and "/" in content.media_type: + extension = content.media_type.split("/")[-1] + + # Save images with correct extension + filename = output_dir / f"image{image_count}.{extension}" + await save_image_from_data_uri(content.uri, str(filename)) + + image_count += 1 + + # Summary + print("\n Summary:") + print(f" Images received: {image_count}") + print(" Output directory: generated_images") + print("\n Streaming image generation completed!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/README.md b/python/samples/getting_started/workflows/README.md index 503a8aacd7..ffbeedd751 100644 --- a/python/samples/getting_started/workflows/README.md +++ b/python/samples/getting_started/workflows/README.md @@ -78,6 +78,7 @@ 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 | | Azure Agents Tool Feedback Loop | [agents/azure_chat_agents_tool_calls_with_feedback.py](./agents/azure_chat_agents_tool_calls_with_feedback.py) | Two-agent workflow that streams tool calls and pauses for human guidance between passes | +| 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 | ### observability diff --git a/python/samples/getting_started/workflows/human-in-the-loop/agents_with_approval_requests.py b/python/samples/getting_started/workflows/human-in-the-loop/agents_with_approval_requests.py new file mode 100644 index 0000000000..a51088e886 --- /dev/null +++ b/python/samples/getting_started/workflows/human-in-the-loop/agents_with_approval_requests.py @@ -0,0 +1,340 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import json +from dataclasses import dataclass +from typing import Annotated, Never + +from agent_framework import ( + AgentExecutorResponse, + ChatMessage, + Executor, + FunctionApprovalRequestContent, + FunctionApprovalResponseContent, + WorkflowBuilder, + WorkflowContext, + ai_function, + executor, + handler, +) +from agent_framework.openai import OpenAIChatClient + +""" +Sample: Agents in a workflow with AI functions requiring approval + +This sample creates a workflow that automatically replies to incoming emails. +If historical email data is needed, it uses an AI function to read the data, +which requires human approval before execution. + +This sample works as follows: +1. An incoming email is received by the workflow. +2. The EmailPreprocessor executor preprocesses the email, adding special notes if the sender is important. +3. The preprocessed email is sent to the Email Writer agent, which generates a response. +4. If the agent needs to read historical email data, it calls the read_historical_email_data AI function, + which triggers an approval request. +5. The sample automatically approves the request for demonstration purposes. +6. Once approved, the AI function executes and returns the historical email data to the agent. +7. The agent uses the historical data to compose a comprehensive email response. +8. The response is sent to the conclude_workflow_executor, which yields the final response. + +Purpose: +Show how to integrate AI functions with approval requests into a workflow. + +Demonstrate: +- Creating AI functions that require approval before execution. +- Building a workflow that includes an agent and executors. +- Handling approval requests during workflow execution. + +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, RequestInfoEvent, and streaming runs. +""" + + +@ai_function +def get_current_date() -> str: + """Get the current date in YYYY-MM-DD format.""" + # For demonstration purposes, we return a fixed date. + return "2025-11-07" + + +@ai_function +def get_team_members_email_addresses() -> list[dict[str, str]]: + """Get the email addresses of team members.""" + # In a real implementation, this might query a database or directory service. + return [ + { + "name": "Alice", + "email": "alice@contoso.com", + "position": "Software Engineer", + "manager": "John Doe", + }, + { + "name": "Bob", + "email": "bob@contoso.com", + "position": "Product Manager", + "manager": "John Doe", + }, + { + "name": "Charlie", + "email": "charlie@contoso.com", + "position": "Senior Software Engineer", + "manager": "John Doe", + }, + { + "name": "Mike", + "email": "mike@contoso.com", + "position": "Principal Software Engineer Manager", + "manager": "VP of Engineering", + }, + ] + + +@ai_function +def get_my_information() -> dict[str, str]: + """Get my personal information.""" + return { + "name": "John Doe", + "email": "john@contoso.com", + "position": "Software Engineer Manager", + "manager": "Mike", + } + + +@ai_function(approval_mode="always_require") +async def read_historical_email_data( + email_address: Annotated[str, "The email address to read historical data from"], + start_date: Annotated[str, "The start date in YYYY-MM-DD format"], + end_date: Annotated[str, "The end date in YYYY-MM-DD format"], +) -> list[dict[str, str]]: + """Read historical email data for a given email address and date range.""" + historical_data = { + "alice@contoso.com": [ + { + "from": "alice@contoso.com", + "to": "john@contoso.com", + "date": "2025-11-05", + "subject": "Bug Bash Results", + "body": "We just completed the bug bash and found a few issues that need immediate attention.", + }, + { + "from": "alice@contoso.com", + "to": "john@contoso.com", + "date": "2025-11-03", + "subject": "Code Freeze", + "body": "We are entering code freeze starting tomorrow.", + }, + ], + "bob@contoso.com": [ + { + "from": "bob@contoso.com", + "to": "john@contoso.com", + "date": "2025-11-04", + "subject": "Team Outing", + "body": "Don't forget about the team outing this Friday!", + }, + { + "from": "bob@contoso.com", + "to": "john@contoso.com", + "date": "2025-11-02", + "subject": "Requirements Update", + "body": "The requirements for the new feature have been updated. Please review them.", + }, + ], + "charlie@contoso.com": [ + { + "from": "charlie@contoso.com", + "to": "john@contoso.com", + "date": "2025-11-05", + "subject": "Project Update", + "body": "The bug bash went well. A few critical bugs but should be fixed by the end of the week.", + }, + { + "from": "charlie@contoso.com", + "to": "john@contoso.com", + "date": "2025-11-06", + "subject": "Code Review", + "body": "Please review my latest code changes.", + }, + ], + } + + emails = historical_data.get(email_address, []) + return [email for email in emails if start_date <= email["date"] <= end_date] + + +@ai_function(approval_mode="always_require") +async def send_email( + to: Annotated[str, "The recipient email address"], + subject: Annotated[str, "The email subject"], + body: Annotated[str, "The email body"], +) -> str: + """Send an email.""" + await asyncio.sleep(1) # Simulate sending email + return "Email successfully sent." + + +@dataclass +class Email: + sender: str + subject: str + body: str + + +class EmailPreprocessor(Executor): + def __init__(self, special_email_addresses: set[str]) -> None: + super().__init__(id="email_preprocessor") + self.special_email_addresses = special_email_addresses + + @handler + async def preprocess(self, email: Email, ctx: WorkflowContext[str]) -> None: + """Preprocess the incoming email.""" + message = str(email) + 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." + ) + message = f"{note}\n\n{message}" + + await ctx.send_message(message) + + +@executor(id="conclude_workflow_executor") +async def conclude_workflow( + email_response: AgentExecutorResponse, + ctx: WorkflowContext[Never, str], +) -> None: + """Conclude the workflow by yielding the final email response.""" + await ctx.yield_output(email_response.agent_run_response.text) + + +async def main() -> None: + # Create the agent and executors + chat_client = OpenAIChatClient() + email_writer = chat_client.create_agent( + name="Email Writer", + instructions=("You are an excellent email assistant. You respond to incoming emails."), + # tools with `approval_mode="always_require"` will trigger approval requests + tools=[ + read_historical_email_data, + send_email, + get_current_date, + get_team_members_email_addresses, + get_my_information, + ], + ) + email_preprocessor = EmailPreprocessor(special_email_addresses={"mike@contoso.com"}) + + # Build the workflow + workflow = ( + WorkflowBuilder() + .set_start_executor(email_preprocessor) + .add_edge(email_preprocessor, email_writer) + .add_edge(email_writer, conclude_workflow) + .build() + ) + + # Simulate an incoming email + incoming_email = Email( + sender="mike@contoso.com", + subject="Important: Project Update", + body="Please provide your team's status update on the project since last week.", + ) + + responses: dict[str, FunctionApprovalResponseContent] = {} + output: list[ChatMessage] | None = None + while True: + if responses: + events = await workflow.send_responses(responses) + responses.clear() + else: + events = await workflow.run(incoming_email) + + request_info_events = events.get_request_info_events() + for request_info_event in request_info_events: + # We should only expect FunctionApprovalRequestContent in this sample + if not isinstance(request_info_event.data, FunctionApprovalRequestContent): + raise ValueError(f"Unexpected request info content type: {type(request_info_event.data)}") + + # Pretty print the function call details + arguments = json.dumps(request_info_event.data.function_call.parse_arguments(), indent=2) + print( + f"Received approval request for function: {request_info_event.data.function_call.name} " + f"with args:\n{arguments}" + ) + + # For demo purposes, we automatically approve the request + # The expected response type of the request is `FunctionApprovalResponseContent`, + # which can be created via `create_response` method on the request content + print("Performing automatic approval for demo purposes...") + responses[request_info_event.request_id] = request_info_event.data.create_response(approved=True) + + # Once we get an output event, we can conclude the workflow + # Outputs can only be produced by the conclude_workflow_executor in this sample + if outputs := events.get_outputs(): + # We expect only one output from the conclude_workflow_executor + output = outputs[0] + break + + if not output: + raise RuntimeError("Workflow did not produce any output event.") + + print("Final email response conversation:") + print(output) + + """ + Sample Output: + Received approval request for function: read_historical_email_data with args: + { + "email_address": "alice@contoso.com", + "start_date": "2025-10-31", + "end_date": "2025-11-07" + } + Performing automatic approval for demo purposes... + Received approval request for function: read_historical_email_data with args: + { + "email_address": "bob@contoso.com", + "start_date": "2025-10-31", + "end_date": "2025-11-07" + } + Performing automatic approval for demo purposes... + Received approval request for function: read_historical_email_data with args: + { + "email_address": "charlie@contoso.com", + "start_date": "2025-10-31", + "end_date": "2025-11-07" + } + Performing automatic approval for demo purposes... + Received approval request for function: send_email with args: + { + "to": "mike@contoso.com", + "subject": "Team's Status Update on the Project", + "body": " + Hi Mike, + + Here's the status update from our team: + - **Bug Bash and Code Freeze:** + - We recently completed a bug bash, during which several issues were identified. Alice and Charlie are working on fixing these critical bugs, and we anticipate resolving them by the end of this week. + - We have entered a code freeze as of November 4, 2025. + + - **Requirements Update:** + - Bob has updated the requirements for a new feature, and all team members are reviewing these changes to ensure alignment. + + - **Ongoing Reviews:** + - Charlie has submitted his latest code changes for review to ensure they meet our quality standards. + + Please let me know if you need more detailed information or have any questions. + + Best regards, + John" + } + Performing automatic approval for demo purposes... + Final email response conversation: + I've sent the status update to Mike with the relevant information from the team. Let me know if there's anything else you need + """ # noqa: E501 + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/uv.lock b/python/uv.lock index 17098dd83f..5f3ea7b28f 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -87,7 +87,7 @@ wheels = [ [[package]] name = "agent-framework" -version = "1.0.0b251108" +version = "1.0.0b251111" source = { virtual = "." } dependencies = [ { name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -178,7 +178,7 @@ docs = [ [[package]] name = "agent-framework-a2a" -version = "1.0.0b251108" +version = "1.0.0b251111" source = { editable = "packages/a2a" } dependencies = [ { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -193,7 +193,7 @@ requires-dist = [ [[package]] name = "agent-framework-ag-ui" -version = "1.0.0b251108" +version = "1.0.0b251111" source = { editable = "packages/ag-ui" } dependencies = [ { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -223,7 +223,7 @@ provides-extras = ["dev"] [[package]] name = "agent-framework-anthropic" -version = "1.0.0b251108" +version = "1.0.0b251111" source = { editable = "packages/anthropic" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -238,7 +238,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-ai" -version = "1.0.0b251108" +version = "1.0.0b251111" source = { editable = "packages/azure-ai" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -257,7 +257,7 @@ requires-dist = [ [[package]] name = "agent-framework-chatkit" -version = "1.0.0b251108" +version = "1.0.0b251111" source = { editable = "packages/chatkit" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -272,7 +272,7 @@ requires-dist = [ [[package]] name = "agent-framework-copilotstudio" -version = "1.0.0b251108" +version = "1.0.0b251111" source = { editable = "packages/copilotstudio" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -287,7 +287,7 @@ requires-dist = [ [[package]] name = "agent-framework-core" -version = "1.0.0b251108" +version = "1.0.0b251111" source = { editable = "packages/core" } dependencies = [ { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -343,7 +343,7 @@ provides-extras = ["all"] [[package]] name = "agent-framework-devui" -version = "1.0.0b251108" +version = "1.0.0b251111" source = { editable = "packages/devui" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -377,7 +377,7 @@ provides-extras = ["dev", "all"] [[package]] name = "agent-framework-lab" -version = "1.0.0b251108" +version = "1.0.0b251111" source = { editable = "packages/lab" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -468,7 +468,7 @@ dev = [ [[package]] name = "agent-framework-mem0" -version = "1.0.0b251108" +version = "1.0.0b251111" source = { editable = "packages/mem0" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -483,7 +483,7 @@ requires-dist = [ [[package]] name = "agent-framework-purview" -version = "1.0.0b251108" +version = "1.0.0b251111" source = { editable = "packages/purview" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -500,7 +500,7 @@ requires-dist = [ [[package]] name = "agent-framework-redis" -version = "1.0.0b251108" +version = "1.0.0b251111" source = { editable = "packages/redis" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, diff --git a/workflow-samples/DeepResearch.yaml b/workflow-samples/DeepResearch.yaml index 4e13b5b9f3..4949680c56 100644 --- a/workflow-samples/DeepResearch.yaml +++ b/workflow-samples/DeepResearch.yaml @@ -119,13 +119,13 @@ trigger: # FACTS Consider this initial fact sheet: - {Trim(Last(Local.TaskFacts).Text)} + {MessageText(Local.TaskFacts)} # PLAN Here is the plan to follow as best as possible: - {Last(Local.Plan).Text} + {MessageText(Local.Plan)} - kind: SendActivity id: sendActivity_bwNZiM @@ -247,7 +247,7 @@ trigger: Here is the old fact sheet: - {Local.TaskFacts}" + {MessageText(Local.TaskFacts)}" - kind: SendActivity id: sendActivity_dsBaJU @@ -291,13 +291,13 @@ trigger: # FACTS Consider this initial fact sheet: - {Local.TaskFacts.Text} + {MessageText(Local.TaskFacts)} # PLAN Here is the plan to follow as best as possible: - {Local.Plan.Text} + {MessageText(Local.Plan)} - kind: SetVariable id: setVariable_6J2snP @@ -356,7 +356,7 @@ trigger: - kind: SetVariable id: setVariable_XzNrdM variable: Local.AgentResponseText - value: =Last(Local.AgentResponse).Text + value: =MessageText(Local.AgentResponse) - kind: ResetVariable id: setVariable_8eIx2A diff --git a/workflow-samples/MathChat.yaml b/workflow-samples/MathChat.yaml index 855d968546..363256efc3 100644 --- a/workflow-samples/MathChat.yaml +++ b/workflow-samples/MathChat.yaml @@ -35,7 +35,7 @@ trigger: id: check_completion conditions: - - condition: =!IsBlank(Find("CONGRATULATIONS", Upper(Last(Local.TeacherResponse).Text))) + - condition: =!IsBlank(Find("CONGRATULATIONS", Upper(MessageText(Local.TeacherResponse)))) id: check_turn_done actions: diff --git a/workflow-samples/README.md b/workflow-samples/README.md index 539ce1a253..a7bed697e5 100644 --- a/workflow-samples/README.md +++ b/workflow-samples/README.md @@ -6,7 +6,7 @@ may be executed locally no different from any regular `Workflow` that is defined The difference is that the workflow definition is loaded from a YAML file instead of being defined in code: ```c# -Workflow workflow = DeclarativeWorkflowBuilder.Build("HelloWorld.yaml", options); +Workflow workflow = DeclarativeWorkflowBuilder.Build("Marketing.yaml", options); ``` These example workflows may be executed by the workflow